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.
438 lines
17 KiB
Go
438 lines
17 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
|
||
}
|