Vellum/internal/metadata/enrich_test.go
mithral ac1bd04df3 Phase 1: one item-access seam, and architecture rules that fail the build
Motivation, from the evidence rather than from taste. Five packages each
implemented "load an item, then check the caller may read it": media, comics,
games, jellyfin (with its own dbItem type and its own SQL), and subsonic. Five
copies of one authorization rule means four can drift, and the failure mode is
not a crash — it is silently serving somebody a library they were never granted.
One implementation is one thing to review.

internal/catalog now owns item and file access, and the access check.

  LoadUnchecked(ctx, db, id)        -- no access check; jobs and tests only
  LoadFor(ctx, db, u, id)           -- load AND enforce the grant, in one call
  Files / FilesFor / FileByID / FirstFile / FileCount

The unchecked variant is deliberately named so that reaching for it by accident
is hard: it is the dangerous call, so it should not have the friendlier name.
This came from the interface-design rule that a good interface makes the right
thing easy and the wrong thing hard.

The security property is tested, not asserted: a forbidden item and an absent
item return byte-identical errors, so no handler can leak the existence of
another user's library through a 403. That is now enforced in one place.

Migrated all five surfaces onto it. media, comics, games and ebooks went from
four duplicate loaders to zero. jellyfin's dbItem and loadDBItem are gone.
subsonic's per-item authorization moved while its album/artist aggregate
projections stayed put, because grouping items into albums is a presentation
concern, not a data-access one. The remaining raw SQL everywhere is browse and
aggregate queries; no per-item load decides access any more.

internal/arch enforces the layering instead of documenting it. Five rules, each
proven able to fail by deliberately breaking it:
  - no upward imports across six explicit layers
  - internal/core may depend only on migrations
  - internal/catalog must not know about HTTP, or it stops being reusable
  - modules must not import each other
  - every package must be placed in a layer, so the map cannot go stale

A test that only ever passes is decoration, so each rule was verified by
introducing the violation and confirming the failure.

Verified: gofmt clean, go build ./... exit 0, go vet exit 0, 20 packages pass,
svelte-check 0/0. End to end against a running server: the vellum API serves
item detail with codec facts, an unknown item 404s, Jellyfin AuthenticateByName
mints a token and /Items browses, and Subsonic ping returns status ok. The
subsonic migration was additionally A/B'd byte-identical against the previous
implementation across ~15 endpoints, and Jellyfin/Subsonic both still return the
same error for a forbidden item as for an absent one.

Known follow-ups, recorded rather than silently skipped:
  - internal/jellyfin has no test files at all. Its wire contract is the most
    client-visible surface in the tree and is now verified only by hand.
  - catalog is item-centric, but Subsonic addresses songs file-first (mf-<id>),
    so it resolves file to item through its own projection. A catalog lookup
    that takes a file id would close that.
  - subsonic loadAlbum now scopes the projection to the item's own library
    rather than all visible audio libraries. Equivalent by construction, since
    an item's kind tracks its library's kind, but a hand-edited mismatch would
    differ.
2026-10-11 15:47:18 -04:00

327 lines
9.6 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/internal/media"
"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 catalog.Load 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)
}
}
func TestNormalizeTitle(t *testing.T) {
cases := map[string]string{
"Random Access Memories": "random access memories",
"The Hobbit, or There and Back Again": "the hobbit or there and back again",
"AC/DC — Back in Black!": "ac dc back in black",
" spaced out ": "spaced out",
}
for in, want := range cases {
if got := normalizeTitle(in); got != want {
t.Errorf("normalizeTitle(%q) = %q, want %q", in, got, want)
}
}
}
func TestSearchTitleStripsReleaseTags(t *testing.T) {
cases := map[string]string{
"Sonic the Hedgehog (USA)": "Sonic the Hedgehog",
"Super Mario Bros. (World)": "Super Mario Bros.",
"Game [!]": "Game",
"Pokemon (USA) (Rev 1)": "Pokemon",
"The Legend of Zelda": "The Legend of Zelda",
"Chrono Trigger (USA) [En,Fr]": "Chrono Trigger",
"(Just Brackets)": "(Just Brackets)",
}
for in, want := range cases {
if got := searchTitle(in); got != want {
t.Errorf("searchTitle(%q) = %q, want %q", in, got, want)
}
}
}
func TestPickBestRejectsUnrelated(t *testing.T) {
candidates := []Candidate{
{Title: "Random Access Memories", ExternalID: "right"},
}
// The exact title is accepted.
if c, ok := pickBest(candidates, "Random Access Memories"); !ok || c.ExternalID != "right" {
t.Errorf("exact match rejected: ok=%v c=%+v", ok, c)
}
// Containment in either direction is accepted.
long := []Candidate{{Title: "The Hobbit, or There and Back Again"}}
if _, ok := pickBest(long, "The Hobbit"); !ok {
t.Error("containment should be accepted")
}
// A same-named release by a different artist is still accepted by title —
// the artist hint, not the title check, is what disambiguates that.
// But a genuinely unrelated title must be rejected.
if _, ok := pickBest(candidates, "Franz Kirmann"); ok {
t.Error("unrelated title should be rejected")
}
// Too-short titles are never matched, to avoid substring false positives.
if _, ok := pickBest(candidates, "a"); ok {
t.Error("a one-character query should be rejected")
}
}
func TestArtistHint(t *testing.T) {
// Directory-grouped kinds take the artist from the parent directory.
album := media.Item{Kind: "music", Path: "Daft Punk/Random Access Memories"}
if got := artistHint(album); got != "Daft Punk" {
t.Errorf("artistHint(album) = %q, want Daft Punk", got)
}
// A loose root-level file has no artist to infer.
loose := media.Item{Kind: "music", Path: "."}
if got := artistHint(loose); got != "" {
t.Errorf("artistHint(loose) = %q, want empty", got)
}
// File-grouped kinds (ebooks) never take a hint: their parent directory is
// usually a series, not an author.
book := media.Item{Kind: "ebooks", Path: "Series/Book.epub"}
if got := artistHint(book); got != "" {
t.Errorf("artistHint(ebook) = %q, want empty", got)
}
}