Vellum/internal/catalog/catalog_test.go
mithral 2c424ae67c Expose the television tree: catalog reads, API routes, Jellyfin shapes
The scanner builds library -> series -> season -> episode; this makes it
reachable. Three reads, all grant-checked through catalog.Store (ADR-005):
a series' seasons, a season's episodes, and the next unwatched episode.

Each loads the parent through ItemFor first, so forbidden and absent are the
same ErrNotFound, and the child query is scoped to the parent's own
library_id so a corrupt parent_id cannot cross a library boundary. Verified
red-green: swapping ItemFor for Item returns two rows to an ungranted
caller.

Episodes come back in episode order, not insertion order, and the ordering
is verified red-green too - dropping the clause yields [3 1 10 2].

Routes stay inside the existing family: /api/v1/items/{id}/seasons,
/episodes and /next. No parallel route surface.

Jellyfin gains SeriesDto, SeasonDto and EpisodeDto. Jellyfin's wire type is
BaseItemDto, so these carry the folder/file split plus IndexNumber and
ParentIndexNumber. ItemType now takes the item rather than the kind, because
an episode IS kind video - that is the one signature change, and the dto
tests were updated with it.

Verified end-to-end against a running server: a library holding S01E02,
1x02, a bare-number file and a film produced two series, three seasons and
six episodes, with the film left alone; the routes returned the seasons in
order, the episodes in episode order, and the next unwatched episode.

Note for later: Subsonic and the web UI are untouched, so a series browses
there as it did before. The scanner also has no separate 'tv' kind, so a
video library mixing films and shows builds both shapes side by side.
2026-10-12 01:41:05 -04:00

486 lines
15 KiB
Go

package catalog
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"log/slog"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/migrations"
)
// testDB builds a migrated database in a temp directory.
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "catalog.db"))
if err != nil {
t.Fatalf("open: %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
}
// newLibrary inserts a library and returns its id.
func newLibrary(t *testing.T, db *sql.DB, name string) int64 {
t.Helper()
res, err := db.Exec(
`INSERT INTO libraries (kind, name, root_path) VALUES ('ebooks', ?, ?)`,
name, "/media/"+name)
if err != nil {
t.Fatalf("insert library: %v", err)
}
id, _ := res.LastInsertId()
return id
}
// newUser inserts a user. isAdmin decides whether grants matter.
func newUser(t *testing.T, db *sql.DB, name string, admin bool) auth.User {
t.Helper()
adminFlag := 0
if admin {
adminFlag = 1
}
res, err := db.Exec(
`INSERT INTO users (username, display_name, password_hash, is_admin) VALUES (?, ?, 'x', ?)`,
name, name, adminFlag)
if err != nil {
t.Fatalf("insert user: %v", err)
}
id, _ := res.LastInsertId()
return auth.User{ID: id, Username: name, IsAdmin: admin}
}
// newItem inserts an item with one file, including codec facts.
func newItem(t *testing.T, db *sql.DB, libID int64, title string) int64 {
t.Helper()
res, err := db.Exec(`
INSERT INTO media_items (library_id, kind, path, title, sort_title, year)
VALUES (?, 'ebooks', ?, ?, ?, 1999)`, libID, title+".epub", title, title)
if err != nil {
t.Fatalf("insert item: %v", err)
}
itemID, _ := res.LastInsertId()
if _, err := db.Exec(`
INSERT INTO media_files (item_id, path, size, mtime, container, duration_ms,
vcodec, acodec, width, height)
VALUES (?, ?, 100, '1700000000000000000', 'mp4', 5000, 'h264', 'aac', 640, 360)`,
itemID, title+".epub"); err != nil {
t.Fatalf("insert file: %v", err)
}
return itemID
}
func grant(t *testing.T, db *sql.DB, u auth.User, libID int64) {
t.Helper()
if _, err := db.Exec(
`INSERT INTO library_grants (user_id, library_id, can_read) VALUES (?, ?, 1)`,
u.ID, libID); err != nil {
t.Fatalf("grant: %v", err)
}
}
// newTVItem writes one row of the television tree exactly as the scanner does:
// a series or season carries no file, an episode is kind='video' with both
// numbers set, and a zero number is stored as NULL rather than 0.
//
// Insertion order is the caller's business — the tests insert out of order on
// purpose, because a query that returns rows in id order would look right.
func newTVItem(t *testing.T, db *sql.DB, libID int64, kind, path, title string, parent int64, season, episode int) int64 {
t.Helper()
var (
parentArg, seasonArg, episodeArg any
)
if parent != 0 {
parentArg = parent
}
if season > 0 {
seasonArg = season
}
if episode > 0 {
episodeArg = episode
}
id := newTVRow(t, db, `INSERT INTO media_items
(library_id, kind, path, parent_id, title, sort_title, season_number, episode_number)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
libID, kind, path, parentArg, title, strings.ToLower(title), seasonArg, episodeArg)
if kind == "video" {
// An episode has a file; progress rows need one to hang off.
if _, err := db.Exec(
`INSERT INTO media_files (item_id, path, size) VALUES (?, ?, 1)`, id, path); err != nil {
t.Fatalf("insert episode file: %v", err)
}
}
return id
}
func newTVRow(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
}
// newTVLibrary builds library -> series -> season 1 (four episodes) -> season 2,
// inserting every child in a deliberately useless order: season 2 before season
// 1, and episodes 3, 1, 10, 2. Anything that sorts by id, by title or by path
// comes back wrong, so these rows pin the ORDER and not just the contents.
type tvFixture struct {
series int64
seasons []int64 // season 1, season 2
ep1 map[int]int64
ep2 map[int]int64
}
func newTVLibrary(t *testing.T, db *sql.DB) tvFixture {
t.Helper()
lib := newTVRow(t, db, `INSERT INTO libraries (kind, name, root_path)
VALUES ('video', 'TV', '/media/TV') RETURNING id`)
f := tvFixture{ep1: map[int]int64{}, ep2: map[int]int64{}}
f.series = newTVItem(t, db, lib, "series", "The Wire", "The Wire", 0, 0, 0)
// Season 2 is written first and still has to come back second.
s2 := newTVItem(t, db, lib, "season", "The Wire/Season 2", "Season 2", f.series, 2, 0)
s1 := newTVItem(t, db, lib, "season", "The Wire/Season 1", "Season 1", f.series, 1, 0)
f.seasons = []int64{s1, s2}
for _, n := range []int{3, 1, 10, 2} {
f.ep1[n] = newTVItem(t, db, lib, "video",
fmt.Sprintf("The Wire/Season 1/S01E%02d.mkv", n),
fmt.Sprintf("Episode %d", n), s1, 1, n)
}
f.ep2[1] = newTVItem(t, db, lib, "video",
"The Wire/Season 2/S02E01.mkv", "Episode 1", s2, 2, 1)
return f
}
// finish marks episodes watched for a user. It replaces rather than inserts, so
// a test can mark progressively without tripping the progress primary key.
func (f tvFixture) finish(t *testing.T, db *sql.DB, u auth.User, eps ...int64) {
t.Helper()
for _, ep := range eps {
var fileID int64
if err := db.QueryRow(`SELECT id FROM media_files WHERE item_id = ?`, ep).Scan(&fileID); err != nil {
t.Fatalf("file for episode %d: %v", ep, err)
}
if _, err := db.Exec(`INSERT OR REPLACE INTO progress
(user_id, item_id, file_id, kind, percent, finished)
VALUES (?, ?, ?, 'video', 1, 1)`, u.ID, ep, fileID); err != nil {
t.Fatalf("mark episode %d finished: %v", ep, err)
}
}
}
// episodeNumbers projects a list of items down to their episode numbers.
func episodeNumbers(items []Item) []int {
out := make([]int, len(items))
for i, it := range items {
out[i] = it.EpisodeNumber
}
return out
}
func seasonNumbers(items []Item) []int {
out := make([]int, len(items))
for i, it := range items {
out[i] = it.SeasonNumber
}
return out
}
func TestLoadReturnsCodecFacts(t *testing.T) {
db := testDB(t)
lib := newLibrary(t, db, "A")
itemID := newItem(t, db, lib, "Thing")
it, err := LoadUnchecked(context.Background(), db, itemID)
if err != nil {
t.Fatal(err)
}
if it.Title != "Thing" || it.LibraryID != lib || it.Kind != "ebooks" {
t.Errorf("unexpected item: %+v", it)
}
if it.RootPath != "/media/A" {
t.Errorf("RootPath = %q", it.RootPath)
}
if it.AbsPath() != "/media/A/Thing.epub" {
t.Errorf("AbsPath = %q", it.AbsPath())
}
if it.ModTime.IsZero() {
t.Error("ModTime not parsed from the file's mtime")
}
files, err := Files(context.Background(), db, itemID)
if err != nil {
t.Fatal(err)
}
if len(files) != 1 {
t.Fatalf("files = %d, want 1", len(files))
}
f := files[0]
if f.VCodec != "h264" || f.ACodec != "aac" || f.Width != 640 || f.DurationMS != 5000 {
t.Errorf("codec facts lost: %+v", f)
}
if f.Missing {
t.Error("a present file reported as missing")
}
}
// The security property: a missing item and a forbidden item must be
// INDISTINGUISHABLE, or a 403 would leak the existence of another user's
// library.
func TestLoadForForbiddenIsIndistinguishableFromAbsent(t *testing.T) {
db := testDB(t)
ctx := context.Background()
lib := newLibrary(t, db, "Secret")
itemID := newItem(t, db, lib, "Hidden")
outsider := newUser(t, db, "outsider", false)
granted := newUser(t, db, "granted", false)
grant(t, db, granted, lib)
// Forbidden: the item exists but this user has no grant.
_, errForbidden := LoadFor(ctx, db, outsider, itemID)
// Absent: no such row at all.
_, errAbsent := LoadFor(ctx, db, outsider, itemID+9999)
if !errors.Is(errForbidden, ErrNotFound) {
t.Errorf("forbidden error = %v, want ErrNotFound", errForbidden)
}
if !errors.Is(errAbsent, ErrNotFound) {
t.Errorf("absent error = %v, want ErrNotFound", errAbsent)
}
if errForbidden.Error() != errAbsent.Error() {
t.Errorf("errors differ and would leak existence: %q vs %q",
errForbidden, errAbsent)
}
// Positive control: a granted user does get it.
if _, err := LoadFor(ctx, db, granted, itemID); err != nil {
t.Errorf("granted user denied: %v", err)
}
}
func TestAdminBypassesGrants(t *testing.T) {
db := testDB(t)
ctx := context.Background()
lib := newLibrary(t, db, "Any")
itemID := newItem(t, db, lib, "Thing")
admin := newUser(t, db, "root", true)
if _, err := LoadFor(ctx, db, admin, itemID); err != nil {
t.Errorf("admin denied: %v", err)
}
}
func TestFileByIDIsScopedToItsItem(t *testing.T) {
db := testDB(t)
ctx := context.Background()
lib := newLibrary(t, db, "A")
first := newItem(t, db, lib, "One")
second := newItem(t, db, lib, "Two")
other, err := FirstFile(ctx, db, second)
if err != nil {
t.Fatal(err)
}
// Asking for item Two's file through item One must not resolve.
if _, err := FileByID(ctx, db, first, other.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("file id leaked across items: err = %v, want ErrNotFound", err)
}
if _, err := FileByID(ctx, db, second, other.ID); err != nil {
t.Errorf("legitimate lookup failed: %v", err)
}
}
func TestFilesForRespectsGrants(t *testing.T) {
db := testDB(t)
ctx := context.Background()
lib := newLibrary(t, db, "A")
itemID := newItem(t, db, lib, "Thing")
outsider := newUser(t, db, "outsider", false)
if _, err := FilesFor(ctx, db, outsider, itemID); !errors.Is(err, ErrNotFound) {
t.Errorf("FilesFor leaked to an ungranted user: %v", err)
}
}
func TestFileCount(t *testing.T) {
db := testDB(t)
lib := newLibrary(t, db, "A")
itemID := newItem(t, db, lib, "Thing")
n, err := FileCount(context.Background(), db, itemID)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("FileCount = %d, want 1", n)
}
}
// ── the television tree ─────────────────────────────────────────────────
// Episodes come back in EPISODE order, not in the order the scanner happened to
// insert them and not in the order their paths sort. Both of those are wrong in
// a way that only shows up on a real library: a rescan upserts by path, and
// "S01E10" sorts before "S01E2" as text. Episode 10 must come after episode 3.
func TestEpisodesAreInEpisodeOrderNotInsertionOrder(t *testing.T) {
db := testDB(t)
ctx := context.Background()
f := newTVLibrary(t, db)
u := newUser(t, db, "watcher", false)
grant(t, db, u, 1)
seasons, err := New(db).Seasons(ctx, u, f.series)
if err != nil {
t.Fatalf("Seasons: %v", err)
}
if got := seasonNumbers(seasons); len(got) != 2 || got[0] != 1 || got[1] != 2 {
t.Errorf("Seasons = %v, want [1 2]", got)
}
eps, err := New(db).Episodes(ctx, u, f.seasons[0])
if err != nil {
t.Fatalf("Episodes: %v", err)
}
want := []int64{f.ep1[1], f.ep1[2], f.ep1[3], f.ep1[10]}
got := make([]int64, len(eps))
for i, ep := range eps {
got[i] = ep.ID
}
if !slices.Equal(got, want) {
t.Fatalf("episodes = %v, want %v (episode order, not insertion order)",
episodeNumbers(eps), []int{1, 2, 3, 10})
}
if slices.IsSorted(got) {
t.Fatal("the fixture no longer tests order: the expected order is the insertion order")
}
if eps[0].SeasonNumber != 1 || eps[0].Path != "The Wire/Season 1/S01E01.mkv" {
t.Errorf("first episode = %+v, want S01E01 of season 1", eps[0])
}
}
// NextEpisode is the first episode the caller has not finished, in order, and
// it rolls from a finished season into the next one.
func TestNextEpisodeIsTheFirstUnfinished(t *testing.T) {
db := testDB(t)
ctx := context.Background()
f := newTVLibrary(t, db)
store := New(db)
u := newUser(t, db, "watcher", false)
grant(t, db, u, 1)
next, err := store.NextEpisode(ctx, u, f.series)
if err != nil {
t.Fatalf("NextEpisode: %v", err)
}
if next.EpisodeNumber != 1 || next.Path != "The Wire/Season 1/S01E01.mkv" {
t.Fatalf("NextEpisode = %+v, want S01E01", next)
}
finish := func(eps ...int64) { f.finish(t, db, u, eps...) }
// A half-watched episode is still the next one: it is what they are in.
finish(f.ep1[1])
if next, err = store.NextEpisode(ctx, u, f.series); err != nil || next.EpisodeNumber != 2 {
t.Errorf("NextEpisode after finishing E01 = %+v, %v; want episode 2", next, err)
}
// Season 1 done, so the next unwatched one is season 2's.
finish(f.ep1[2], f.ep1[3], f.ep1[10])
if next, err = store.NextEpisode(ctx, u, f.series); err != nil || next.SeasonNumber != 2 {
t.Errorf("NextEpisode after finishing season 1 = %+v, %v; want season 2", next, err)
}
finish(f.ep2[1])
if _, err := store.NextEpisode(ctx, u, f.series); !errors.Is(err, ErrNotFound) {
t.Errorf("a fully watched series: err = %v, want ErrNotFound", err)
}
}
// The security property, for the television tree specifically: these read
// CHILDREN of a parent the caller never named, so the grant has to be applied
// before the walk. A caller with no grant gets nothing at all, and the error is
// byte-identical to the one for a parent that does not exist.
func TestTVTreeReadsAreGrantCheckedAndIndistinguishable(t *testing.T) {
db := testDB(t)
ctx := context.Background()
f := newTVLibrary(t, db)
store := New(db)
outsider := newUser(t, db, "outsider", false)
granted := newUser(t, db, "granted", false)
grant(t, db, granted, 1)
type read struct {
name string
id int64
fn func(auth.User, int64) error
}
// list wraps a list read so every read reports the same way: the error, plus
// a failure if rows came back at all.
list := func(name string, s func(context.Context, auth.User, int64) ([]Item, error)) func(auth.User, int64) error {
return func(u auth.User, id int64) error {
items, err := s(ctx, u, id)
if err == nil && len(items) > 0 {
t.Errorf("%s returned %d rows to an ungranted caller", name, len(items))
}
return err
}
}
reads := []read{
{"Seasons", f.series, list("Seasons", store.Seasons)},
{"Episodes", f.seasons[0], list("Episodes", store.Episodes)},
{"NextEpisode", f.series, func(u auth.User, id int64) error {
_, err := store.NextEpisode(ctx, u, id)
return err
}},
}
for _, r := range reads {
// Forbidden: the parent exists, this user has no grant on its library.
ferr := r.fn(outsider, r.id)
// Absent: no such parent at all.
aerr := r.fn(outsider, r.id+9999)
if !errors.Is(ferr, ErrNotFound) {
t.Errorf("%s forbidden: err = %v, want ErrNotFound", r.name, ferr)
}
if !errors.Is(aerr, ErrNotFound) {
t.Errorf("%s absent: err = %v, want ErrNotFound", r.name, aerr)
}
if ferr.Error() != aerr.Error() {
t.Errorf("%s: errors differ and would leak existence: %q vs %q",
r.name, ferr, aerr)
}
}
// Positive control: the granted user reads the same tree.
seasons, err := store.Seasons(ctx, granted, f.series)
if err != nil || len(seasons) != 2 {
t.Fatalf("granted Seasons = %v, %v; want 2 seasons", seasons, err)
}
if _, err := store.Episodes(ctx, granted, f.seasons[1]); err != nil {
t.Errorf("granted Episodes: %v", err)
}
if _, err := store.NextEpisode(ctx, granted, f.series); err != nil {
t.Errorf("granted NextEpisode: %v", err)
}
}