Vellum/internal/media/handlers_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

70 lines
2.2 KiB
Go

package media
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/mithral/vellum/internal/auth"
)
// getItem drives the per-item detail handler directly. PathValue is normally
// filled in by the ServeMux; the test sets it explicitly.
func getItem(t *testing.T, h *Handlers, u auth.User, id string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/v1/items/"+id, nil)
req.SetPathValue("id", id)
rec := httptest.NewRecorder()
h.handleItem(rec, req, u)
return rec
}
// TestHandleItemScoping pins the per-item access chokepoint: an item in a
// library the caller cannot read must be indistinguishable from a missing one.
func TestHandleItemScoping(t *testing.T) {
f := setupHome(t)
h := mediaHandlers(t, f.db)
// userA may read libA, so a1 resolves.
rec := getItem(t, h, auth.User{ID: f.userA}, itoa(f.a1))
if rec.Code != http.StatusOK {
t.Fatalf("accessible item status = %d, body=%s", rec.Code, rec.Body.String())
}
var body struct {
Item struct {
ID int64 `json:"id"`
LibraryID int64 `json:"library_id"`
Kind string `json:"kind"`
} `json:"item"`
Authors []string `json:"authors"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode item: %v", err)
}
if body.Item.ID != f.a1 || body.Item.LibraryID != f.libA || body.Item.Kind != "ebooks" {
t.Errorf("item = %+v, want id %d in library %d", body.Item, f.a1, f.libA)
}
if len(body.Authors) != 1 || body.Authors[0] != "Ada Author" {
t.Errorf("authors = %v, want [Ada Author]", body.Authors)
}
// b1 is in libB, which userA cannot read: 404, not 403, so existence is
// not disclosed.
rec = getItem(t, h, auth.User{ID: f.userA}, itoa(f.b1))
if rec.Code != http.StatusNotFound {
t.Errorf("inaccessible item status = %d, want 404", rec.Code)
}
// A user with no grants gets the same 404.
rec = getItem(t, h, auth.User{ID: f.userNone}, itoa(f.a1))
if rec.Code != http.StatusNotFound {
t.Errorf("no-grant item status = %d, want 404", rec.Code)
}
// A non-numeric id is a 400, not a panic.
rec = getItem(t, h, auth.User{ID: f.userA}, "abc")
if rec.Code != http.StatusBadRequest {
t.Errorf("invalid id status = %d, want 400", rec.Code)
}
}