The /stats page gains a second tab (Reading | Library) implementing the
Audiobookshelf-style library dashboard (HANDOFF item 9): media_items
INVENTORY aggregates — items, hours/pages, distinct authors, size,
distinct narrators, Top Genres / Top Authors bars, Longest / Largest
items — for one library or the All-libraries aggregate.
- GET /api/stats/library-usage?library_id=<uuid|omit>; family lens
enforced: a library_id outside the requester's visible set is a 403,
never a silent pass (AGENTS.md standing rule; 431fec0 history)
- six sqlc queries with the visibility pattern + healthy-item filter
(archived_at IS NULL AND missing_scan_count = 0); hours_mode lets the
client swap the Hours/Pages card and the Longest ranking per scope
- Reading tab unit audit (HANDOFF item 3, settled 2026-10-16): minutes
are the merged currency everywhere honest; only the PAGES unit splits
— Pages card goes mode-aware (Pages · text / Listening Time in an
audio-only pool) and the charts row gains Listening per Day (hrs)
when audio minutes exist in the range; summary daily buckets gain
audio_minutes split by the item's library type
- tab state in the URL (?tab=library&library_id=…); selector fed by the
existing /api/libraries/visibility endpoint; main.js?v=4
- integration tests: family-lens 403 pin, hidden-library exclusion
(differential), seeded text+audio aggregate correctness, auth gate
- docs/developer/api/stats/get_library_usage.md
Known-remaining suite failures are environmental: TestConflictList_Empty
+ TestWebSocketPingPong collide with the concurrently-running app session
(live emulator pushes + scans on the shared dev DB), and
TestAdminCreateUser's timezone expectation drifted from live system
settings — all three fail on the clean tree too.
767 lines
31 KiB
Go
767 lines
31 KiB
Go
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,
|
||
Author: pgtype.Text{String: author, Valid: true},
|
||
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")
|
||
}
|