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 }