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 }