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 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 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)) } }