package main import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "bookhoard/internal/database" ) // Reading-statistics coverage: session CAPTURE at the sync chokepoint, // the summary aggregation (tz buckets, streaks, finished detection), // range validation, the widget, and the per-book card. // // All seeded instants anchor to USER-TZ midnights (the test user is UTC) // so day buckets are deterministic regardless of when the suite runs. func pgUser(id uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: [16]byte(id), Valid: true} } func pgFloat(v float64) pgtype.Float8 { return pgtype.Float8{Float64: v, Valid: true} } func pgInt4(v int32) pgtype.Int4 { return pgtype.Int4{Int32: v, Valid: true} } func pgTS(v time.Time) pgtype.Timestamptz { return pgtype.Timestamptz{Time: v, Valid: !v.IsZero()} } // middayDaysAgo returns noon UTC N calendar days before today — a // bucket-stable instant for any wall-clock time the suite runs at. func middayDaysAgo(daysAgo int) time.Time { midnight := time.Now().UTC() midnight = time.Date(midnight.Year(), midnight.Month(), midnight.Day(), 0, 0, 0, 0, time.UTC) return midnight.AddDate(0, 0, -daysAgo).Add(12 * time.Hour) } func statsLogin(t *testing.T, setup *TestServerSetup, token, path string) (*http.Response, []byte) { t.Helper() req, _ := http.NewRequest("GET", setup.Server.URL+path, nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() t.Setenv("STATS_LAST_BODY_PATH", path) // no-op; keeps body-close ordering explicit }() body, err := io.ReadAll(resp.Body) require.NoError(t, err) return resp, body } func TestReadingStatsCapture(t *testing.T) { setup := setupTestServer(t) userID := getTestUserID(t, setup.DB) ctx := context.Background() bookID := createTestMediaItemID(t, setup) push := func(body map[string]interface{}) int { payload, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+bookID+"/progress", bytes.NewBuffer(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+setup.Token) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() return resp.StatusCode } require.Equal(t, http.StatusOK, push(map[string]interface{}{ "percentage": 0.10, "current_page": 10, "total_pages": 100, })) time.Sleep(1100 * time.Millisecond) // the second push derives a ~1s span require.Equal(t, http.StatusOK, push(map[string]interface{}{ "percentage": 0.15, "current_page": 15, "total_pages": 100, })) rows, err := setup.DB.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{ UserID: pgUser(userID), MediaItemID: uuidToPGType(uuid.MustParse(bookID)), }) require.NoError(t, err) require.Len(t, rows, 2, "each push records one session row") first := rows[0] assert.Equal(t, int32(0), first.TimeSpentSeconds.Int32, "first contact: marker with zero time") assert.InEpsilon(t, 0.10, first.ProgressPercentage.Float64, 0.001) second := rows[1] assert.GreaterOrEqual(t, second.TimeSpentSeconds.Int32, int32(1), "the ~1.1s gap is attributed") assert.LessOrEqual(t, second.TimeSpentSeconds.Int32, int32(10), "and bounded by the slept interval") require.True(t, second.PagesRead.Valid, "page info was carried on both pushes") assert.Equal(t, int32(5), second.PagesRead.Int32, "pages: 15 − 10") progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{ MediaItemID: uuidToPGType(uuid.MustParse(bookID)), UserID: pgUser(userID), }) require.NoError(t, err) assert.InEpsilon(t, 0.15, progress.Percentage.Float64, 0.001, "capture never disturbs stored position") } func TestReadingStatsDeviceAttribution(t *testing.T) { setup := setupTestServer(t) userID := getTestUserID(t, setup.DB) ctx := context.Background() bookID := createTestMediaItemID(t, setup) // A registered device owned by the test user, plus one owned by a // DIFFERENT user (the foreign token must be ignored, never sent). device, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{ UserID: pgUser(userID), DeviceName: "Pixel 9 Test", DeviceType: "mobile", DeviceIdentifier: "stats-test-android-" + uuid.NewString(), AuthToken: "dev_test_token_" + uuid.NewString(), SyncEnabled: pgtype.Bool{Bool: true, Valid: true}, AutoSync: pgtype.Bool{Bool: true, Valid: true}, SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true}, DeviceMetadata: []byte("{}"), }) require.NoError(t, err) t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, device.ID) }) otherUser := getRegularUserID(t, setup.DB) foreign, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{ UserID: pgUser(otherUser), DeviceName: "Foreign Device", DeviceType: "mobile", DeviceIdentifier: "stats-test-foreign-" + uuid.NewString(), AuthToken: "dev_foreign_token_" + uuid.NewString(), SyncEnabled: pgtype.Bool{Bool: true, Valid: true}, AutoSync: pgtype.Bool{Bool: true, Valid: true}, SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true}, DeviceMetadata: []byte("{}"), }) require.NoError(t, err) t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, foreign.ID) }) push := func(body map[string]interface{}, deviceToken string) int { payload, _ := json.Marshal(body) req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+bookID+"/progress", bytes.NewBuffer(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+setup.Token) if deviceToken != "" { req.Header.Set("X-Device-Token", deviceToken) } resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() return resp.StatusCode } // Registered device push → attributed to the device with its name. require.Equal(t, http.StatusOK, push(map[string]interface{}{ "percentage": 0.10, "current_page": 10, "total_pages": 100, }, device.AuthToken)) rows, err := setup.DB.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{ UserID: pgUser(userID), MediaItemID: uuidToPGType(uuid.MustParse(bookID)), }) require.NoError(t, err) require.Len(t, rows, 1) require.True(t, rows[0].DeviceID.Valid) assert.Equal(t, uuid.UUID(device.ID.Bytes).String(), uuid.UUID(rows[0].DeviceID.Bytes).String()) var meta struct { Source string `json:"source"` } require.NoError(t, json.Unmarshal(rows[0].DeviceMetadata, &meta)) assert.Equal(t, "Pixel 9 Test", meta.Source, "capture metadata carries the registered device name") progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{ MediaItemID: uuidToPGType(uuid.MustParse(bookID)), UserID: pgUser(userID), }) require.NoError(t, err) assert.Equal(t, "Pixel 9 Test", progress.LastSyncSource.String, "last-sync surface shows the device name") // Foreign device token → soft-fails: unattributed, plain web source. require.Equal(t, http.StatusOK, push(map[string]interface{}{ "percentage": 0.20, "current_page": 20, "total_pages": 100, }, "dev_foreign_token_"+uuid.NewString() /* nonexistent token */)) // A REAL foreign token must also be ignored, not adopted. require.Equal(t, http.StatusOK, push(map[string]interface{}{ "percentage": 0.25, "current_page": 25, "total_pages": 100, }, foreign.AuthToken)) rows, err = setup.DB.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{ UserID: pgUser(userID), MediaItemID: uuidToPGType(uuid.MustParse(bookID)), }) require.NoError(t, err) require.Len(t, rows, 3) for _, row := range rows[1:] { assert.False(t, row.DeviceID.Valid, "invalid/foreign tokens never attribute a device") require.NoError(t, json.Unmarshal(row.DeviceMetadata, &meta)) assert.Equal(t, "web", meta.Source) } progress, err = setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{ MediaItemID: uuidToPGType(uuid.MustParse(bookID)), UserID: pgUser(userID), }) require.NoError(t, err) assert.Equal(t, "web", progress.LastSyncSource.String) } func TestReadingStatsSummary(t *testing.T) { setup := setupTestServer(t) // The REGULAR test user, deliberately: the shared dev DB carries a // live reader on `testuser` (the dev account genuinely reads while // the suite runs), and exact-total assertions cannot share an // account with reality. The fresh regular user has no live pushes. userID := getRegularUserID(t, setup.DB) ctx := context.Context(context.Background()) libIDStr := createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Stats Library %d", time.Now().UnixNano()), "ebooks") libUUID := uuid.MustParse(libIDStr) var finishedBookID, offRangeBookID string // A real registered device: attributed sessions carry its id (the // rest fall into the "Web / unsynced" bucket). seedDevice, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{ UserID: pgUser(userID), DeviceName: "Kindle Papered Test", DeviceType: "koreader", DeviceIdentifier: "stats-test-kindle-" + uuid.NewString(), AuthToken: "dev_kindle_token_" + uuid.NewString(), SyncEnabled: pgtype.Bool{Bool: true, Valid: true}, AutoSync: pgtype.Bool{Bool: true, Valid: true}, SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true}, DeviceMetadata: []byte("{}"), }) require.NoError(t, err) t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, seedDevice.ID) }) deviceID := seedDevice.ID insertSession := func(daysAgo int, minutes int32, pages int32, pct float64, title string, attributed bool, reuseMediaID ...string) string { var media database.MediaItems var err error if len(reuseMediaID) > 0 && reuseMediaID[0] != "" { media, err = setup.DB.GetMediaItem(ctx, uuidToPGType(uuid.MustParse(reuseMediaID[0]))) require.NoError(t, err, "reuse seeded media item") } else { media, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ LibraryID: pgtype.UUID{Bytes: [16]byte(libUUID), Valid: true}, Title: title, FilePath: fmt.Sprintf("/tmp/stats-%s-%d.epub", title, time.Now().UnixNano()), FileSize: pgtype.Int8{Int64: 1024, Valid: true}, MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, }) require.NoError(t, err, "seed media item") } deviceIDArg := pgtype.UUID{} if attributed { deviceIDArg = deviceID } pushAt := middayDaysAgo(daysAgo) _, err = setup.DB.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{ UserID: pgUser(userID), MediaItemID: media.ID, DeviceID: deviceIDArg, ProgressPercentage: pgFloat(pct), ReadingSessionStart: pgTS(pushAt.Add(-time.Duration(minutes) * time.Minute)), ReadingSessionEnd: pgTS(pushAt), PagesRead: pgInt4(pages), TimeSpentSeconds: pgInt4(minutes * 60), DeviceMetadata: []byte(`{"source":"test"}`), }) require.NoError(t, err, "seed session") id, _ := uuid.FromBytes(media.ID.Bytes[0:16]) return id.String() } // Device-split scenario: attributed (80 min) + unattributed (75 min). finishedBookID = insertSession(0, 30, 12, 1.0, "Stats Finished Book", false) insertSession(1, 45, 20, 0.5, "Stats Streak Book", false) insertSession(2, 60, 25, 0.42, "Stats Back Book", true) insertSession(2, 20, 8, 0.9, "Stats Finished Book (same book, second session)", true, finishedBookID) offRangeBookID = insertSession(400, 120, 40, 0.2, "Stats OffRange Book", false) // Summary ----------------------------------------------------------- resp, body := statsLogin(t, setup, setup.RegularToken, "/api/stats/summary?range=7") require.Equal(t, http.StatusOK, resp.StatusCode) var summary struct { Range string `json:"range"` TotalMinutes int `json:"total_minutes"` TotalPages int `json:"total_pages"` SessionCount int `json:"session_count"` BooksActive int `json:"books_with_activity"` BooksFinished int `json:"books_finished"` CompletionRate float64 `json:"completion_rate"` Daily []struct { Date string `json:"date"` Minutes int `json:"minutes"` Pages int `json:"pages"` } `json:"daily"` Streak struct { Current int `json:"current"` Longest int `json:"longest"` ActiveToday bool `json:"active_today"` } `json:"streak"` TopBooks []struct { MediaItemID string `json:"media_item_id"` Minutes int `json:"minutes"` } `json:"top_books"` LibraryBreakdown []struct { Name string `json:"name"` Minutes int `json:"minutes"` } `json:"library_breakdown"` DeviceBreakdown []struct { DeviceID string `json:"device_id"` Name string `json:"name"` Type string `json:"type"` Minutes int `json:"minutes"` Sessions int `json:"sessions"` } `json:"device_breakdown"` } require.NoError(t, json.Unmarshal(body, &summary)) assert.Equal(t, "7", summary.Range) require.Len(t, summary.Daily, 7, "zero-filled inclusive calendar") assert.Equal(t, 155, summary.TotalMinutes, "30+45+60+20 (the 400-day-day session is out of range)") assert.Equal(t, 65, summary.TotalPages, "12+20+25+8") assert.Equal(t, 4, summary.SessionCount) assert.Equal(t, 3, summary.BooksActive, "distinct books with sessions") assert.Equal(t, 1, summary.BooksFinished, "finished := any push ≥ 99.5%") assert.InEpsilon(t, float64(1)/float64(3), summary.CompletionRate, 0.001) // Streaks: today, −1d, −2d are consecutive; the lone −400d day isn't. t.Logf("STREAK DUMP: %+v (values %#v)", summary.Streak, summary.Streak) t.Logf("DAILY DUMP: %+v", summary.Daily) assert.Equal(t, 3, summary.Streak.Current) assert.Equal(t, 3, summary.Streak.Longest) assert.True(t, summary.Streak.ActiveToday) // Top books: minutes-ordered — the seeded 60-minute back book tops. require.NotEmpty(t, summary.TopBooks) assert.Equal(t, 60, summary.TopBooks[0].Minutes) for i := 1; i < len(summary.TopBooks); i++ { assert.GreaterOrEqual(t, summary.TopBooks[i-1].Minutes, summary.TopBooks[i].Minutes, "top books sorted by minutes") } // Library breakdown carries the seeded library. found := false for _, lib := range summary.LibraryBreakdown { if lib.Minutes >= 155 { found = true } } assert.True(t, found, "library breakdown aggregates the seeded library") // Device breakdown: attributed (80) + unattributed (75) reconcile // against the headline total; both buckets present. require.Len(t, summary.DeviceBreakdown, 2, "one device bucket + one unattributed bucket") var kindleFound, unattributedFound bool for _, dev := range summary.DeviceBreakdown { if dev.Name == "Kindle Papered Test" { kindleFound = true assert.Equal(t, 80, dev.Minutes, "60 + 20 attributed sessions") assert.Equal(t, 2, dev.Sessions) assert.NotEqual(t, "", dev.DeviceID, "device bucket carries its id") assert.Equal(t, "koreader", dev.Type) } if dev.Name == "Web" { unattributedFound = true assert.Equal(t, 75, dev.Minutes, "30 + 45 unattributed sessions") assert.Equal(t, 2, dev.Sessions) assert.Equal(t, "", dev.DeviceID) } } assert.True(t, kindleFound, "the registered device has its own bucket") assert.True(t, unattributedFound, "device-less pushes aggregate into the Web bucket") deviceMinutes := 0 for _, dev := range summary.DeviceBreakdown { deviceMinutes += dev.Minutes } assert.Equal(t, 155, deviceMinutes, "device buckets reconcile with the total") // Range validation --------------------------------------------------- badResp, badBody := statsLogin(t, setup, setup.RegularToken, "/api/stats/summary?range=fortnight") assert.Equal(t, http.StatusBadRequest, badResp.StatusCode) assert.Contains(t, string(badBody), "invalid range") // Auth gate ---------------------------------------------------------- anonym, err := http.Get(setup.Server.URL + "/api/stats/summary") require.NoError(t, err) assert.Equal(t, http.StatusUnauthorized, anonym.StatusCode) _ = anonym.Body.Close() // Widget ------------------------------------------------------------- resp, body = statsLogin(t, setup, setup.RegularToken, "/api/stats/this-week") require.Equal(t, http.StatusOK, resp.StatusCode) var week struct { WeekMinutes int `json:"week_minutes"` WeekBooks int `json:"week_books"` ActiveToday bool `json:"active_today"` CurrentStreak int `json:"current_streak"` PrevMinutes int `json:"prev_week_minutes"` } require.NoError(t, json.Unmarshal(body, &week)) assert.Equal(t, 155, week.WeekMinutes) assert.Equal(t, 3, week.WeekBooks) assert.True(t, week.ActiveToday) assert.Equal(t, 3, week.CurrentStreak) assert.Equal(t, 0, week.PrevMinutes) // Per-book card (lifetime, so the 400-day book still totals) ----------- resp, body = statsLogin(t, setup, setup.RegularToken, "/api/stats/books/"+offRangeBookID) require.Equal(t, http.StatusOK, resp.StatusCode) var bookStats struct { TotalMinutes int `json:"total_minutes"` SessionCount int `json:"session_count"` MaxProgress float64 `json:"max_progress"` } require.NoError(t, json.Unmarshal(body, &bookStats)) assert.Equal(t, 120, bookStats.TotalMinutes) assert.Equal(t, 1, bookStats.SessionCount) assert.InEpsilon(t, 0.2, bookStats.MaxProgress, 0.001) _ = finishedBookID } // TestReadingStatsWeekLibraryScope: the dashboard widget tracks the // switched library — this-week totals and streaks scope to the // requested library (and stay global without one). func TestReadingStatsWeekLibraryScope(t *testing.T) { setup := setupTestServer(t) userID := getRegularUserID(t, setup.DB) // isolated from the live dev-reader account ctx := context.Context(context.Background()) libA := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Scope LibA %d", time.Now().UnixNano()), "ebooks")) libB := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Scope LibB %d", time.Now().UnixNano()), "ebooks")) seed := func(lib uuid.UUID, title string, daysAgo int, minutes int32) { media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true}, Title: title, FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()), FileSize: pgtype.Int8{Int64: 1024, Valid: true}, MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, }) require.NoError(t, err) pushAt := middayDaysAgo(daysAgo) _, err = setup.DB.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{ UserID: pgUser(userID), MediaItemID: media.ID, ProgressPercentage: pgFloat(0.3), ReadingSessionStart: pgTS(pushAt.Add(-time.Duration(minutes) * time.Minute)), ReadingSessionEnd: pgTS(pushAt), PagesRead: pgInt4(5), TimeSpentSeconds: pgInt4(minutes * 60), DeviceMetadata: []byte(`{"source":"test"}`), }) require.NoError(t, err) } seed(libA, "Scope A Today", 0, 30) seed(libA, "Scope A Yesterday", 1, 20) seed(libB, "Scope B Today", 0, 45) week := func(libraryID string) map[string]interface{} { url := setup.Server.URL + "/api/stats/this-week" if libraryID != "" { url += "?library_id=" + libraryID } req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+setup.RegularToken) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() require.Equal(t, http.StatusOK, resp.StatusCode) var out map[string]interface{} require.NoError(t, json.NewDecoder(resp.Body).Decode(&out)) return out } num := func(m map[string]interface{}, key string) int { return int(m[key].(float64)) } all := week("") assert.Equal(t, 95, num(all, "week_minutes"), "no library filter = all visible libraries") assert.Equal(t, 2, num(all, "current_streak"), "today + yesterday (libA)") onlyA := week(libA.String()) assert.Equal(t, 50, num(onlyA, "week_minutes")) assert.Equal(t, 2, num(onlyA, "current_streak")) assert.True(t, onlyA["active_today"].(bool)) onlyB := week(libB.String()) assert.Equal(t, 45, num(onlyB, "week_minutes")) assert.Equal(t, 1, num(onlyB, "current_streak"), "libB only has today") // A library owned by someone else (invisible to this user) leaks nothing. hidden := uuid.MustParse(createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), fmt.Sprintf("Test Scope Hidden %d", time.Now().UnixNano()), "ebooks")) assert.Equal(t, 0, num(week(hidden.String()), "week_minutes"), "invisible library contributes nothing") // Malformed scope is a clean 400. req, _ := http.NewRequest("GET", setup.Server.URL+"/api/stats/this-week?library_id=nope", nil) req.Header.Set("Authorization", "Bearer "+setup.RegularToken) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() assert.Equal(t, http.StatusBadRequest, resp.StatusCode) } // TestLibraryUsageStats: the /stats Library tab (GET // /api/stats/library-usage) — media_items INVENTORY aggregates scoped per // request. Read-only over seeded rows (no reading_history), so the live // dev-reader warnings do not apply. Seeds both a text and an audiobook // library to pin the hours-vs-pages mode selection and every panel. func TestLibraryUsageStats(t *testing.T) { setup := setupTestServer(t) userID := getRegularUserID(t, setup.DB) ctx := context.Background() ebooksID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Usage Ebooks %d", time.Now().UnixNano()), "ebooks")) audioID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Usage Audio %d", time.Now().UnixNano()), "audiobooks")) seed := func(lib uuid.UUID, title, author, genre string, pages int32, size int64, narrators []string, durationSeconds int) database.MediaItems { if narrators == nil { narrators = []string{} // NOT NULL DEFAULT '{}' — explicit NULL would 23502 } audioMeta := []byte(nil) if durationSeconds > 0 { audioMeta = []byte(fmt.Sprintf(`{"duration_seconds":%d,"chapters":[]}`, durationSeconds)) } media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true}, Title: title, Authors: []string{author}, Genre: pgtype.Text{String: genre, Valid: true}, FilePath: fmt.Sprintf("/tmp/usage-%s-%d.bin", title, time.Now().UnixNano()), FileSize: pgtype.Int8{Int64: size, Valid: true}, MimeType: pgtype.Text{String: "application/octet-stream", Valid: true}, PageCount: pgInt4(pages), Narrators: narrators, NarratorsSearch: narrators, AudioMetadata: audioMeta, }) require.NoError(t, err, "seed usage item") return media } // Text library: 3 items, 2 distinct authors, genres Fantasy×2 + // Computers×1, 1000 total pages, longest = the 500-page book. seed(ebooksID, "Usage Dune", "Ann Author", "Fantasy", 300, 1_000_000, nil, 0) seed(ebooksID, "Usage Botany", "Ann Author", "Fantasy", 200, 2_000_000, nil, 0) seed(ebooksID, "Usage Computery", "Bob Author", "Computers", 500, 500_000, nil, 0) // Audio library: 2 items, 1 author, 2 distinct narrators, real // durations (36h + 5h), the 36h one also the largest file. seed(audioID, "Usage LongListen", "Cal Author", "", 0, 1_400_000_000, []string{"Ann Narrator", "Bob Narrator"}, 129_600) seed(audioID, "Usage ShortListen", "Cal Author", "", 0, 900_000_000, []string{"Ann Narrator"}, 18_000) fetch := func(query string) (int, []byte) { url := setup.Server.URL + "/api/stats/library-usage" + query req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+setup.RegularToken) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) require.NoError(t, err) return resp.StatusCode, body } type UsageItemT struct { MediaItemID string `json:"media_item_id"` Title string `json:"title"` Author string `json:"author"` Seconds float64 `json:"seconds"` Pages int `json:"pages"` Size int64 `json:"size"` } type NameCountT struct { Name string `json:"name"` Count int `json:"count"` } var usage struct { LibraryID string `json:"library_id"` Items int `json:"items"` Authors int `json:"authors"` Narrators int `json:"narrators"` TotalSizeBytes int64 `json:"total_size_bytes"` TotalSeconds int64 `json:"total_seconds"` TotalPages int `json:"total_pages"` HoursMode bool `json:"hours_mode"` Genres []NameCountT `json:"genres"` AuthorsTop []NameCountT `json:"top_authors"` LongestItems []UsageItemT `json:"longest_items"` LargestItems []UsageItemT `json:"largest_items"` } decode := func(body []byte) { require.NoError(t, json.Unmarshal(body, &usage)) } // Text library scope: pages mode. code, body := fetch("?library_id=" + ebooksID.String()) require.Equal(t, http.StatusOK, code) decode(body) assert.Equal(t, ebooksID.String(), usage.LibraryID) assert.Equal(t, 3, usage.Items) assert.Equal(t, 2, usage.Authors) assert.Equal(t, 0, usage.Narrators) assert.Equal(t, int64(3_500_000), usage.TotalSizeBytes) assert.Equal(t, int64(0), usage.TotalSeconds) assert.Equal(t, 1000, usage.TotalPages) assert.False(t, usage.HoursMode, "no audio in scope → pages mode") require.Len(t, usage.Genres, 2) assert.Equal(t, "Fantasy", usage.Genres[0].Name) assert.Equal(t, 2, usage.Genres[0].Count) assert.Equal(t, "Computers", usage.Genres[1].Name) require.Len(t, usage.AuthorsTop, 2) assert.Equal(t, "Ann Author", usage.AuthorsTop[0].Name) assert.Equal(t, 2, usage.AuthorsTop[0].Count) require.Len(t, usage.LongestItems, 3) assert.Equal(t, "Usage Computery", usage.LongestItems[0].Title) assert.Equal(t, 500, usage.LongestItems[0].Pages) require.Len(t, usage.LargestItems, 3) assert.Equal(t, int64(2_000_000), usage.LargestItems[0].Size, "largest = file_size DESC") // Audio library scope: hours mode. code, body = fetch("?library_id=" + audioID.String()) require.Equal(t, http.StatusOK, code) decode(body) assert.Equal(t, 2, usage.Items) assert.Equal(t, 1, usage.Authors) assert.Equal(t, 2, usage.Narrators, "Ann + Bob across the two audiobooks") assert.Equal(t, int64(147_600), usage.TotalSeconds, "36h + 5h") assert.True(t, usage.HoursMode, "audio in scope → hours mode") require.Len(t, usage.LongestItems, 2) assert.Equal(t, "Usage LongListen", usage.LongestItems[0].Title) assert.InEpsilon(t, 129_600, usage.LongestItems[0].Seconds, 0.001) require.Len(t, usage.LargestItems, 2) assert.Equal(t, int64(1_400_000_000), usage.LargestItems[0].Size) // All libraries (no param): the aggregate across the visible set. // The shared dev DB carries REAL libraries every user sees by default // (Manga/Comics/Ebooks + whatever the live reader has), so totals are // relative floors — but inclusion + ranking are exact. code, body = fetch("") require.Equal(t, http.StatusOK, code) decode(body) assert.Equal(t, "", usage.LibraryID) assert.GreaterOrEqual(t, usage.Items, 5, "both test pools are in the aggregate") assert.GreaterOrEqual(t, usage.Authors, 3) assert.GreaterOrEqual(t, usage.Narrators, 2) assert.GreaterOrEqual(t, usage.TotalSeconds, int64(147_600), "audio sums include the seeded audiobooks") assert.GreaterOrEqual(t, usage.TotalPages, 1000) assert.True(t, usage.HoursMode, "audio exists in the aggregate → longest ranks by hours") require.NotEmpty(t, usage.LongestItems) assert.GreaterOrEqual(t, usage.LongestItems[0].Seconds, float64(129_600), "the seeded 36h item ranks at-or-above the top of the longest list") // Malformed scope is a clean 400. code, _ = fetch("?library_id=nope") assert.Equal(t, http.StatusBadRequest, code) // Auth gate. anonym, err := http.Get(setup.Server.URL + "/api/stats/library-usage") require.NoError(t, err) assert.Equal(t, http.StatusUnauthorized, anonym.StatusCode) _ = anonym.Body.Close() } // TestLibraryUsageStats_Visibility pins the family-lens contract on the // Library tab: a library hidden from the requester contributes NOTHING to // the All-libraries aggregate, and naming it explicitly is a 403 — never // a silent pass (AGENTS.md standing rule; history: 431fec0). func TestLibraryUsageStats_Visibility(t *testing.T) { setup := setupTestServer(t) userID := getRegularUserID(t, setup.DB) ctx := context.Background() visibleID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Usage Visible %d", time.Now().UnixNano()), "ebooks")) hiddenID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Usage Hidden %d", time.Now().UnixNano()), "ebooks")) seed := func(lib uuid.UUID, title string, size int64) { _, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true}, Title: title, FilePath: fmt.Sprintf("/tmp/usage-vis-%s-%d.bin", title, time.Now().UnixNano()), FileSize: pgtype.Int8{Int64: size, Valid: true}, MimeType: pgtype.Text{String: "application/octet-stream", Valid: true}, Narrators: []string{}, NarratorsSearch: []string{}, }) require.NoError(t, err) } seed(visibleID, "Usage Visible One", 1_000_000) seed(visibleID, "Usage Visible Two", 2_000_000) seed(hiddenID, "Usage Hidden One", 4_000_000) setVisibility := func(visible bool) int { payload, _ := json.Marshal(map[string]interface{}{ "library_id": hiddenID.String(), "is_visible": visible, }) req, _ := http.NewRequest("POST", setup.Server.URL+"/api/libraries/visibility", bytes.NewBuffer(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+setup.RegularToken) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() return resp.StatusCode } fetch := func(query string) (int, []byte) { url := setup.Server.URL + "/api/stats/library-usage" + query req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+setup.RegularToken) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) require.NoError(t, err) return resp.StatusCode, body } // Baseline with the library visible: the aggregate includes its item. code, body := fetch("") require.Equal(t, http.StatusOK, code) var before struct { Items int `json:"items"` } require.NoError(t, json.Unmarshal(body, &before)) // Hide, then re-measure: a differential of exactly one item proves // the hidden pool stays out of the aggregate (exact, and immune to // whatever else lives in the shared dev libraries). require.Equal(t, http.StatusOK, setVisibility(false)) defer func() { require.Equal(t, http.StatusOK, setVisibility(true)) }() // Explicit hidden library → 403, the REQUIRED pin. code, _ = fetch("?library_id=" + hiddenID.String()) assert.Equal(t, http.StatusForbidden, code, "hidden library scope must 403") // The All-libraries aggregate excludes the hidden pool. var after struct { Items int `json:"items"` } code, body = fetch("") require.Equal(t, http.StatusOK, code) require.NoError(t, json.Unmarshal(body, &after)) assert.Equal(t, before.Items-1, after.Items, "hiding the library removes exactly its item from the aggregate") }