Vellum/internal/home/home_test.go
mithral 35442e4aff Phase 2: split the fat packages along real boundaries
Four extractions, each chosen because the two halves have different callers,
different failure modes or different testing needs. Not a line-count exercise.

internal/home out of internal/media
- media serves item RESOURCES (detail, file, cover, progress); home computes
  AGGREGATES (the five shelves). The 2328-line package was doing both.
- media keeps POST /items/{id}/view because it is item-scoped and must stay in
  the package that authorizes the item; it imports home.RecordView for that one
  thing. home does not import media.
- Verified live: all five shelves non-empty, and two consecutive /home calls
  return different explore ids, which is the non-determinism the Explore rewrite
  exists for.

internal/scanner out of internal/library
- The scanner walks the filesystem and reconciles; library CRUD is an admin HTTP
  surface. Different callers (a job vs a request), different failure modes.
- The registry deliberately STAYED in internal/library: three of its four callers
  are not the scanner (modules register types, handlers serve the list, metadata
  reads the group mode, subsonic calls CleanTitle). It is a property of the kind,
  not of the scan. The one genuinely scanner-owned helper became
  library.ExtensionsFor.
- Two tests moved with the code rather than being rewritten; they now exercise
  the boundary by calling library.ListMissing across it.
- Verified live: first scan updates, rescan reports unchanged=N, so change
  detection still short-circuits.

internal/providers out of internal/metadata
- Providers talk to third-party APIs; the enrich job owns the matching policy and
  the database writes. The split's whole point is that a provider is testable
  WITHOUT a database, so a DB-free test was added and OpenLibrary gained a
  BaseURL override to make it possible.
- providers imports zero internal packages — a true leaf.
- The matching policy (searchTitle, artistHint, pickBest) is untouched. It exists
  because real false matches were found.

dto packages out of internal/jellyfin and internal/subsonic
- The wire types and their mapping are now separate from the handler logic, so
  the contract with clients we do not control is reviewable on its own.
- Both dto packages are PURE: zero references to sql.DB or net/http, verified by
  grep. That is what makes them reviewable.
- No field name, tag or error code changed.

internal/arch did its job: it failed the build twice during this phase, once for
each new package that was not yet placed in a layer. That is the rule working,
not an obstacle.

Verified: gofmt clean, build exit 0, vet exit 0, 23 packages pass, svelte-check
0/0, and end to end against a running server — home shelves, a queued scan
reporting unchanged, Jellyfin /Items, and Subsonic ping.

Process note: one agent (dto) stopped mid-task and left the tree red — the dto
packages unlayered and its test still referencing constants that had moved. The
arch test caught it immediately. I finished it by hand: exported constant
references in the test, and both dto packages placed at layer 3.

Still open from Phase 1: internal/jellyfin has no test files, catalog is
item-centric while Subsonic is file-first, and subsonic loadAlbum now scopes to
the item's own library.
2026-10-11 16:03:10 -04:00

745 lines
25 KiB
Go

package home
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/migrations"
)
// ── shared helpers ─────────────────────────────────────────────────────
func homeHandlers(t *testing.T, db *sql.DB) *Handlers {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
return NewHandlers(db, log, nil)
}
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "home.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db
}
func mustQueryID(t *testing.T, db *sql.DB, q string, args ...any) int64 {
t.Helper()
var id int64
if err := db.QueryRowContext(context.Background(), q, args...).Scan(&id); err != nil {
t.Fatalf("query %q: %v", q, err)
}
return id
}
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
t.Helper()
if _, err := db.ExecContext(context.Background(), q, args...); err != nil {
t.Fatalf("exec %q: %v", q, err)
}
}
func newLibrary(t *testing.T, db *sql.DB, name, kind, root string) int64 {
t.Helper()
return mustQueryID(t, db,
`INSERT INTO libraries (name, kind, root_path) VALUES (?, ?, ?) RETURNING id`, name, kind, root)
}
func newUser(t *testing.T, db *sql.DB, name string) int64 {
t.Helper()
return mustQueryID(t, db,
`INSERT INTO users (username, password_hash) VALUES (?, 'x') RETURNING id`, name)
}
func grantRead(t *testing.T, db *sql.DB, userID, libID int64) {
t.Helper()
mustExec(t, db,
`INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, 1, 1)`,
userID, libID)
}
// newItem inserts a media item plus a backing file, because progress rows carry
// a NOT NULL file_id foreign key.
func newItem(t *testing.T, db *sql.DB, libID int64, kind, path, title, sortTitle string, missing bool, addedAt string) int64 {
t.Helper()
m := 0
if missing {
m = 1
}
id := mustQueryID(t, db, `
INSERT INTO media_items (library_id, kind, path, title, sort_title, missing, added_at)
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`,
libID, kind, path, title, sortTitle, m, addedAt)
mustExec(t, db,
`INSERT INTO media_files (item_id, path, size) VALUES (?, ?, 1)`,
id, "file-"+strconv.FormatInt(id, 10))
return id
}
func fileIDFor(t *testing.T, db *sql.DB, itemID int64) int64 {
t.Helper()
var id int64
if err := db.QueryRowContext(context.Background(),
`SELECT id FROM media_files WHERE item_id = ? ORDER BY id LIMIT 1`, itemID).Scan(&id); err != nil {
t.Fatalf("file for item %d: %v", itemID, err)
}
return id
}
func newProgress(t *testing.T, db *sql.DB, userID, itemID int64, percent float64, finished bool, updatedAt string) {
t.Helper()
f := 0
if finished {
f = 1
}
mustExec(t, db, `
INSERT INTO progress (user_id, item_id, file_id, kind, position, percent, finished, updated_at)
VALUES (?, ?, ?, 'ebooks', 1, ?, ?, ?)`,
userID, itemID, fileIDFor(t, db, itemID), percent, f, updatedAt)
}
func newView(t *testing.T, db *sql.DB, userID, itemID int64, views int, lastViewedAt string) {
t.Helper()
mustExec(t, db,
`INSERT INTO item_views (user_id, item_id, views, last_viewed_at) VALUES (?, ?, ?, ?)`,
userID, itemID, views, lastViewedAt)
}
func idSet(items []shelfItem) map[int64]bool {
out := make(map[int64]bool, len(items))
for _, it := range items {
out[it.ID] = true
}
return out
}
func hasDuplicates(items []shelfItem) bool {
seen := map[int64]bool{}
for _, it := range items {
if seen[it.ID] {
return true
}
seen[it.ID] = true
}
return false
}
// ── handleHome ─────────────────────────────────────────────────────────
type homeFixture struct {
db *sql.DB
libA int64 // ebooks/comics, visible to userA
libB int64 // audiobooks, visible to userB
userA int64
userB int64
userNone int64
a1 int64 // in progress (unfinished, most recent)
a2 int64 // finished
a3 int64 // unstarted, viewed, same author as a1
a4 int64 // missing, in progress
a5 int64 // unstarted
b1 int64 // userB's only item
}
func setupHome(t *testing.T) homeFixture {
t.Helper()
db := testDB(t)
f := homeFixture{db: db}
f.libA = newLibrary(t, db, "Ebooks", "ebooks", "/media/ebooks")
f.libB = newLibrary(t, db, "Audio", "audiobooks", "/media/audio")
f.userA = newUser(t, db, "alice")
f.userB = newUser(t, db, "bob")
f.userNone = newUser(t, db, "carol")
grantRead(t, db, f.userA, f.libA)
grantRead(t, db, f.userB, f.libB)
f.a1 = newItem(t, db, f.libA, "ebooks", "a1.epub", "A One", "a one", false, "2026-01-01T00:00:01.000Z")
f.a2 = newItem(t, db, f.libA, "ebooks", "a2.epub", "A Two", "a two", false, "2026-01-01T00:00:02.000Z")
f.a3 = newItem(t, db, f.libA, "comics", "a3.cbz", "A Three", "a three", false, "2026-01-01T00:00:03.000Z")
f.a4 = newItem(t, db, f.libA, "ebooks", "a4.epub", "A Four", "a four", true, "2026-01-01T00:00:04.000Z")
f.a5 = newItem(t, db, f.libA, "ebooks", "a5.epub", "A Five", "a five", false, "2026-01-01T00:00:05.000Z")
f.b1 = newItem(t, db, f.libB, "audiobooks", "b1.mp3", "B One", "b one", false, "2026-01-01T00:00:06.000Z")
// userA's progress. a1 is the most recent non-missing row, so it drives
// "top engagement". a2 is finished (must leave Continue). a4 is missing
// (must leave Continue even though unfinished).
newProgress(t, db, f.userA, f.a1, 0.5, false, "2026-02-01T00:00:03.000Z")
newProgress(t, db, f.userA, f.a2, 1.0, true, "2026-02-01T00:00:02.000Z")
newProgress(t, db, f.userA, f.a4, 0.3, false, "2026-02-01T00:00:04.000Z")
// userB only ever touches their own library.
newProgress(t, db, f.userB, f.b1, 0.2, false, "2026-02-02T00:00:01.000Z")
newView(t, db, f.userA, f.a1, 3, "2026-02-03T00:00:01.000Z")
newView(t, db, f.userA, f.a3, 1, "2026-02-03T00:00:02.000Z")
newView(t, db, f.userB, f.b1, 9, "2026-02-03T00:00:03.000Z")
// Ada Author wrote a1 (in progress) and a3 (unstarted) so Suggestions has
// a same-author candidate.
ada := mustQueryID(t, db, `INSERT INTO people (name) VALUES ('Ada Author') RETURNING id`)
mustExec(t, db, `INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', 0)`, f.a1, ada)
mustExec(t, db, `INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', 0)`, f.a3, ada)
return f
}
func getHome(t *testing.T, h *Handlers, u auth.User) (homeResponse, []byte) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/v1/home", nil)
rec := httptest.NewRecorder()
h.handleHome(rec, req, u)
if rec.Code != http.StatusOK {
t.Fatalf("home status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp homeResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode home: %v (body %s)", err, rec.Body.String())
}
return resp, rec.Body.Bytes()
}
func TestHandleHomeShelvesAndCounts(t *testing.T) {
f := setupHome(t)
h := homeHandlers(t, f.db)
resp, body := getHome(t, h, auth.User{ID: f.userA})
// The landing screen contract: all five shelves plus the sidebar counts.
var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
t.Fatalf("decode raw home: %v", err)
}
for _, key := range []string{"continue", "most_viewed", "suggestions", "recent", "explore", "counts_by_kind", "library_count", "total_items"} {
if _, ok := raw[key]; !ok {
t.Errorf("home response is missing %q", key)
}
}
if resp.Continue == nil || resp.MostViewed == nil || resp.Suggestions == nil || resp.Recent == nil || resp.Explore == nil {
t.Fatalf("a shelf is nil: continue=%v most=%v sugg=%v recent=%v explore=%v",
resp.Continue == nil, resp.MostViewed == nil, resp.Suggestions == nil, resp.Recent == nil, resp.Explore == nil)
}
// Continue: only a1. a2 finished, a4 missing, b1 belongs to another user.
if got := ids(resp.Continue); len(got) != 1 || got[0] != f.a1 {
t.Errorf("continue = %v, want [%d]", got, f.a1)
}
for _, s := range resp.Continue {
if s.Percent <= 0 {
t.Errorf("continue item %d has percent %v", s.ID, s.Percent)
}
}
// Most viewed: a1 (3 views) then a3 (1). b1 is in another library.
if got := ids(resp.MostViewed); len(got) != 2 || got[0] != f.a1 || got[1] != f.a3 {
t.Errorf("most_viewed = %v, want [%d %d]", got, f.a1, f.a3)
}
// Counts exclude the missing item and the invisible library.
if resp.CountsByKind["ebooks"] != 3 || resp.CountsByKind["comics"] != 1 {
t.Errorf("counts_by_kind = %v, want ebooks:3 comics:1", resp.CountsByKind)
}
if _, ok := resp.CountsByKind["audiobooks"]; ok {
t.Errorf("counts_by_kind leaked another library: %v", resp.CountsByKind)
}
if resp.TotalItems != 4 {
t.Errorf("total_items = %d, want 4", resp.TotalItems)
}
if resp.LibraryCount != 1 {
t.Errorf("library_count = %d, want 1", resp.LibraryCount)
}
// Recent excludes the missing item and the other library, newest first.
recent := ids(resp.Recent)
if len(recent) != 4 {
t.Errorf("recent = %v, want 4 libA items", recent)
}
for _, id := range recent {
if id == f.a4 || id == f.b1 {
t.Errorf("recent included excluded item %d", id)
}
}
}
func TestHandleHomeGrantScoping(t *testing.T) {
f := setupHome(t)
h := homeHandlers(t, f.db)
// userA holds a grant to libA only. Nothing from libB may surface anywhere.
resp, _ := getHome(t, h, auth.User{ID: f.userA})
for name, shelf := range map[string][]shelfItem{
"continue": resp.Continue, "most_viewed": resp.MostViewed,
"recent": resp.Recent, "explore": resp.Explore,
} {
for _, s := range shelf {
if s.LibraryID != f.libA {
t.Errorf("%s item %d leaked from library %d, want only %d", name, s.ID, s.LibraryID, f.libA)
}
}
}
for _, s := range resp.Suggestions {
if s.LibraryID != f.libA {
t.Errorf("suggestion %d leaked from library %d", s.ID, s.LibraryID)
}
}
// A user with no grants gets empty shelves, not an error and not content.
none, _ := getHome(t, h, auth.User{ID: f.userNone})
if none.LibraryCount != 0 || none.TotalItems != 0 || len(none.CountsByKind) != 0 {
t.Errorf("no-grant user got library_count=%d total=%d counts=%v",
none.LibraryCount, none.TotalItems, none.CountsByKind)
}
for name, n := range map[string]int{
"continue": len(none.Continue), "most_viewed": len(none.MostViewed),
"suggestions": len(none.Suggestions), "recent": len(none.Recent), "explore": len(none.Explore),
} {
if n != 0 {
t.Errorf("no-grant user's %s shelf has %d items", name, n)
}
}
}
func TestHandleHomeIsolatesUsers(t *testing.T) {
f := setupHome(t)
h := homeHandlers(t, f.db)
resp, _ := getHome(t, h, auth.User{ID: f.userB})
if got := ids(resp.Continue); len(got) != 1 || got[0] != f.b1 {
t.Errorf("user B continue = %v, want [%d]", got, f.b1)
}
if got := ids(resp.MostViewed); len(got) != 1 || got[0] != f.b1 {
t.Errorf("user B most_viewed = %v, want [%d]", got, f.b1)
}
if resp.CountsByKind["audiobooks"] != 1 || resp.TotalItems != 1 || resp.LibraryCount != 1 {
t.Errorf("user B counts = %v total=%d library_count=%d, want audiobooks:1 total:1 libs:1",
resp.CountsByKind, resp.TotalItems, resp.LibraryCount)
}
for name, shelf := range map[string][]shelfItem{
"continue": resp.Continue, "most_viewed": resp.MostViewed,
"recent": resp.Recent, "explore": resp.Explore,
} {
for _, s := range shelf {
if s.LibraryID != f.libB {
t.Errorf("user B's %s saw library %d", name, s.LibraryID)
}
}
}
}
// ── Explore ────────────────────────────────────────────────────────────
func TestExploreOnlyVisibleAndPresent(t *testing.T) {
db, libA, libB, userA, userB := setupExplore(t)
ctx := context.Background()
var libAIDs []int64
for i := 0; i < 6; i++ {
libAIDs = append(libAIDs, newItem(t, db, libA, "ebooks",
fmt.Sprintf("a%d.epub", i), fmt.Sprintf("A%d", i), fmt.Sprintf("a%02d", i),
false, "2026-01-01T00:00:00.000Z"))
}
missing := map[int64]bool{}
for i := 0; i < 3; i++ {
missing[newItem(t, db, libA, "ebooks",
fmt.Sprintf("m%d.epub", i), fmt.Sprintf("M%d", i), fmt.Sprintf("m%02d", i),
true, "2026-01-01T00:00:00.000Z")] = true
}
for i := 0; i < 6; i++ {
newItem(t, db, libB, "audiobooks",
fmt.Sprintf("b%d.mp3", i), fmt.Sprintf("B%d", i), fmt.Sprintf("b%02d", i),
false, "2026-01-01T00:00:00.000Z")
}
got, err := Explore(ctx, db, userA, []int64{libA}, 30)
if err != nil {
t.Fatal(err)
}
if len(got) != len(libAIDs) {
t.Fatalf("explore returned %d, want all %d present libA items", len(got), len(libAIDs))
}
seen := idSet(got)
for _, id := range libAIDs {
if !seen[id] {
t.Errorf("explore omitted present item %d", id)
}
}
for _, s := range got {
if s.LibraryID != libA {
t.Errorf("explore item %d came from library %d, want %d", s.ID, s.LibraryID, libA)
}
if missing[s.ID] {
t.Errorf("explore returned missing item %d", s.ID)
}
}
// userB must never see libA content.
gotB, err := Explore(ctx, db, userB, []int64{libB}, 30)
if err != nil {
t.Fatal(err)
}
for _, s := range gotB {
if s.LibraryID != libB {
t.Errorf("user B explore item %d came from library %d", s.ID, s.LibraryID)
}
}
}
// TestExploreExcludesContinueShelf guards the whole point of the shelf: it is
// discovery, so anything the user already has in progress must not reappear.
func TestExploreExcludesContinueShelf(t *testing.T) {
db, libA, _, userA, _ := setupExplore(t)
ctx := context.Background()
var all []int64
for i := 0; i < 20; i++ {
all = append(all, newItem(t, db, libA, "ebooks",
fmt.Sprintf("a%d.epub", i), fmt.Sprintf("A%d", i), fmt.Sprintf("a%02d", i),
false, "2026-01-01T00:00:00.000Z"))
}
// Three of them are on the Continue shelf.
inProgress := map[int64]bool{all[0]: true, all[7]: true, all[19]: true}
for id := range inProgress {
newProgress(t, db, userA, id, 0.4, false, "2026-02-01T00:00:00.000Z")
}
// Repeated calls, because Explore samples a random window per kind.
for i := 0; i < 50; i++ {
got, err := Explore(ctx, db, userA, []int64{libA}, 10)
if err != nil {
t.Fatal(err)
}
if len(got) == 0 {
t.Fatal("explore returned nothing")
}
for _, s := range got {
if inProgress[s.ID] {
t.Fatalf("call %d returned in-progress item %d", i, s.ID)
}
}
}
}
// TestExploreRepresentsEveryKind checks the fair-share split: when the limit is
// large enough, every kind present in the visible libraries must appear.
func TestExploreRepresentsEveryKind(t *testing.T) {
db, libA, _, userA, _ := setupExplore(t)
ctx := context.Background()
kinds := []string{"ebooks", "comics", "audiobooks"}
for _, kind := range kinds {
for i := 0; i < 12; i++ {
newItem(t, db, libA, kind,
fmt.Sprintf("%s-%d.dat", kind, i), fmt.Sprintf("%s %d", kind, i),
fmt.Sprintf("%s-%02d", kind, i), false, "2026-01-01T00:00:00.000Z")
}
}
got, err := Explore(ctx, db, userA, []int64{libA}, 30)
if err != nil {
t.Fatal(err)
}
if len(got) != 30 {
t.Fatalf("explore returned %d items, want the full limit 30", len(got))
}
if hasDuplicates(got) {
t.Errorf("explore returned duplicate items: %v", ids(got))
}
present := map[string]bool{}
for _, s := range got {
present[s.Kind] = true
}
for _, kind := range kinds {
if !present[kind] {
t.Errorf("kind %q missing from explore shelf %v", kind, ids(got))
}
}
}
// TestExploreNoDuplicates exercises the wrap-around path: the result set must
// never repeat an item even though a kind may be filled from both halves of the
// id range.
func TestExploreNoDuplicates(t *testing.T) {
db, libA, _, userA, _ := setupExplore(t)
ctx := context.Background()
for i := 0; i < 40; i++ {
newItem(t, db, libA, "ebooks",
fmt.Sprintf("a%d.epub", i), fmt.Sprintf("A%d", i), fmt.Sprintf("a%02d", i),
false, "2026-01-01T00:00:00.000Z")
}
for i := 0; i < 100; i++ {
got, err := Explore(ctx, db, userA, []int64{libA}, 15)
if err != nil {
t.Fatal(err)
}
if hasDuplicates(got) {
t.Fatalf("call %d returned duplicates: %v", i, ids(got))
}
}
}
// TestExploreVariesAcrossCalls pins the fix for the old deterministic ordering:
// with far more items than the limit, repeated visits must not return the same
// slice every time. It asserts variety (an invariant of the sampler) rather
// than a particular order, so it is not sensitive to the random seed.
func TestExploreVariesAcrossCalls(t *testing.T) {
db, libA, _, userA, _ := setupExplore(t)
ctx := context.Background()
for i := 0; i < 120; i++ {
newItem(t, db, libA, "ebooks",
fmt.Sprintf("a%d.epub", i), fmt.Sprintf("A%d", i), fmt.Sprintf("a%03d", i),
false, "2026-01-01T00:00:00.000Z")
}
variants := map[string]bool{}
for i := 0; i < 30; i++ {
got, err := Explore(ctx, db, userA, []int64{libA}, 30)
if err != nil {
t.Fatal(err)
}
variants[signature(got)] = true
}
if len(variants) < 2 {
t.Fatalf("explore returned an identical shelf on all 30 calls (%d items each); the sampler is deterministic", len(variants))
}
}
// TestExploreCompleteWhenLimitCoversEverything checks the short-result /
// wrap-around path deterministically: regardless of where the random start
// lands, a limit larger than the library must yield the whole eligible set.
func TestExploreCompleteWhenLimitCoversEverything(t *testing.T) {
db, libA, _, userA, _ := setupExplore(t)
ctx := context.Background()
var want []int64
for _, kind := range []string{"ebooks", "comics"} {
for i := 0; i < 4; i++ {
want = append(want, newItem(t, db, libA, kind,
fmt.Sprintf("%s-%d.dat", kind, i), fmt.Sprintf("%s %d", kind, i),
fmt.Sprintf("%s-%02d", kind, i), false, "2026-01-01T00:00:00.000Z"))
}
}
got, err := Explore(ctx, db, userA, []int64{libA}, 100)
if err != nil {
t.Fatal(err)
}
seen := idSet(got)
for _, id := range want {
if !seen[id] {
t.Errorf("limit above library size omitted item %d", id)
}
}
if len(got) != len(want) {
t.Errorf("explore returned %d items, want exactly %d", len(got), len(want))
}
}
// signature renders an item slice as a stable string for set comparison.
func signature(items []shelfItem) string {
ids := ids(items)
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
parts := make([]string, len(ids))
for i, id := range ids {
parts[i] = strconv.FormatInt(id, 10)
}
return strings.Join(parts, ",")
}
// ── RecordView / MostViewed ────────────────────────────────────────────
func TestMostViewedOrdersByViewsThenRecency(t *testing.T) {
db, libA, _, userA, _ := setupExplore(t)
ctx := context.Background()
older := newItem(t, db, libA, "ebooks", "older.epub", "Older", "older", false, "2026-01-01T00:00:00.000Z")
newer := newItem(t, db, libA, "ebooks", "newer.epub", "Newer", "newer", false, "2026-01-01T00:00:00.000Z")
most := newItem(t, db, libA, "ebooks", "most.epub", "Most", "most", false, "2026-01-01T00:00:00.000Z")
// Equal view counts, different recency: the newer one must win.
newView(t, db, userA, older, 5, "2026-03-01T00:00:00.000Z")
newView(t, db, userA, newer, 5, "2026-03-02T00:00:00.000Z")
// More views always outranks recency.
newView(t, db, userA, most, 9, "2026-01-01T00:00:00.000Z")
got, err := MostViewed(ctx, db, userA, []int64{libA}, 10)
if err != nil {
t.Fatal(err)
}
want := []int64{most, newer, older}
if len(got) != len(want) {
t.Fatalf("most viewed = %v, want %v", ids(got), want)
}
for i := range want {
if got[i].ID != want[i] {
t.Errorf("most viewed[%d] = %d, want %d (full order %v)", i, got[i].ID, want[i], ids(got))
}
}
}
func TestMostViewedExcludesMissing(t *testing.T) {
db, libA, _, userA, _ := setupExplore(t)
ctx := context.Background()
present := newItem(t, db, libA, "ebooks", "p.epub", "P", "p", false, "2026-01-01T00:00:00.000Z")
gone := newItem(t, db, libA, "ebooks", "g.epub", "G", "g", true, "2026-01-01T00:00:00.000Z")
newView(t, db, userA, present, 1, "2026-03-01T00:00:00.000Z")
newView(t, db, userA, gone, 50, "2026-03-02T00:00:00.000Z")
got, err := MostViewed(ctx, db, userA, []int64{libA}, 10)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].ID != present {
t.Errorf("most viewed = %v, want only the present item %d", ids(got), present)
}
}
// TestRecordViewIsPerUser guards the (user_id, item_id) key: one user's habit
// must not move another user's count.
func TestRecordViewIsPerUser(t *testing.T) {
db, libA, _, userA, userB := setupExplore(t)
ctx := context.Background()
item := newItem(t, db, libA, "ebooks", "x.epub", "X", "x", false, "2026-01-01T00:00:00.000Z")
// userB is not granted libA, but RecordView itself is keyed by user id.
grantRead(t, db, userB, libA)
for i := 1; i <= 3; i++ {
n, err := RecordView(ctx, db, userA, item)
if err != nil {
t.Fatal(err)
}
if n != i {
t.Errorf("user A view %d returned %d", i, n)
}
}
n, err := RecordView(ctx, db, userB, item)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("user B's first view returned %d, want 1 (counts are per user)", n)
}
}
// ── Suggestions ────────────────────────────────────────────────────────
func TestSuggestionsReasonsAndUniqueness(t *testing.T) {
f := setupHome(t)
ctx := context.Background()
sugg, err := Suggestions(ctx, f.db, f.userA, []int64{f.libA}, 20)
if err != nil {
t.Fatal(err)
}
if len(sugg) == 0 {
t.Fatal("no suggestions")
}
seen := map[int64]bool{}
for _, s := range sugg {
if s.Reason == "" {
t.Errorf("suggestion %d has an empty reason", s.ID)
}
if seen[s.ID] {
t.Errorf("suggestion %d returned twice", s.ID)
}
seen[s.ID] = true
if s.LibraryID != f.libA {
t.Errorf("suggestion %d leaked from library %d", s.ID, s.LibraryID)
}
}
// a1 is in progress, so it must not be suggested; a3 (same author) and a5
// (same kind) are the unstarted candidates.
if seen[f.a1] {
t.Error("an in-progress item was suggested")
}
if !seen[f.a3] || !seen[f.a5] {
t.Errorf("expected same-author a3 and same-kind a5, got %v", idsOfSuggestions(sugg))
}
}
func idsOfSuggestions(s []suggestion) []int64 {
out := make([]int64, len(s))
for i := range s {
out[i] = s[i].ID
}
return out
}
// TestSuggestionsSeedIsNotScopedToVisibleLibraries documents a hardening gap,
// NOT desired behaviour.
//
// topEngagement() picks the user's most recent progress row across every
// library, including ones the user can no longer read (e.g. a revoked grant).
// The items Suggestions returns are still correctly scoped to the visible
// libraries, so nothing hidden is ever listed — but the inaccessible row's
// kind/author still shapes the shelf. Here a hidden library's ebooks progress
// makes the visible ebooks item come back as "Recently added" rather than being
// mislabelled "More books" from a library the user cannot read.
//
// The seed (topEngagement) is now scoped to visible libraries. Before that, a
// revoked library's most recent progress row still steered the home screen:
// the item below was labelled "More books" — a suggestion reason derived from
// content the user no longer had access to.
func TestSuggestionsSeedIsScopedToVisibleLibraries(t *testing.T) {
db := testDB(t)
ctx := context.Background()
visible := newLibrary(t, db, "Seed Visible", "ebooks", "/seed/v")
hidden := newLibrary(t, db, "Seed Hidden", "ebooks", "/seed/h")
u := newUser(t, db, "seed-user")
grantRead(t, db, u, visible)
newItem(t, db, visible, "ebooks", "v1.epub", "V1", "v1", false, "2026-01-01T00:00:00.000Z")
hid := newItem(t, db, hidden, "ebooks", "h1.epub", "H1", "h1", false, "2026-01-01T00:00:00.000Z")
newProgress(t, db, u, hid, 0.5, false, "2026-02-01T00:00:00.000Z")
sugg, err := Suggestions(ctx, db, u, []int64{visible}, 20)
if err != nil {
t.Fatal(err)
}
if len(sugg) != 1 {
t.Fatalf("suggestions = %d, want 1", len(sugg))
}
if sugg[0].LibraryID != visible {
t.Errorf("suggestion leaked library %d", sugg[0].LibraryID)
}
if sugg[0].Reason != "Recently added" {
t.Errorf("reason = %q; want \"Recently added\" — a revoked library's progress must not label the suggestion", sugg[0].Reason)
}
}
// setupExplore builds two libraries with one user each and returns them.
func setupExplore(t *testing.T) (db *sql.DB, libA, libB, userA, userB int64) {
t.Helper()
db = testDB(t)
libA = newLibrary(t, db, "Explore A", "ebooks", "/explore/a")
libB = newLibrary(t, db, "Explore B", "audiobooks", "/explore/b")
userA = newUser(t, db, "explore-a")
userB = newUser(t, db, "explore-b")
grantRead(t, db, userA, libA)
grantRead(t, db, userB, libB)
return db, libA, libB, userA, userB
}