package media import ( "database/sql" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/mithral/vellum/internal/auth" ) // listResponse mirrors the JSON contract of handleListItems. type listResponse struct { Items []itemRow `json:"items"` Total int `json:"total"` Offset int `json:"offset"` Limit int `json:"limit"` } func listItems(t *testing.T, h *Handlers, u auth.User, query string) (int, listResponse) { t.Helper() req := httptest.NewRequest(http.MethodGet, "/api/v1/items"+query, nil) rec := httptest.NewRecorder() h.handleListItems(rec, req, u) var resp listResponse if rec.Code == http.StatusOK { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode list: %v (body %s)", err, rec.Body.String()) } } return rec.Code, resp } type listFixture struct { db *sql.DB libA int64 libB int64 userA int64 userNone int64 e1 int64 // libA ebooks, sort 01 e2 int64 // libA ebooks, sort 02 e3 int64 // libA ebooks, sort 03 c1 int64 // libA comics, sort 04 m1 int64 // libA ebooks, missing b1 int64 // libB audiobooks } func setupList(t *testing.T) listFixture { t.Helper() db := testDB(t) f := listFixture{db: db} f.libA = newLibrary(t, db, "List A", "ebooks", "/list/a") f.libB = newLibrary(t, db, "List B", "audiobooks", "/list/b") f.userA = newUser(t, db, "list-a") f.userNone = newUser(t, db, "list-none") grantRead(t, db, f.userA, f.libA) const at = "2026-01-01T00:00:00.000Z" f.e1 = newItem(t, db, f.libA, "ebooks", "e1.epub", "Echo", "01 echo", false, at) f.e2 = newItem(t, db, f.libA, "ebooks", "e2.epub", "Delta", "02 delta", false, at) f.e3 = newItem(t, db, f.libA, "ebooks", "e3.epub", "Charlie", "03 charlie", false, at) f.c1 = newItem(t, db, f.libA, "comics", "c1.cbz", "Bravo", "04 bravo", false, at) f.m1 = newItem(t, db, f.libA, "ebooks", "m1.epub", "Missing", "05 missing", true, at) f.b1 = newItem(t, db, f.libB, "audiobooks", "b1.mp3", "Alpha", "06 alpha", false, at) return f } // TestListItemsTotalIsNotThePage pins the recently added offset/limit/total // contract: total counts every matching row, independent of the page window. func TestListItemsTotalIsNotThePage(t *testing.T) { f := setupList(t) h := mediaHandlers(t, f.db) code, resp := listItems(t, h, auth.User{ID: f.userA}, "?limit=2") if code != http.StatusOK { t.Fatalf("status = %d, want 200", code) } if resp.Total != 4 { t.Errorf("total = %d, want 4 (all matching present rows, not the page)", resp.Total) } if len(resp.Items) != 2 { t.Errorf("page has %d items, want 2", len(resp.Items)) } if resp.Limit != 2 || resp.Offset != 0 { t.Errorf("limit/offset = %d/%d, want 2/0", resp.Limit, resp.Offset) } for _, it := range resp.Items { if it.ID == f.m1 { t.Error("a missing item was listed") } } } func TestListItemsPaging(t *testing.T) { f := setupList(t) h := mediaHandlers(t, f.db) u := auth.User{ID: f.userA} _, p1 := listItems(t, h, u, "?limit=2&offset=0") _, p2 := listItems(t, h, u, "?limit=2&offset=2") if len(p1.Items) != 2 || len(p2.Items) != 2 { t.Fatalf("page sizes = %d/%d, want 2/2", len(p1.Items), len(p2.Items)) } if p1.Offset != 0 || p2.Offset != 2 { t.Errorf("offsets = %d/%d, want 0/2", p1.Offset, p2.Offset) } // Pages must not overlap, and must order by sort_title. if p1.Items[0].ID != f.e1 || p1.Items[1].ID != f.e2 { t.Errorf("page 1 = %v, want [%d %d]", idsFromRows(p1.Items), f.e1, f.e2) } if p2.Items[0].ID != f.e3 || p2.Items[1].ID != f.c1 { t.Errorf("page 2 = %v, want [%d %d]", idsFromRows(p2.Items), f.e3, f.c1) } seen := map[int64]bool{} for _, it := range append(append([]itemRow{}, p1.Items...), p2.Items...) { if seen[it.ID] { t.Errorf("item %d appears on both pages", it.ID) } seen[it.ID] = true } // A page past the end is empty but still reports the true total. _, beyond := listItems(t, h, u, "?limit=2&offset=99") if len(beyond.Items) != 0 || beyond.Total != 4 { t.Errorf("beyond page: items=%d total=%d, want 0/4", len(beyond.Items), beyond.Total) } } func TestListItemsFiltersCompose(t *testing.T) { f := setupList(t) h := mediaHandlers(t, f.db) u := auth.User{ID: f.userA} _, ebooks := listItems(t, h, u, "?kind=ebooks") if ebooks.Total != 3 { t.Errorf("kind=ebooks total = %d, want 3", ebooks.Total) } for _, it := range ebooks.Items { if it.Kind != "ebooks" { t.Errorf("kind filter returned a %q item", it.Kind) } } _, libA := listItems(t, h, u, "?library_id="+itoa(f.libA)) if libA.Total != 4 { t.Errorf("library_id=%d total = %d, want 4", f.libA, libA.Total) } // kind and library_id compose (both must hold). _, both := listItems(t, h, u, "?kind=ebooks&library_id="+itoa(f.libA)) if both.Total != 3 { t.Errorf("kind+library total = %d, want 3", both.Total) } for _, it := range both.Items { if it.Kind != "ebooks" || it.LibraryID != f.libA { t.Errorf("composed filter returned %+v", it) } } // A kind with no items is an empty page, not an error. code, none := listItems(t, h, u, "?kind=movies") if code != http.StatusOK || none.Total != 0 || len(none.Items) != 0 { t.Errorf("unknown kind: status=%d total=%d items=%d", code, none.Total, len(none.Items)) } } func TestListItemsScoping(t *testing.T) { f := setupList(t) h := mediaHandlers(t, f.db) // A user with no grants sees nothing. _, none := listItems(t, h, auth.User{ID: f.userNone}, "?limit=100") if none.Total != 0 || len(none.Items) != 0 { t.Errorf("no-grant user saw total=%d items=%d", none.Total, len(none.Items)) } // Asking explicitly for a library the user cannot read must not leak it. code, leak := listItems(t, h, auth.User{ID: f.userA}, "?library_id="+itoa(f.libB)+"&limit=100") if code != http.StatusOK { t.Fatalf("status = %d, want 200", code) } if leak.Total != 0 || len(leak.Items) != 0 { t.Errorf("user A read library %d: total=%d items=%d", f.libB, leak.Total, len(leak.Items)) } // The unfiltered list contains only the readable library. _, all := listItems(t, h, auth.User{ID: f.userA}, "?limit=100") for _, it := range all.Items { if it.LibraryID != f.libA { t.Errorf("item %d leaked from library %d", it.ID, it.LibraryID) } } if containsRowID(all.Items, f.b1) { t.Error("user A's list contained another user's item") } } func TestListItemsInvalidLibraryID(t *testing.T) { f := setupList(t) h := mediaHandlers(t, f.db) for _, q := range []string{"?library_id=abc", "?library_id=-1", "?library_id=0"} { code, _ := listItems(t, h, auth.User{ID: f.userA}, q) if code != http.StatusBadRequest { t.Errorf("%s status = %d, want 400", q, code) } } } func TestListItemsDefaultsAndCaps(t *testing.T) { f := setupList(t) h := mediaHandlers(t, f.db) u := auth.User{ID: f.userA} _, def := listItems(t, h, u, "") if def.Limit != 300 || def.Offset != 0 { t.Errorf("defaults = limit %d offset %d, want 300/0", def.Limit, def.Offset) } // Out-of-range limit and negative offset fall back to the defaults. for _, q := range []string{"?limit=0", "?limit=5000", "?limit=-1"} { _, r := listItems(t, h, u, q) if r.Limit != 300 { t.Errorf("%s limit = %d, want 300", q, r.Limit) } } _, neg := listItems(t, h, u, "?offset=-5") if neg.Offset != 0 { t.Errorf("negative offset = %d, want 0", neg.Offset) } } func idsFromRows(rows []itemRow) []int64 { out := make([]int64, len(rows)) for i, r := range rows { out[i] = r.ID } return out } func containsRowID(rows []itemRow, id int64) bool { for _, r := range rows { if r.ID == id { return true } } return false }