Vellum/internal/media/items_test.go
mithral 09474b0e08 Wave 3: search depth, transfer size, and the Home screen's tests
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.
2026-10-11 13:46:18 -04:00

250 lines
7.5 KiB
Go

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
}