Vellum/internal/search/search_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

298 lines
8.6 KiB
Go

package search
import (
"context"
"database/sql"
"io"
"log/slog"
"path/filepath"
"testing"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/migrations"
)
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "search.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 addLibrary(t *testing.T, db *sql.DB, name, kind string) int64 {
t.Helper()
var id int64
err := db.QueryRowContext(context.Background(),
`INSERT INTO libraries (name, kind, root_path) VALUES (?, ?, ?) RETURNING id`,
name, kind, "/media/"+name).Scan(&id)
if err != nil {
t.Fatal(err)
}
return id
}
func addItem(t *testing.T, db *sql.DB, libraryID int64, kind, path, title string) int64 {
t.Helper()
var id int64
err := db.QueryRowContext(context.Background(),
`INSERT INTO media_items (library_id, kind, path, title, sort_title) VALUES (?, ?, ?, ?, ?) RETURNING id`,
libraryID, kind, path, title, title).Scan(&id)
if err != nil {
t.Fatal(err)
}
return id
}
func addPerson(t *testing.T, db *sql.DB, name string) int64 {
t.Helper()
var id int64
err := db.QueryRowContext(context.Background(),
`INSERT INTO people (name) VALUES (?) RETURNING id`, name).Scan(&id)
if err != nil {
t.Fatal(err)
}
return id
}
func addCredit(t *testing.T, db *sql.DB, itemID, personID int64, role string, ord int) {
t.Helper()
if _, err := db.ExecContext(context.Background(),
`INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, ?, ?)`,
itemID, personID, role, ord); err != nil {
t.Fatal(err)
}
}
func titles(results []Result) []string {
out := make([]string, len(results))
for i, r := range results {
out[i] = r.Title
}
return out
}
func contains(haystack []string, needle string) bool {
for _, h := range haystack {
if h == needle {
return true
}
}
return false
}
func TestQueryFindsItems(t *testing.T) {
db := testDB(t)
ctx := context.Background()
lib := addLibrary(t, db, "ebooks", "ebooks")
addItem(t, db, lib, "ebooks", "hobbit.epub", "The Hobbit")
addItem(t, db, lib, "ebooks", "hail.epub", "Project Hail Mary")
res, err := Query(ctx, db, "hobbit", []int64{lib}, 50)
if err != nil {
t.Fatalf("Query: %v", err)
}
if len(res) != 1 || res[0].Title != "The Hobbit" {
t.Fatalf("results = %v", titles(res))
}
// Multi-term is an implicit AND.
res, err = Query(ctx, db, "project mary", []int64{lib}, 50)
if err != nil {
t.Fatal(err)
}
if len(res) != 1 || res[0].Title != "Project Hail Mary" {
t.Errorf("multi-term results = %v", titles(res))
}
// A term that matches nothing.
res, err = Query(ctx, db, "nonexistent", []int64{lib}, 50)
if err != nil {
t.Fatal(err)
}
if len(res) != 0 {
t.Errorf("expected no results, got %v", titles(res))
}
}
func TestQueryFindsItemsByAuthor(t *testing.T) {
db := testDB(t)
ctx := context.Background()
lib := addLibrary(t, db, "ebooks", "ebooks")
id := addItem(t, db, lib, "ebooks", "notes.epub", "Notes on the Analytical Engine")
ada := addPerson(t, db, "Ada Lovelace")
addCredit(t, db, id, ada, "author", 0)
// The credit is not in the title or path, so this only passes if the FTS
// index covers the denormalised authors column.
res, err := Query(ctx, db, "lovelace", []int64{lib}, 50)
if err != nil {
t.Fatalf("Query: %v", err)
}
if len(res) != 1 || res[0].ID != id {
t.Fatalf("author search results = %v", titles(res))
}
if res[0].Authors != "Ada Lovelace" {
t.Errorf("Authors = %q, want %q", res[0].Authors, "Ada Lovelace")
}
}
// TestAuthorIndexStaysInSync exercises every trigger 0009 installs on the
// credit tables: insert, delete, rename (people UPDATE), and moving a credit
// between items (item_people UPDATE).
func TestAuthorIndexStaysInSync(t *testing.T) {
db := testDB(t)
ctx := context.Background()
lib := addLibrary(t, db, "ebooks", "ebooks")
id := addItem(t, db, lib, "ebooks", "notes.epub", "Notes")
ada := addPerson(t, db, "Ada Lovelace")
addCredit(t, db, id, ada, "author", 0)
if res, _ := Query(ctx, db, "lovelace", []int64{lib}, 50); len(res) != 1 {
t.Fatal("credit insert did not reach the index")
}
// Deleting the credit must remove the author from the index.
if _, err := db.ExecContext(ctx,
`DELETE FROM item_people WHERE item_id = ? AND person_id = ?`, id, ada); err != nil {
t.Fatal(err)
}
if res, _ := Query(ctx, db, "lovelace", []int64{lib}, 50); len(res) != 0 {
t.Error("stale author survived the credit delete")
}
// Re-add, then rename: the old name must stop matching and the new start.
addCredit(t, db, id, ada, "author", 0)
if res, _ := Query(ctx, db, "lovelace", []int64{lib}, 50); len(res) != 1 {
t.Fatal("re-added credit did not reach the index")
}
if _, err := db.ExecContext(ctx,
`UPDATE people SET name = 'Ada King' WHERE id = ?`, ada); err != nil {
t.Fatal(err)
}
if res, _ := Query(ctx, db, "lovelace", []int64{lib}, 50); len(res) != 0 {
t.Error("old name survived the rename")
}
if res, _ := Query(ctx, db, "king", []int64{lib}, 50); len(res) != 1 {
t.Error("new name did not reach the index after the rename")
}
// Moving the credit to another item re-indexes both ends.
other := addItem(t, db, lib, "ebooks", "other.epub", "Other")
if _, err := db.ExecContext(ctx,
`UPDATE item_people SET item_id = ? WHERE item_id = ? AND person_id = ?`,
other, id, ada); err != nil {
t.Fatal(err)
}
res, err := Query(ctx, db, "king", []int64{lib}, 50)
if err != nil {
t.Fatal(err)
}
if len(res) != 1 || res[0].ID != other {
t.Errorf("moved credit: results = %v", titles(res))
}
}
func TestQueryIsScopedToLibraries(t *testing.T) {
db := testDB(t)
ctx := context.Background()
allowed := addLibrary(t, db, "allowed", "ebooks")
hidden := addLibrary(t, db, "hidden", "ebooks")
addItem(t, db, allowed, "ebooks", "a.epub", "Shared Word")
addItem(t, db, hidden, "ebooks", "b.epub", "Shared Word Two")
res, err := Query(ctx, db, "shared", []int64{allowed}, 50)
if err != nil {
t.Fatal(err)
}
if len(res) != 1 || res[0].LibraryID != allowed {
t.Fatalf("scoping failed: %v", titles(res))
}
// No libraries means no results, never everything.
res, err = Query(ctx, db, "shared", nil, 50)
if err != nil {
t.Fatal(err)
}
if len(res) != 0 {
t.Errorf("empty library set returned %v", titles(res))
}
}
func TestQueryHandlesHostileInput(t *testing.T) {
db := testDB(t)
ctx := context.Background()
lib := addLibrary(t, db, "ebooks", "ebooks")
addItem(t, db, lib, "ebooks", "a.epub", "The Hobbit")
// None of these may raise an FTS5 syntax error.
for _, q := range []string{
`"`, `"unbalanced`, `OR`, `AND NOT`, `foo*`, `-hobbit`, `title:hobbit`,
`hobbit OR`, `*`, `""`, `NEAR(`, `a"b`, `(); DROP TABLE users;--`,
} {
if _, err := Query(ctx, db, q, []int64{lib}, 50); err != nil {
t.Errorf("Query(%q) errored: %v", q, err)
}
}
// The injection attempt must not have dropped anything.
var n int
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM media_items`).Scan(&n); err != nil {
t.Fatalf("media_items is gone: %v", err)
}
if n != 1 {
t.Errorf("media_items count = %d, want 1", n)
}
}
func TestIndexStaysInSync(t *testing.T) {
db := testDB(t)
ctx := context.Background()
lib := addLibrary(t, db, "ebooks", "ebooks")
id := addItem(t, db, lib, "ebooks", "x.epub", "Original Title")
if res, _ := Query(ctx, db, "original", []int64{lib}, 50); len(res) != 1 {
t.Fatalf("insert did not reach the index")
}
if _, err := db.ExecContext(ctx, `UPDATE media_items SET title = 'Changed Title' WHERE id = ?`, id); err != nil {
t.Fatal(err)
}
if res, _ := Query(ctx, db, "changed", []int64{lib}, 50); len(res) != 1 {
t.Error("update did not reach the index")
}
if res, _ := Query(ctx, db, "original", []int64{lib}, 50); len(res) != 0 {
t.Error("stale entry survived the update")
}
if _, err := db.ExecContext(ctx, `DELETE FROM media_items WHERE id = ?`, id); err != nil {
t.Fatal(err)
}
if res, _ := Query(ctx, db, "changed", []int64{lib}, 50); len(res) != 0 {
t.Error("delete did not reach the index")
}
}
func TestMissingItemsAreExcluded(t *testing.T) {
db := testDB(t)
ctx := context.Background()
lib := addLibrary(t, db, "ebooks", "ebooks")
id := addItem(t, db, lib, "ebooks", "gone.epub", "Vanished Book")
if _, err := db.ExecContext(ctx, `UPDATE media_items SET missing = 1 WHERE id = ?`, id); err != nil {
t.Fatal(err)
}
res, err := Query(ctx, db, "vanished", []int64{lib}, 50)
if err != nil {
t.Fatal(err)
}
if len(res) != 0 {
t.Errorf("missing item appeared in search: %v", titles(res))
}
}