Authors are searchable (internal/search, migrations/0009) - The FTS index covered only title and path, so typing an artist or author found nothing even though people/item_people already held the credits. - Rather than a second index, the names are denormalised into a real media_items.authors column, so the existing external-content FTS and its triggers keep doing the work; item_people/people triggers recompute it, and the media_items UPDATE trigger re-indexes automatically. - Verified: searching "daft punk" now returns Random Access Memories, and so does the partial "punk". - The agent found and worked around a real FTS5 external-content bug: on an UPGRADE the backfill UPDATE fires the delete trigger for rowids not yet in the fresh index, raising "database disk image is malformed". A rebuild before the backfill fixes it. This would have broken existing databases while a fresh install looked fine. gzip for the SPA (internal/httpapi/static.go) - 456 KB of JS and 31 KB of CSS were served uncompressed on every cold load. - Scoped to the embedded SPA assets only, never API or media: compressing a Range response would break seeking, and wrapping every response would undo Wave 2's Flusher/ReaderFrom forwarding. - Compresses only when the client accepts gzip, the response is a compressible type, there is no Range header, and nothing already set Content-Encoding. Sets Vary: Accept-Encoding and drops the now-wrong Content-Length. - Verified by sha256 round-trip (identity vs gzip-decompressed are identical), and by confirming a Range request still returns 206 with no Content-Encoding. JS 351,835 -> 108,794 bytes (69% smaller); CSS 80% smaller. epub.js is code-split (Reader.svelte) - It was a static import, so ~350 KB was downloaded by everyone on first load even though most never open an ebook. Now loaded on demand. - Main bundle 458 KB -> 112 KB (gzipped 149 KB -> 39.5 KB). - Verified at runtime, which pnpm check cannot: only the main bundle loads initially, the epubjs chunk is fetched when a book is opened, and the reader still renders. Home screen tests (internal/media) - Coverage 23.4% -> 50.2%. (The 17.9% quoted in the brief was a stale reading; the agent measured the real baseline by moving its own files aside.) - Covers the five shelves, counts, Explore's invariants (visibility, no duplicates, every kind represented, varies across calls, wrap-around), the list offset/limit/total contract, MostViewed ordering, and — most importantly — grant scoping, which is the security chokepoint. Fixed a bug the tests found: Suggestions seeded from revoked libraries - topEngagement selected the user's most recent progress row with no library filter, so progress from a library whose grant had been revoked still steered the home screen — a visible item was labelled "More books" based on content the user could no longer read. Returned items were always correctly scoped, so this was an information-flow gap rather than a content leak. - Now scoped to visible libraries, and the test that pinned the old behaviour flipped to assert the correct reason. gofmt/vet clean, 14 packages pass, media tests run 3x for flakiness.
728 lines
24 KiB
Go
728 lines
24 KiB
Go
package media
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mithral/vellum/internal/auth"
|
|
)
|
|
|
|
// ── shared helpers ─────────────────────────────────────────────────────
|
|
|
|
func mediaHandlers(t *testing.T, db *sql.DB) *Handlers {
|
|
t.Helper()
|
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
return NewHandlers(db, log, nil, t.TempDir())
|
|
}
|
|
|
|
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 := mediaHandlers(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 := mediaHandlers(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 := mediaHandlers(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
|
|
}
|