package metadata import ( "context" "database/sql" "errors" "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(), "meta.db")) if err != nil { t.Fatalf("OpenDB: %v", err) } t.Cleanup(func() { _ = db.Close() }) if err := core.Migrate(context.Background(), db, migrations.FS, discard()); err != nil { t.Fatalf("Migrate: %v", err) } return db } func discard() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } func seedItem(t *testing.T, db *sql.DB, title string) int64 { t.Helper() ctx := context.Background() var libID int64 if err := db.QueryRowContext(ctx, `INSERT INTO libraries (name, kind, root_path) VALUES ('Ebooks','ebooks','/tmp/e') RETURNING id`).Scan(&libID); err != nil { t.Fatal(err) } var itemID int64 if err := db.QueryRowContext(ctx, ` INSERT INTO media_items (library_id, kind, path, title, sort_title) VALUES (?, 'ebooks', 'book.epub', ?, ?) RETURNING id`, libID, title, title).Scan(&itemID); err != nil { t.Fatal(err) } // A file row so media.LoadItem can resolve a modtime. if _, err := db.ExecContext(ctx, `INSERT INTO media_files (item_id, path, size, mtime) VALUES (?, 'book.epub', 1, '1')`, itemID); err != nil { t.Fatal(err) } return itemID } // fakeProvider is a Provider with scripted results. type fakeProvider struct { name string kinds []string results []Candidate err error calls int } func (f *fakeProvider) Name() string { return f.name } func (f *fakeProvider) Kinds() []string { return f.kinds } func (f *fakeProvider) Search(context.Context, Query) ([]Candidate, error) { f.calls++ return f.results, f.err } func enricherWith(t *testing.T, db *sql.DB, provs ...Provider) *Enricher { t.Helper() e := NewEnricher(db, discard(), t.TempDir()) e.providers = func(string) []Provider { return provs } return e } func TestEnrichAppliesCandidate(t *testing.T) { db := testDB(t) itemID := seedItem(t, db, "the hobbit") fake := &fakeProvider{ name: "fake", kinds: []string{"ebooks"}, results: []Candidate{{ Provider: "fake", ExternalID: "OL123W", Title: "The Hobbit", Authors: []string{"J.R.R. Tolkien"}, Year: 1937, Publisher: "Allen & Unwin", }}, } e := enricherWith(t, db, fake) if err := e.EnrichItem(context.Background(), itemID); err != nil { t.Fatalf("EnrichItem: %v", err) } ctx := context.Background() var title string var year sql.NullInt64 if err := db.QueryRowContext(ctx, `SELECT title, year FROM media_items WHERE id = ?`, itemID).Scan(&title, &year); err != nil { t.Fatal(err) } if title != "The Hobbit" { t.Errorf("title = %q, want The Hobbit", title) } if !year.Valid || year.Int64 != 1937 { t.Errorf("year = %v, want 1937", year) } var provider, external string if err := db.QueryRowContext(ctx, `SELECT provider, external_id FROM provider_ids WHERE item_id = ?`, itemID).Scan(&provider, &external); err != nil { t.Fatalf("provider_ids not written: %v", err) } if provider != "fake" || external != "OL123W" { t.Errorf("provider_ids = %s/%s", provider, external) } var author string if err := db.QueryRowContext(ctx, ` SELECT p.name FROM item_people ip JOIN people p ON p.id = ip.person_id WHERE ip.item_id = ? AND ip.role = 'author'`, itemID).Scan(&author); err != nil { t.Fatalf("author not linked: %v", err) } if author != "J.R.R. Tolkien" { t.Errorf("author = %q", author) } var status string if err := db.QueryRowContext(ctx, `SELECT status FROM metadata_fetch WHERE item_id = ? AND provider = 'fake'`, itemID).Scan(&status); err != nil { t.Fatal(err) } if status != "ok" { t.Errorf("fetch status = %q, want ok", status) } } func TestEnrichFallsBackToSecondProvider(t *testing.T) { db := testDB(t) itemID := seedItem(t, db, "some book") empty := &fakeProvider{name: "empty", kinds: []string{"ebooks"}} good := &fakeProvider{ name: "good", kinds: []string{"ebooks"}, results: []Candidate{{Provider: "good", ExternalID: "g1", Title: "Some Book"}}, } e := enricherWith(t, db, empty, good) if err := e.EnrichItem(context.Background(), itemID); err != nil { t.Fatalf("EnrichItem: %v", err) } if empty.calls != 1 || good.calls != 1 { t.Errorf("calls: empty=%d good=%d, want 1/1", empty.calls, good.calls) } var provider string if err := db.QueryRow( `SELECT provider FROM provider_ids WHERE item_id = ?`, itemID).Scan(&provider); err != nil { t.Fatal(err) } if provider != "good" { t.Errorf("matched provider = %q, want good", provider) } } func TestEnrichContinuesAfterProviderError(t *testing.T) { db := testDB(t) itemID := seedItem(t, db, "some book") broken := &fakeProvider{name: "broken", kinds: []string{"ebooks"}, err: errors.New("network down")} good := &fakeProvider{ name: "good", kinds: []string{"ebooks"}, results: []Candidate{{Provider: "good", ExternalID: "g1", Title: "Some Book"}}, } e := enricherWith(t, db, broken, good) if err := e.EnrichItem(context.Background(), itemID); err != nil { t.Fatalf("a provider error must not fail the item: %v", err) } if good.calls != 1 { t.Error("fallback was not consulted after an error") } var status string if err := db.QueryRow( `SELECT status FROM metadata_fetch WHERE item_id = ? AND provider = 'broken'`, itemID).Scan(&status); err != nil { t.Fatal(err) } if status != "error" { t.Errorf("error status = %q, want error", status) } } func TestEnrichSkipsAlreadyMatched(t *testing.T) { db := testDB(t) itemID := seedItem(t, db, "book") fake := &fakeProvider{name: "fake", kinds: []string{"ebooks"}, results: []Candidate{{Provider: "fake", ExternalID: "x", Title: "Book"}}} e := enricherWith(t, db, fake) if err := e.EnrichItem(context.Background(), itemID); err != nil { t.Fatal(err) } if err := e.EnrichItem(context.Background(), itemID); err != nil { t.Fatal(err) } if fake.calls != 1 { t.Errorf("provider called %d times, want 1 (second run must skip)", fake.calls) } } func TestEnrichDoesNotRetryRecentNotFound(t *testing.T) { db := testDB(t) itemID := seedItem(t, db, "unmatchable") empty := &fakeProvider{name: "empty", kinds: []string{"ebooks"}} e := enricherWith(t, db, empty) if err := e.EnrichItem(context.Background(), itemID); err != nil { t.Fatal(err) } if err := e.EnrichItem(context.Background(), itemID); err != nil { t.Fatal(err) } if empty.calls != 1 { t.Errorf("provider called %d times, want 1 (notfound is cached)", empty.calls) } } func TestEnrichIgnoresUnknownKind(t *testing.T) { db := testDB(t) itemID := seedItem(t, db, "book") e := enricherWith(t, db) // no providers at all if err := e.EnrichItem(context.Background(), itemID); err != nil { t.Errorf("EnrichItem with no providers errored: %v", err) } }