Vellum/internal/metadata/enrich_test.go
mithral df740d3e1a Phase 5: job queue and metadata providers
- jobs: a table-backed queue in the same SQLite file. No Redis, no second
  process, included in the same backup. claim() is a single UPDATE...RETURNING
  so a job cannot run twice even with several workers. Failures back off
  exponentially; a missing handler fails permanently rather than retrying a
  bug.
- metadata: Provider interface plus Open Library and Google Books (both
  keyless). The enricher tries providers in registration order, falls back on
  empty, and continues past a provider error.
- migration 0004: metadata_fetch records the attempt, not just the match, so a
  rescan does not re-hammer a rate-limited API for an item that will never
  match. Errors are always retried; ok/notfound are cached for 30 days.
- enrichment writes provider_ids, updates title/year, links authors through
  people/item_people, and downloads the cover as an asset. The cover download
  runs outside the transaction: a slow image host must not hold a write lock.
- endpoints: queue enrichment per library, and inspect an item's matches.
- web: Enrich button per library.

Verified live against Open Library: "The Hobbit" matched /works/OL27482W,
title and year 1937 written, a 301x500 JPEG cover downloaded and served.
2026-10-09 21:16:07 -04:00

245 lines
6.8 KiB
Go

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