Files
bookhoard/cmd/server/tests/statistics_test.go
T
John O'Keefe 5a2c1585ae refactor(stats): 'Web / unsynced' bucket label → 'Web'; analytics tests isolated from the live reader
The bucket name read as confusing — 'unsynced' implied data loss when
the push is always captured; it only lacks a device credential. The
bucket is just 'Web' now (service fallback, stats.ts fallback, panel
copy, tests).

Also: analytics_test exact-zero assertions (EmptyHistory,
NoReadingHistory) ran against testuser, which is a LIVE reader — real
sessions accrued on it mid-suite and both flapped. They now run under
the fresh regular user, like the stats tests; the 'testuser is a live
reader' rule is recorded in AGENTS.md.
2026-10-14 13:24:30 -04:00

438 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}