Files
bookhoard/cmd/server/tests/statistics_test.go
T
John O'Keefe 6457fbaf72 feat(stats): reading statistics — session capture wired + /stats page + widget + per-book card
The design parked in the handoff (multi-tier stats off reading_history;
bookorbit-style in data, not looks) is in. The real prerequisite work
was data: reading_history existed but NOTHING wrote to it — the whole
stats feature was sitting on an empty table.

- CAPTURE (no client changes, no repo-boundary crossings): every
  successful progress write through SaveProgress (KOReader + app + web
  + Kobo + offline queue — the one chokepoint) now records one
  reading_history session row: span = the interval since the PREVIOUS
  write instant, pages = positive page delta when both sides know the
  page index, marker rows for first contact. Spans over 2h are kept as
  zero-second markers (overnight suspend→resume, offline flushes must
  not inflate totals) — the honest 'sync cadence, not a stopwatch'
  caveat stays. device_id only when a REAL device row matches (the
  web/app progress path historically stuffs the USER id into DeviceID
  — probe instead of trusting it; FK violation would silently drop
  every capture). Source preserved in device_metadata.

- API: /api/stats/summary?range=7|30|90|year (totals, zero-filled
  daily arrays, streaks, most-active day/weekday, top books, library
  breakdown — one call), /api/stats/this-week (widget: rolling 7d vs
  previous + streak), /api/stats/books/:id (lifetime + recent
  sessions). All bucketed in the user's timezone; finished ≡ any push
  ≥0.995; visible-library lens enforced at source. Legacy
  /api/analytics/* kept untouched. Range windows anchor end-of-day to
  the START of today (blasting the clock time into tomorrow resolved
  'today' a calendar day late — found by the streak test).

- /stats page (sidebar Stats between Progress and Devices, previously
  /analytics was an orphan): range selector, five stat cards, two
  Chart.js v4 charts (daily minutes bar, pages/day line — chunked via
  dynamic import so main.js stays untouched), per-library breakdown,
  most-read list, empty-state explainer. /analytics now 301s to /stats.
  The orphaned analytics.templ + analytics.ts removed (their containers
  and the page JSON never matched; the page was astub).

- Dashboard 'This Week' widget (server-rendered, suppressed on service
  failure) and the book detail 'Your Reading Stats' card (lifetime
  totals of the viewer's own sessions) complete the tiers: per-user
  default, per-library breakdown inline, per-book on the page,
  per-system/admin aggregate parked as before.

Tests: statistics_test.go (capture semantics at the chokepoint,
tz/day buckets, zero-fill, streak math, finished detection, range
validation, auth gate, widget, per-book lifetime). Full suite PASS.
2026-10-14 12:46:38 -04:00

268 lines
10 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, path string) (*http.Response, []byte) {
t.Helper()
req, _ := http.NewRequest("GET", setup.Server.URL+path, nil)
req.Header.Set("Authorization", "Bearer "+setup.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 TestReadingStatsSummary(t *testing.T) {
setup := setupTestServer(t)
userID := getTestUserID(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
insertSession := func(daysAgo int, minutes int32, pages int32, pct float64, title string, 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")
}
pushAt := middayDaysAgo(daysAgo)
_, err = setup.DB.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{
UserID: pgUser(userID),
MediaItemID: media.ID,
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()
}
finishedBookID = insertSession(0, 30, 12, 1.0, "Stats Finished Book")
insertSession(1, 45, 20, 0.5, "Stats Streak Book")
insertSession(2, 60, 25, 0.42, "Stats Back Book")
insertSession(2, 20, 8, 0.9, "Stats Finished Book (same book, second session)", finishedBookID)
offRangeBookID = insertSession(400, 120, 40, 0.2, "Stats OffRange Book")
// Summary -----------------------------------------------------------
resp, body := statsLogin(t, setup, "/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"`
}
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")
// Range validation ---------------------------------------------------
badResp, badBody := statsLogin(t, setup, "/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, "/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, "/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
}