diff --git a/api/dbv1/get_sitemap.sql.go b/api/dbv1/get_sitemap.sql.go new file mode 100644 index 00000000..c7611598 --- /dev/null +++ b/api/dbv1/get_sitemap.sql.go @@ -0,0 +1,207 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: get_sitemap.sql + +package dbv1 + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getSitemapPlaylistCount = `-- name: GetSitemapPlaylistCount :one +SELECT count(*) as count +FROM playlist_routes pr +JOIN playlists p ON pr.playlist_id = p.playlist_id +JOIN users u ON pr.owner_id = u.user_id +JOIN aggregate_user au ON u.user_id = au.user_id +WHERE u.is_current = true + AND pr.is_current = true + AND p.is_current = true + AND p.is_private = false + AND p.is_delete = false + AND au.follower_count >= 10 +` + +func (q *Queries) GetSitemapPlaylistCount(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, getSitemapPlaylistCount) + var count int64 + err := row.Scan(&count) + return count, err +} + +const getSitemapPlaylistSlugs = `-- name: GetSitemapPlaylistSlugs :many +SELECT u.handle, pr.slug, p.is_album +FROM playlist_routes pr +JOIN playlists p ON pr.playlist_id = p.playlist_id +JOIN users u ON pr.owner_id = u.user_id +JOIN aggregate_user au ON u.user_id = au.user_id +WHERE u.is_current = true + AND pr.is_current = true + AND p.is_current = true + AND p.is_private = false + AND p.is_delete = false + AND au.follower_count >= 10 +ORDER BY p.playlist_id ASC +LIMIT $1 OFFSET $2 +` + +type GetSitemapPlaylistSlugsParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +type GetSitemapPlaylistSlugsRow struct { + Handle pgtype.Text `json:"handle"` + Slug string `json:"slug"` + IsAlbum bool `json:"is_album"` +} + +func (q *Queries) GetSitemapPlaylistSlugs(ctx context.Context, arg GetSitemapPlaylistSlugsParams) ([]GetSitemapPlaylistSlugsRow, error) { + rows, err := q.db.Query(ctx, getSitemapPlaylistSlugs, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetSitemapPlaylistSlugsRow + for rows.Next() { + var i GetSitemapPlaylistSlugsRow + if err := rows.Scan(&i.Handle, &i.Slug, &i.IsAlbum); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getSitemapTrackCount = `-- name: GetSitemapTrackCount :one +SELECT count(*) as count +FROM track_routes tr +JOIN tracks t ON tr.track_id = t.track_id +JOIN users u ON tr.owner_id = u.user_id +JOIN aggregate_user au ON u.user_id = au.user_id +WHERE t.is_current = true + AND t.stem_of IS NULL + AND t.is_unlisted = false + AND t.is_available = true + AND t.is_delete = false + AND u.is_current = true + AND tr.is_current = true + AND au.follower_count >= 10 +` + +func (q *Queries) GetSitemapTrackCount(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, getSitemapTrackCount) + var count int64 + err := row.Scan(&count) + return count, err +} + +const getSitemapTrackSlugs = `-- name: GetSitemapTrackSlugs :many +SELECT u.handle, tr.slug +FROM track_routes tr +JOIN tracks t ON tr.track_id = t.track_id +JOIN users u ON tr.owner_id = u.user_id +JOIN aggregate_user au ON u.user_id = au.user_id +WHERE t.is_current = true + AND t.stem_of IS NULL + AND t.is_unlisted = false + AND t.is_available = true + AND t.is_delete = false + AND u.is_current = true + AND tr.is_current = true + AND au.follower_count >= 10 +ORDER BY t.track_id ASC +LIMIT $1 OFFSET $2 +` + +type GetSitemapTrackSlugsParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +type GetSitemapTrackSlugsRow struct { + Handle pgtype.Text `json:"handle"` + Slug string `json:"slug"` +} + +func (q *Queries) GetSitemapTrackSlugs(ctx context.Context, arg GetSitemapTrackSlugsParams) ([]GetSitemapTrackSlugsRow, error) { + rows, err := q.db.Query(ctx, getSitemapTrackSlugs, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetSitemapTrackSlugsRow + for rows.Next() { + var i GetSitemapTrackSlugsRow + if err := rows.Scan(&i.Handle, &i.Slug); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getSitemapUserCount = `-- name: GetSitemapUserCount :one +SELECT count(*) as count +FROM users u +JOIN aggregate_user au ON u.user_id = au.user_id +WHERE u.is_current = true + AND u.is_deactivated = false + AND u.handle_lc IS NOT NULL + AND u.is_available = true + AND au.follower_count >= 10 +` + +func (q *Queries) GetSitemapUserCount(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, getSitemapUserCount) + var count int64 + err := row.Scan(&count) + return count, err +} + +const getSitemapUserSlugs = `-- name: GetSitemapUserSlugs :many +SELECT u.handle +FROM users u +JOIN aggregate_user au ON u.user_id = au.user_id +WHERE u.is_current = true + AND u.is_deactivated = false + AND u.handle_lc IS NOT NULL + AND u.is_available = true + AND au.follower_count >= 10 +ORDER BY u.user_id ASC +LIMIT $1 OFFSET $2 +` + +type GetSitemapUserSlugsParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) GetSitemapUserSlugs(ctx context.Context, arg GetSitemapUserSlugsParams) ([]pgtype.Text, error) { + rows, err := q.db.Query(ctx, getSitemapUserSlugs, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []pgtype.Text + for rows.Next() { + var handle pgtype.Text + if err := rows.Scan(&handle); err != nil { + return nil, err + } + items = append(items, handle) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/api/dbv1/queries/get_sitemap.sql b/api/dbv1/queries/get_sitemap.sql new file mode 100644 index 00000000..a601f6d7 --- /dev/null +++ b/api/dbv1/queries/get_sitemap.sql @@ -0,0 +1,81 @@ +-- name: GetSitemapTrackCount :one +SELECT count(*) as count +FROM track_routes tr +JOIN tracks t ON tr.track_id = t.track_id +JOIN users u ON tr.owner_id = u.user_id +JOIN aggregate_user au ON u.user_id = au.user_id +WHERE t.is_current = true + AND t.stem_of IS NULL + AND t.is_unlisted = false + AND t.is_available = true + AND t.is_delete = false + AND u.is_current = true + AND tr.is_current = true + AND au.follower_count >= 10; + +-- name: GetSitemapTrackSlugs :many +SELECT u.handle, tr.slug +FROM track_routes tr +JOIN tracks t ON tr.track_id = t.track_id +JOIN users u ON tr.owner_id = u.user_id +JOIN aggregate_user au ON u.user_id = au.user_id +WHERE t.is_current = true + AND t.stem_of IS NULL + AND t.is_unlisted = false + AND t.is_available = true + AND t.is_delete = false + AND u.is_current = true + AND tr.is_current = true + AND au.follower_count >= 10 +ORDER BY t.track_id ASC +LIMIT $1 OFFSET $2; + +-- name: GetSitemapPlaylistCount :one +SELECT count(*) as count +FROM playlist_routes pr +JOIN playlists p ON pr.playlist_id = p.playlist_id +JOIN users u ON pr.owner_id = u.user_id +JOIN aggregate_user au ON u.user_id = au.user_id +WHERE u.is_current = true + AND pr.is_current = true + AND p.is_current = true + AND p.is_private = false + AND p.is_delete = false + AND au.follower_count >= 10; + +-- name: GetSitemapPlaylistSlugs :many +SELECT u.handle, pr.slug, p.is_album +FROM playlist_routes pr +JOIN playlists p ON pr.playlist_id = p.playlist_id +JOIN users u ON pr.owner_id = u.user_id +JOIN aggregate_user au ON u.user_id = au.user_id +WHERE u.is_current = true + AND pr.is_current = true + AND p.is_current = true + AND p.is_private = false + AND p.is_delete = false + AND au.follower_count >= 10 +ORDER BY p.playlist_id ASC +LIMIT $1 OFFSET $2; + +-- name: GetSitemapUserCount :one +SELECT count(*) as count +FROM users u +JOIN aggregate_user au ON u.user_id = au.user_id +WHERE u.is_current = true + AND u.is_deactivated = false + AND u.handle_lc IS NOT NULL + AND u.is_available = true + AND au.follower_count >= 10; + +-- name: GetSitemapUserSlugs :many +SELECT u.handle +FROM users u +JOIN aggregate_user au ON u.user_id = au.user_id +WHERE u.is_current = true + AND u.is_deactivated = false + AND u.handle_lc IS NOT NULL + AND u.is_available = true + AND au.follower_count >= 10 +ORDER BY u.user_id ASC +LIMIT $1 OFFSET $2; diff --git a/api/server.go b/api/server.go index 5029e2d4..d053f87c 100644 --- a/api/server.go +++ b/api/server.go @@ -351,6 +351,12 @@ func NewApiServer(config config.Config) *ApiServer { // Archiver proxy app.All("/archive/*", archiveProxy) + // Sitemaps + app.Get("/sitemaps/default.xml", app.sitemapDefault) + app.Get("/sitemaps/defaults.xml", app.sitemapDefaults) + app.Get("/sitemaps/:type/index.xml", app.sitemapTypeIndex) + app.Get("/sitemaps/:type/:fileName", app.sitemapTypePage) + // resolve myId app.Use(app.isFullMiddleware) app.Use(app.resolveMyIdMiddleware) diff --git a/api/v1_sitemaps.go b/api/v1_sitemaps.go new file mode 100644 index 00000000..0ec6f697 --- /dev/null +++ b/api/v1_sitemaps.go @@ -0,0 +1,333 @@ +package api + +import ( + "context" + "encoding/xml" + "fmt" + "net/url" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "api.audius.co/api/dbv1" + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" +) + +const sitemapLimit = 40_000 +const sitemapCountCacheTTL = 1 * time.Hour + +var sitemapPageRegex = regexp.MustCompile(`^(\d+)\.xml$`) + +var defaultRoutes = []string{ + "legal/privacy-policy", + "legal/terms-of-use", + "download", + "feed", + "trending", + "explore", + "upload", + "favorites", + "history", + "messages", + "dashboard", + "explore/playlists", + "explore/top-albums", + "explore/remixables", + "explore/feeling-lucky", + "explore/chill", + "explore/upbeat", + "explore/intense", + "explore/provoking", + "explore/intimate", + "signup", + "signin", + "audio", + "settings", +} + +// cachedCount stores a count value with an expiration time. +type cachedCount struct { + value int64 + expiresAt time.Time + refreshing bool // true if a background refresh is already in flight +} + +var ( + sitemapCountCache = make(map[string]cachedCount) + sitemapCountMu sync.Mutex +) + +// getCachedCount returns the cached value and whether any value exists (even stale). +// If the entry is expired, it returns the stale value with needsRefresh=true. +func getCachedCount(key string) (value int64, exists bool, needsRefresh bool) { + sitemapCountMu.Lock() + defer sitemapCountMu.Unlock() + entry, ok := sitemapCountCache[key] + if !ok { + return 0, false, false + } + expired := time.Now().After(entry.expiresAt) + if expired && !entry.refreshing { + entry.refreshing = true + sitemapCountCache[key] = entry + return entry.value, true, true + } + return entry.value, true, false +} + +func setCachedCount(key string, value int64) { + sitemapCountMu.Lock() + defer sitemapCountMu.Unlock() + sitemapCountCache[key] = cachedCount{ + value: value, + expiresAt: time.Now().Add(sitemapCountCacheTTL), + } +} + +type sitemapURL struct { + XMLName xml.Name `xml:"url"` + Loc string `xml:"loc"` +} + +type sitemapURLSet struct { + XMLName xml.Name `xml:"urlset"` + Xmlns string `xml:"xmlns,attr"` + URLs []sitemapURL `xml:"url"` +} + +type sitemapEntry struct { + XMLName xml.Name `xml:"sitemap"` + Loc string `xml:"loc"` +} + +type sitemapIndex struct { + XMLName xml.Name `xml:"sitemapindex"` + Xmlns string `xml:"xmlns,attr"` + Sitemaps []sitemapEntry `xml:"sitemap"` +} + +// escapePathSegments escapes each segment of a path individually, +// preserving the "/" separators. +func escapePathSegments(path string) string { + parts := strings.Split(path, "/") + for i, p := range parts { + parts[i] = url.PathEscape(p) + } + return strings.Join(parts, "/") +} + +func buildURLSet(urls []string, baseURL string) ([]byte, error) { + set := sitemapURLSet{ + Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9", + } + for _, u := range urls { + set.URLs = append(set.URLs, sitemapURL{Loc: baseURL + "/" + escapePathSegments(u)}) + } + out, err := xml.MarshalIndent(set, "", " ") + if err != nil { + return nil, err + } + return append([]byte(xml.Header), out...), nil +} + +func buildSitemapIndex(entries []sitemapEntry) ([]byte, error) { + idx := sitemapIndex{ + Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9", + Sitemaps: entries, + } + out, err := xml.MarshalIndent(idx, "", " ") + if err != nil { + return nil, err + } + return append([]byte(xml.Header), out...), nil +} + +func numPages(count int64, limit int) int { + if count == 0 { + return 1 + } + pages := int(count) / limit + if int(count)%limit != 0 { + pages++ + } + return pages +} + +func (app *ApiServer) fetchSitemapCount(entityType string) (int64, error) { + var count int64 + var err error + switch entityType { + case "track": + count, err = app.queries.GetSitemapTrackCount(context.Background()) + case "playlist": + count, err = app.queries.GetSitemapPlaylistCount(context.Background()) + case "user": + count, err = app.queries.GetSitemapUserCount(context.Background()) + } + if err != nil { + return 0, err + } + setCachedCount(entityType, count) + return count, nil +} + +func (app *ApiServer) getSitemapCount(c *fiber.Ctx, entityType string) (int64, error) { + cached, exists, needsRefresh := getCachedCount(entityType) + + if needsRefresh { + // Return stale value immediately, refresh in background + go func() { + if _, err := app.fetchSitemapCount(entityType); err != nil { + app.logger.Error("failed to refresh sitemap count", zap.String("type", entityType), zap.Error(err)) + } + }() + return cached, nil + } + + if exists { + return cached, nil + } + + // First request ever — must block + return app.fetchSitemapCount(entityType) +} + +// sitemapDefault serves the root sitemap index that references the static default +// sitemap plus all entity type indexes, so crawlers can discover everything. +func (app *ApiServer) sitemapDefault(c *fiber.Ctx) error { + base := app.audiusAppUrl + entries := []sitemapEntry{ + {Loc: base + "/sitemaps/defaults.xml"}, + {Loc: base + "/sitemaps/track/index.xml"}, + {Loc: base + "/sitemaps/playlist/index.xml"}, + {Loc: base + "/sitemaps/user/index.xml"}, + } + data, err := buildSitemapIndex(entries) + if err != nil { + return err + } + c.Set("Content-Type", "text/xml") + return c.Send(data) +} + +// sitemapDefaults serves the static default routes urlset. +func (app *ApiServer) sitemapDefaults(c *fiber.Ctx) error { + data, err := buildURLSet(defaultRoutes, app.audiusAppUrl) + if err != nil { + return err + } + c.Set("Content-Type", "text/xml") + return c.Send(data) +} + +func (app *ApiServer) sitemapTypeIndex(c *fiber.Ctx) error { + entityType := c.Params("type") + + switch entityType { + case "track", "playlist", "user": + default: + return fiber.NewError(400, fmt.Sprintf("Invalid sitemap type %s, should be one of track, playlist, user", entityType)) + } + + count, err := app.getSitemapCount(c, entityType) + if err != nil { + return err + } + + pages := numPages(count, sitemapLimit) + entries := make([]sitemapEntry, pages) + for i := 1; i <= pages; i++ { + entries[i-1] = sitemapEntry{ + Loc: fmt.Sprintf("%s/sitemaps/%s/%d.xml", app.audiusAppUrl, entityType, i), + } + } + + data, err := buildSitemapIndex(entries) + if err != nil { + return err + } + c.Set("Content-Type", "text/xml") + return c.Send(data) +} + +func (app *ApiServer) sitemapTypePage(c *fiber.Ctx) error { + entityType := c.Params("type") + fileName := c.Params("fileName") + + matches := sitemapPageRegex.FindStringSubmatch(fileName) + if matches == nil { + return fiber.NewError(400, fmt.Sprintf("Invalid filepath %s, should be of format .xml", fileName)) + } + pageNumber, _ := strconv.Atoi(matches[1]) + if pageNumber < 1 { + return fiber.NewError(400, "Page number must be >= 1") + } + + offset := int32((pageNumber - 1) * sitemapLimit) + ctx := c.Context() + baseURL := app.audiusAppUrl + + var slugs []string + var err error + + switch entityType { + case "track": + rows, e := app.queries.GetSitemapTrackSlugs(ctx, dbv1.GetSitemapTrackSlugsParams{ + Limit: int32(sitemapLimit), + Offset: offset, + }) + if e != nil { + return e + } + for _, r := range rows { + if r.Handle.Valid { + slugs = append(slugs, r.Handle.String+"/"+r.Slug) + } + } + + case "playlist": + rows, e := app.queries.GetSitemapPlaylistSlugs(ctx, dbv1.GetSitemapPlaylistSlugsParams{ + Limit: int32(sitemapLimit), + Offset: offset, + }) + if e != nil { + return e + } + for _, r := range rows { + if r.Handle.Valid { + kind := "playlist" + if r.IsAlbum { + kind = "album" + } + slugs = append(slugs, r.Handle.String+"/"+kind+"/"+r.Slug) + } + } + + case "user": + handles, e := app.queries.GetSitemapUserSlugs(ctx, dbv1.GetSitemapUserSlugsParams{ + Limit: int32(sitemapLimit), + Offset: offset, + }) + if e != nil { + return e + } + for _, h := range handles { + if h.Valid { + slugs = append(slugs, h.String) + } + } + + default: + return fiber.NewError(400, fmt.Sprintf("Invalid sitemap type %s, should be one of track, playlist, user", entityType)) + } + + data, err := buildURLSet(slugs, baseURL) + if err != nil { + return err + } + c.Set("Content-Type", "text/xml") + return c.Send(data) +} diff --git a/api/v1_sitemaps_test.go b/api/v1_sitemaps_test.go new file mode 100644 index 00000000..c7a27f51 --- /dev/null +++ b/api/v1_sitemaps_test.go @@ -0,0 +1,164 @@ +package api + +import ( + "strings" + "testing" + + "api.audius.co/database" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func sitemapTestApp(t *testing.T) *ApiServer { + app := emptyTestApp(t) + + // Users: user 1 has 100 followers (>=10, included), user 2 has 5 (<10, excluded), user 3 is deactivated (excluded) + database.Seed(app.pool.Replicas[0], database.FixtureMap{ + "users": { + {"user_id": 1, "handle": "artist1", "handle_lc": "artist1", "is_deactivated": false}, + {"user_id": 2, "handle": "smallartist", "handle_lc": "smallartist", "is_deactivated": false}, + {"user_id": 3, "handle": "gone", "handle_lc": "gone", "is_deactivated": true}, + }, + "aggregate_user": { + {"user_id": 1, "follower_count": 100}, + {"user_id": 2, "follower_count": 5}, + {"user_id": 3, "follower_count": 200}, + }, + "tracks": { + {"track_id": 1, "owner_id": 1, "title": "Listed Track", "is_unlisted": "f"}, + {"track_id": 2, "owner_id": 1, "title": "Unlisted Track", "is_unlisted": "t"}, + {"track_id": 3, "owner_id": 2, "title": "Small Artist Track", "is_unlisted": "f"}, + {"track_id": 4, "owner_id": 1, "title": "Deleted Track", "is_unlisted": "f", "is_delete": true}, + }, + "track_routes": { + {"slug": "listed-track", "title_slug": "listed-track", "collision_id": 0, "owner_id": 1, "track_id": 1}, + {"slug": "unlisted-track", "title_slug": "unlisted-track", "collision_id": 0, "owner_id": 1, "track_id": 2}, + {"slug": "small-artist-track", "title_slug": "small-artist-track", "collision_id": 0, "owner_id": 2, "track_id": 3}, + {"slug": "deleted-track", "title_slug": "deleted-track", "collision_id": 0, "owner_id": 1, "track_id": 4}, + }, + "playlists": { + {"playlist_id": 1, "playlist_owner_id": 1, "playlist_name": "My Playlist", "is_album": false, "is_private": false, "playlist_contents": "{}"}, + {"playlist_id": 2, "playlist_owner_id": 1, "playlist_name": "My Album", "is_album": true, "is_private": false, "playlist_contents": "{}"}, + {"playlist_id": 3, "playlist_owner_id": 1, "playlist_name": "Private PL", "is_album": false, "is_private": true, "playlist_contents": "{}"}, + {"playlist_id": 4, "playlist_owner_id": 2, "playlist_name": "Small PL", "is_album": false, "is_private": false, "playlist_contents": "{}"}, + }, + "playlist_routes": { + {"slug": "my-playlist", "title_slug": "my-playlist", "collision_id": 0, "owner_id": 1, "playlist_id": 1}, + {"slug": "my-album", "title_slug": "my-album", "collision_id": 0, "owner_id": 1, "playlist_id": 2}, + {"slug": "private-pl", "title_slug": "private-pl", "collision_id": 0, "owner_id": 1, "playlist_id": 3}, + {"slug": "small-pl", "title_slug": "small-pl", "collision_id": 0, "owner_id": 2, "playlist_id": 4}, + }, + }) + + return app +} + +func TestSitemapDefault(t *testing.T) { + app := sitemapTestApp(t) + status, body := testGet(t, app, "/sitemaps/default.xml") + require.Equal(t, 200, status) + + xml := string(body) + assert.Contains(t, xml, "=10 followers + assert.Contains(t, xml, "artist1/listed-track") + // Should NOT include unlisted track + assert.NotContains(t, xml, "unlisted-track") + // Should NOT include track from user with <10 followers + assert.NotContains(t, xml, "small-artist-track") + // Should NOT include deleted track + assert.NotContains(t, xml, "deleted-track") + // Slashes should not be encoded + assert.NotContains(t, xml, "%2F") +} + +func TestSitemapPlaylistPage(t *testing.T) { + app := sitemapTestApp(t) + status, body := testGet(t, app, "/sitemaps/playlist/1.xml") + require.Equal(t, 200, status) + + xml := string(body) + // Public playlist from user with >=10 followers + assert.Contains(t, xml, "artist1/playlist/my-playlist") + // Album uses "album" prefix + assert.Contains(t, xml, "artist1/album/my-album") + // Private playlist excluded + assert.NotContains(t, xml, "private-pl") + // Playlist from user with <10 followers excluded + assert.NotContains(t, xml, "small-pl") +} + +func TestSitemapUserPage(t *testing.T) { + app := sitemapTestApp(t) + status, body := testGet(t, app, "/sitemaps/user/1.xml") + require.Equal(t, 200, status) + + xml := string(body) + // User with >=10 followers included + assert.Contains(t, xml, "artist1") + // User with <10 followers excluded + assert.NotContains(t, xml, "smallartist") + // Deactivated user excluded + assert.NotContains(t, xml, "gone") +} + +func TestSitemapInvalidType(t *testing.T) { + app := sitemapTestApp(t) + status, _ := testGet(t, app, "/sitemaps/bogus/index.xml") + assert.Equal(t, 400, status) +} + +func TestSitemapInvalidPage(t *testing.T) { + app := sitemapTestApp(t) + status, _ := testGet(t, app, "/sitemaps/track/notanumber.xml") + assert.Equal(t, 400, status) +} + +func TestSitemapContentType(t *testing.T) { + app := sitemapTestApp(t) + req := strings.NewReader("") + _ = req + status, body := testGet(t, app, "/sitemaps/default.xml") + require.Equal(t, 200, status) + assert.Contains(t, string(body), "