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.
298 lines
8.6 KiB
Go
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))
|
|
}
|
|
}
|