Vellum/internal/playback/handlers_test.go
mithral 8bd6b8a417 Phase 3: a storage seam behind catalog, and one consumer threaded onto it
EXPAND ONLY, per the migration order (expand, migrate, verify, contract). No
Postgres implementation, no behaviour change.

internal/catalog now exposes:

  type Store interface {
      Item / ItemFor / Files / FilesFor / FileByID / FirstFile / FileCount
  }
  type SQL struct{ db *sql.DB }; func New(db *sql.DB) *SQL

The seven bodies are the old functions moved verbatim. ItemFor still returns the
same ErrNotFound for absent and forbidden, so the security property is
unchanged. A compile-time `var _ Store = (*SQL)(nil)` keeps the implementation
honest.

Deliberately NOT moved: browse lists, home shelves and subsonic album queries.
Those are each package's own presentation concern, and pulling all of them into
the seam is exactly the 186-statement rewrite the seam exists to avoid. The
package doc says so.

Proven substitutable, not just declared: store_test.go implements a fake Store
in memory with no database at all and drives a caller through the interface. A
seam nobody can substitute proves nothing.

The agent's own risk note was right and I acted on it: the seam only pays off
once something actually threads it, and initially nothing did. So internal/
playback — the smallest consumer — now takes a catalog.Store instead of *sql.DB
and is wired with catalog.New(db) at the composition point. It has no database
reference left outside a comment. The remaining consumers (media, jellyfin,
subsonic, metadata) still call the package-level wrappers; threading them is
mechanical, and I would rather do it deliberately than in one sweep.

Verified: gofmt clean, build exit 0, vet exit 0, 25 packages pass, arch rules
pass. End to end against a running server: /playback/resolve still returns
vcodec:hevc for html5 and engine mpv when mpv is offered, an unknown item 404s,
/home and Jellyfin /Items still answer.

Known gap, unchanged by this commit: internal/arch does not enforce "new code
must not take *sql.DB", so the seam can erode by drift. That rule would be the
natural next step if this is to hold.
2026-10-11 16:16:07 -04:00

231 lines
7.9 KiB
Go

package playback
import (
"context"
"database/sql"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/catalog"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/engine"
"github.com/mithral/vellum/migrations"
)
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "playback.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 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 mustID(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 newHandlers(t *testing.T, db *sql.DB) *Handlers {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
a := auth.NewAuthenticator(db, "vellum_session", 0, false, log)
return NewHandlers(catalog.New(db), log, a)
}
// fixture builds two libraries with one item each and a user granted only the
// first. The item in the second library must be invisible.
type fixture struct {
db *sql.DB
libA int64
libB int64
userA int64
itemA int64 // mp4 h264 aac, readable
itemB int64 // mp4 hevc aac, NOT readable
}
func setup(t *testing.T) fixture {
t.Helper()
db := testDB(t)
f := fixture{db: db}
f.libA = mustID(t, db, `INSERT INTO libraries (name, kind, root_path) VALUES ('A','video','/a') RETURNING id`)
f.libB = mustID(t, db, `INSERT INTO libraries (name, kind, root_path) VALUES ('B','video','/b') RETURNING id`)
f.userA = mustID(t, db, `INSERT INTO users (username, password_hash) VALUES ('alice','x') RETURNING id`)
mustExec(t, db, `INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, 1, 1)`, f.userA, f.libA)
f.itemA = mustID(t, db, `INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'video', 'a.mp4', 'A') RETURNING id`, f.libA)
mustExec(t, db, `INSERT INTO media_files (item_id, path, size, container, vcodec, acodec) VALUES (?, 'a.mp4', 1, 'mp4', 'h264', 'aac')`, f.itemA)
f.itemB = mustID(t, db, `INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'video', 'b.mp4', 'B') RETURNING id`, f.libB)
mustExec(t, db, `INSERT INTO media_files (item_id, path, size, container, vcodec, acodec) VALUES (?, 'b.mp4', 1, 'mp4', 'hevc', 'aac')`, f.itemB)
return f
}
func resolve(t *testing.T, h *Handlers, u auth.User, body string) (int, engine.Resolution) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/resolve", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.handleResolve(rec, req, u)
var res engine.Resolution
if rec.Code == http.StatusOK {
if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil {
t.Fatalf("decode resolution: %v (body %s)", err, rec.Body.String())
}
}
return rec.Code, res
}
func TestEnginesEndpoint(t *testing.T) {
f := setup(t)
h := newHandlers(t, f.db)
req := httptest.NewRequest(http.MethodGet, "/api/v1/playback/engines", nil)
rec := httptest.NewRecorder()
h.handleEngines(rec, req, auth.User{ID: f.userA})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
}
var body struct {
Engines []engine.Engine `json:"engines"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode engines: %v", err)
}
got := map[string]bool{}
for _, e := range body.Engines {
got[e.ID] = true
}
for _, id := range []string{"html5", "mpv", "vlc", "mdk"} {
if !got[id] {
t.Errorf("engines response is missing %q: %s", id, rec.Body.String())
}
}
}
func TestResolvePlayableAndNot(t *testing.T) {
f := setup(t)
h := newHandlers(t, f.db)
u := auth.User{ID: f.userA}
code, res := resolve(t, h, u, `{"item_id":`+itoa(f.itemA)+`,"engines":["html5"]}`)
if code != http.StatusOK || !res.Playable || res.Engine == nil || *res.Engine != "html5" {
t.Fatalf("h264 resolve = %d %+v, want playable html5", code, res)
}
// The readable item's facts are h264, so to test HEVC we point at itemB but
// grant it first — scoping is tested separately.
mustExec(t, f.db, `INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, 1, 1)`, f.userA, f.libB)
code, res = resolve(t, h, u, `{"item_id":`+itoa(f.itemB)+`,"engines":["html5"]}`)
if code != http.StatusOK || res.Playable {
t.Fatalf("hevc resolve = %d %+v, want not playable", code, res)
}
if res.Reason != "vcodec:hevc" {
t.Errorf("reason = %q, want vcodec:hevc", res.Reason)
}
if !strings.Contains(res.Message, "HEVC") {
t.Errorf("message = %q, want it to name HEVC", res.Message)
}
// A native engine changes the answer.
code, res = resolve(t, h, u, `{"item_id":`+itoa(f.itemB)+`,"engines":["html5","mpv"]}`)
if code != http.StatusOK || !res.Playable || res.Engine == nil || *res.Engine != "mpv" {
t.Fatalf("hevc+mpv resolve = %d %+v, want playable mpv", code, res)
}
}
// TestResolveGrantScoping pins the chokepoint: an item in a library the caller
// cannot read must be a 404, indistinguishable from a missing item.
func TestResolveGrantScoping(t *testing.T) {
f := setup(t)
h := newHandlers(t, f.db)
// userA may read libA only; itemB lives in libB.
code, _ := resolve(t, h, auth.User{ID: f.userA}, `{"item_id":`+itoa(f.itemB)+`,"engines":["html5"]}`)
if code != http.StatusNotFound {
t.Errorf("inaccessible item status = %d, want 404", code)
}
// A user with no grants gets the same 404 for a readable-by-someone item.
none := mustID(t, f.db, `INSERT INTO users (username, password_hash) VALUES ('carol','x') RETURNING id`)
code, _ = resolve(t, h, auth.User{ID: none}, `{"item_id":`+itoa(f.itemA)+`,"engines":["html5"]}`)
if code != http.StatusNotFound {
t.Errorf("no-grant item status = %d, want 404", code)
}
// A nonexistent item is also a 404, not a leak.
code, _ = resolve(t, h, auth.User{ID: f.userA}, `{"item_id":99999,"engines":["html5"]}`)
if code != http.StatusNotFound {
t.Errorf("missing item status = %d, want 404", code)
}
}
func TestResolveValidation(t *testing.T) {
f := setup(t)
h := newHandlers(t, f.db)
u := auth.User{ID: f.userA}
for _, body := range []string{
`{"item_id":0,"engines":["html5"]}`,
`{"item_id":-3,"engines":["html5"]}`,
} {
if code, _ := resolve(t, h, u, body); code != http.StatusBadRequest {
t.Errorf("body %s status = %d, want 400", body, code)
}
}
// Missing engines is allowed: the handler falls back to the html5 baseline.
if code, res := resolve(t, h, u, `{"item_id":`+itoa(f.itemA)+`}`); code != http.StatusOK || !res.Playable {
t.Errorf("missing engines = %d %+v, want playable fallback", code, res)
}
}
// TestRegisterRequiresAuth proves the routes are mounted behind the auth
// middleware: an unauthenticated request is rejected with 401.
func TestRegisterRequiresAuth(t *testing.T) {
f := setup(t)
h := newHandlers(t, f.db)
mux := http.NewServeMux()
h.Register(mux)
for _, tc := range []struct{ method, path, body string }{
{http.MethodGet, "/api/v1/playback/engines", ""},
{http.MethodPost, "/api/v1/playback/resolve", `{"item_id":1,"engines":["html5"]}`},
} {
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
if tc.body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s %s status = %d, want 401", tc.method, tc.path, rec.Code)
}
}
}
func itoa(n int64) string { return strconv.FormatInt(n, 10) }