Files
bookhoard/cmd/server/tests/statistics_test.go
T
John O'Keefe 0914c935d9 feat(dashboard): collapsible This Week widget, library-scoped + switch-surviving; title into the subbar
Three dashboard complaints, one build:

- The widget collapses to a single summary row (This Week · minutes ·
  pages · sessions · books · streak · Full stats) on every screen size
  and expands to the full grid via a details/summary toggle — the
  carousel sections start immediately below it again.
- The 'Your Library' heading moved into the library switcher subbar
  (LibrarySwitcher gains an optional title param; collections/series
  keep the plain 'Library' label). The standalone heading block is
  gone.
- Root-caused the JS-switch vanishing: renderDashboardCollections
  replaces ALL of #collections-container's innerHTML, so anything
  server-rendered inside it (title, widget) died on the first library
  switch. The widget now re-fetches ITSELF: a /dashboard/week-widget
  fragment (same resolveLibrary family-lens rules as the page) is
  pulled by htmx whenever the switcher dispatches week-refresh —
  carrying the current #library-select value — so the widget both
  survives switches and RE-SCOPES to the switched library. Initial SSR
  renders the same scope the sections use.

ThisWeek (and the streak calendar) gained an optional library filter.
Tests: TestReadingStatsWeekLibraryScope — per-library totals, scoped
streaks, invisible-library isolation, malformed id 400. Full suite
PASS.
2026-10-14 13:46:18 -04:00

520 lines
21 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
}
// 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)
}