package media import ( "context" "database/sql" "io" "log/slog" "path/filepath" "strconv" "testing" "github.com/mithral/vellum/internal/core" "github.com/mithral/vellum/migrations" ) // These fixtures are duplicated from internal/home. Go test helpers cannot be // shared across packages without being exported from non-test code, and the home // screen's version is a different shape: it seeds progress and views so the // shelves have something to aggregate. // ── shared helpers ───────────────────────────────────────────────────── func mediaHandlers(t *testing.T, db *sql.DB) *Handlers { t.Helper() log := slog.New(slog.NewTextHandler(io.Discard, nil)) // nil sessions: the constructor falls back to a fresh manager, which is what // a test wants. Tests that exercise the transcode routes inject their own // fake by replacing the field. return NewHandlers(db, log, nil, t.TempDir(), nil) } func testDB(t *testing.T) *sql.DB { t.Helper() db, err := core.OpenDB(filepath.Join(t.TempDir(), "media.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 mustQueryID(t *testing.T, db *sql.DB, q string, args ...any) int64 { t.Helper() var id int64 if err := db.QueryRowContext(context.Background(), q, args...).Scan(&id); err != nil { t.Fatalf("query %q: %v", q, err) } return id } func mustExec(t *testing.T, db *sql.DB, q string, args ...any) { t.Helper() if _, err := db.ExecContext(context.Background(), q, args...); err != nil { t.Fatalf("exec %q: %v", q, err) } } func newLibrary(t *testing.T, db *sql.DB, name, kind, root string) int64 { t.Helper() return mustQueryID(t, db, `INSERT INTO libraries (name, kind, root_path) VALUES (?, ?, ?) RETURNING id`, name, kind, root) } func newUser(t *testing.T, db *sql.DB, name string) int64 { t.Helper() return mustQueryID(t, db, `INSERT INTO users (username, password_hash) VALUES (?, 'x') RETURNING id`, name) } func grantRead(t *testing.T, db *sql.DB, userID, libID int64) { t.Helper() mustExec(t, db, `INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, 1, 1)`, userID, libID) } // newItem inserts a media item plus a backing file, because progress rows carry // a NOT NULL file_id foreign key. func newItem(t *testing.T, db *sql.DB, libID int64, kind, path, title, sortTitle string, missing bool, addedAt string) int64 { t.Helper() m := 0 if missing { m = 1 } id := mustQueryID(t, db, ` INSERT INTO media_items (library_id, kind, path, title, sort_title, missing, added_at) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`, libID, kind, path, title, sortTitle, m, addedAt) mustExec(t, db, `INSERT INTO media_files (item_id, path, size) VALUES (?, ?, 1)`, id, "file-"+strconv.FormatInt(id, 10)) return id } func fileIDFor(t *testing.T, db *sql.DB, itemID int64) int64 { t.Helper() var id int64 if err := db.QueryRowContext(context.Background(), `SELECT id FROM media_files WHERE item_id = ? ORDER BY id LIMIT 1`, itemID).Scan(&id); err != nil { t.Fatalf("file for item %d: %v", itemID, err) } return id } func newProgress(t *testing.T, db *sql.DB, userID, itemID int64, percent float64, finished bool, updatedAt string) { t.Helper() f := 0 if finished { f = 1 } mustExec(t, db, ` INSERT INTO progress (user_id, item_id, file_id, kind, position, percent, finished, updated_at) VALUES (?, ?, ?, 'ebooks', 1, ?, ?, ?)`, userID, itemID, fileIDFor(t, db, itemID), percent, f, updatedAt) } func newView(t *testing.T, db *sql.DB, userID, itemID int64, views int, lastViewedAt string) { t.Helper() mustExec(t, db, `INSERT INTO item_views (user_id, item_id, views, last_viewed_at) VALUES (?, ?, ?, ?)`, userID, itemID, views, lastViewedAt) } // setupHome builds the two-library, three-user fixture the item-detail tests // use: one item the caller may read, one in a library they may not, and one user // with no grants at all. func setupHome(t *testing.T) homeFixture { t.Helper() db := testDB(t) f := homeFixture{db: db} f.libA = newLibrary(t, db, "Ebooks", "ebooks", "/media/ebooks") f.libB = newLibrary(t, db, "Audio", "audiobooks", "/media/audio") f.userA = newUser(t, db, "alice") f.userB = newUser(t, db, "bob") f.userNone = newUser(t, db, "carol") grantRead(t, db, f.userA, f.libA) grantRead(t, db, f.userB, f.libB) f.a1 = newItem(t, db, f.libA, "ebooks", "a1.epub", "A One", "a one", false, "2026-01-01T00:00:01.000Z") f.a2 = newItem(t, db, f.libA, "ebooks", "a2.epub", "A Two", "a two", false, "2026-01-01T00:00:02.000Z") f.a3 = newItem(t, db, f.libA, "comics", "a3.cbz", "A Three", "a three", false, "2026-01-01T00:00:03.000Z") f.a4 = newItem(t, db, f.libA, "ebooks", "a4.epub", "A Four", "a four", true, "2026-01-01T00:00:04.000Z") f.a5 = newItem(t, db, f.libA, "ebooks", "a5.epub", "A Five", "a five", false, "2026-01-01T00:00:05.000Z") f.b1 = newItem(t, db, f.libB, "audiobooks", "b1.mp3", "B One", "b one", false, "2026-01-01T00:00:06.000Z") newProgress(t, db, f.userA, f.a1, 0.5, false, "2026-02-01T00:00:03.000Z") newProgress(t, db, f.userA, f.a2, 1.0, true, "2026-02-01T00:00:02.000Z") newProgress(t, db, f.userA, f.a4, 0.3, false, "2026-02-01T00:00:04.000Z") newProgress(t, db, f.userB, f.b1, 0.2, false, "2026-02-02T00:00:01.000Z") newView(t, db, f.userA, f.a1, 3, "2026-02-03T00:00:01.000Z") newView(t, db, f.userA, f.a3, 1, "2026-02-03T00:00:02.000Z") newView(t, db, f.userB, f.b1, 9, "2026-02-03T00:00:03.000Z") ada := mustQueryID(t, db, `INSERT INTO people (name) VALUES ('Ada Author') RETURNING id`) mustExec(t, db, `INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', 0)`, f.a1, ada) mustExec(t, db, `INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?, ?, 'author', 0)`, f.a3, ada) return f } func itoa(n int64) string { if n == 0 { return "0" } var b [20]byte i := len(b) for n > 0 { i-- b[i] = byte('0' + n%10) n /= 10 } return string(b[i:]) } type homeFixture struct { db *sql.DB libA int64 libB int64 userA int64 userB int64 userNone int64 a1 int64 a2 int64 a3 int64 a4 int64 a5 int64 b1 int64 }