Vellum/internal/subsonic/subsonic_test.go
mithral 35442e4aff Phase 2: split the fat packages along real boundaries
Four extractions, each chosen because the two halves have different callers,
different failure modes or different testing needs. Not a line-count exercise.

internal/home out of internal/media
- media serves item RESOURCES (detail, file, cover, progress); home computes
  AGGREGATES (the five shelves). The 2328-line package was doing both.
- media keeps POST /items/{id}/view because it is item-scoped and must stay in
  the package that authorizes the item; it imports home.RecordView for that one
  thing. home does not import media.
- Verified live: all five shelves non-empty, and two consecutive /home calls
  return different explore ids, which is the non-determinism the Explore rewrite
  exists for.

internal/scanner out of internal/library
- The scanner walks the filesystem and reconciles; library CRUD is an admin HTTP
  surface. Different callers (a job vs a request), different failure modes.
- The registry deliberately STAYED in internal/library: three of its four callers
  are not the scanner (modules register types, handlers serve the list, metadata
  reads the group mode, subsonic calls CleanTitle). It is a property of the kind,
  not of the scan. The one genuinely scanner-owned helper became
  library.ExtensionsFor.
- Two tests moved with the code rather than being rewritten; they now exercise
  the boundary by calling library.ListMissing across it.
- Verified live: first scan updates, rescan reports unchanged=N, so change
  detection still short-circuits.

internal/providers out of internal/metadata
- Providers talk to third-party APIs; the enrich job owns the matching policy and
  the database writes. The split's whole point is that a provider is testable
  WITHOUT a database, so a DB-free test was added and OpenLibrary gained a
  BaseURL override to make it possible.
- providers imports zero internal packages — a true leaf.
- The matching policy (searchTitle, artistHint, pickBest) is untouched. It exists
  because real false matches were found.

dto packages out of internal/jellyfin and internal/subsonic
- The wire types and their mapping are now separate from the handler logic, so
  the contract with clients we do not control is reviewable on its own.
- Both dto packages are PURE: zero references to sql.DB or net/http, verified by
  grep. That is what makes them reviewable.
- No field name, tag or error code changed.

internal/arch did its job: it failed the build twice during this phase, once for
each new package that was not yet placed in a layer. That is the rule working,
not an obstacle.

Verified: gofmt clean, build exit 0, vet exit 0, 23 packages pass, svelte-check
0/0, and end to end against a running server — home shelves, a queued scan
reporting unchanged, Jellyfin /Items, and Subsonic ping.

Process note: one agent (dto) stopped mid-task and left the tree red — the dto
packages unlayered and its test still referencing constants that had moved. The
arch test caught it immediately. I finished it by hand: exported constant
references in the test, and both dto packages placed at layer 3.

Still open from Phase 1: internal/jellyfin has no test files, catalog is
item-centric while Subsonic is file-first, and subsonic loadAlbum now scopes to
the item's own library.
2026-10-11 16:03:10 -04:00

502 lines
17 KiB
Go

package subsonic
import (
"context"
"crypto/md5"
"database/sql"
"encoding/hex"
"encoding/json"
"encoding/xml"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mithral/vellum/internal/apitoken"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/subsonic/dto"
"github.com/mithral/vellum/migrations"
)
// fixture is one seeded Subsonic server.
type fixture struct {
db *sql.DB
userID int64
key string
libID int64
album int64
file int64
root string
}
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "subsonic.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := core.Migrate(context.Background(), db, migrations.FS, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db
}
// newFixture seeds one non-admin user with a granted music library holding a
// single three-track album, an artist credit and a cover.
func newFixture(t *testing.T) (*httptest.Server, *fixture) {
t.Helper()
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
for _, name := range []string{
"Artist/Album/01 - One.mp3",
"Artist/Album/02 - Two.mp3",
"Artist/Album/03 - Three.mp3",
} {
p := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
// 100 deterministic bytes so a Range read is verifiable.
if err := os.WriteFile(p, []byte(strings.Repeat("v", 100)), 0o644); err != nil {
t.Fatal(err)
}
}
coverPath := filepath.Join(root, "cover.png")
if err := os.WriteFile(coverPath, []byte("\x89PNG\r\n\x1a\ncoverbytes"), 0o644); err != nil {
t.Fatal(err)
}
user, err := auth.CreateUser(ctx, db, "alice", "Alice", "password123", false)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
var libID int64
if err := db.QueryRowContext(ctx,
`INSERT INTO libraries (name, kind, root_path) VALUES ('Music','music',?) RETURNING id`,
root).Scan(&libID); err != nil {
t.Fatalf("insert library: %v", err)
}
if _, err := db.ExecContext(ctx,
`INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?,?,1,1)`,
user.ID, libID); err != nil {
t.Fatalf("grant: %v", err)
}
var albumID int64
if err := db.QueryRowContext(ctx, `
INSERT INTO media_items (library_id, kind, path, title, sort_title, year)
VALUES (?, 'music', 'Artist/Album', 'Album', 'album', 2013) RETURNING id`,
libID).Scan(&albumID); err != nil {
t.Fatalf("insert album: %v", err)
}
var firstFile int64
for i, name := range []string{
"Artist/Album/01 - One.mp3",
"Artist/Album/02 - Two.mp3",
"Artist/Album/03 - Three.mp3",
} {
var id int64
if err := db.QueryRowContext(ctx, `
INSERT INTO media_files (item_id, path, size, container, duration_ms)
VALUES (?,?,100,'mp3',60000) RETURNING id`,
albumID, name).Scan(&id); err != nil {
t.Fatalf("insert file: %v", err)
}
if i == 0 {
firstFile = id
}
}
var personID int64
if err := db.QueryRowContext(ctx,
`INSERT INTO people (name) VALUES ('The Artist') RETURNING id`).Scan(&personID); err != nil {
t.Fatalf("insert person: %v", err)
}
if _, err := db.ExecContext(ctx,
`INSERT INTO item_people (item_id, person_id, role, ord) VALUES (?,?,'author',0)`,
albumID, personID); err != nil {
t.Fatalf("insert credit: %v", err)
}
if _, err := db.ExecContext(ctx,
`INSERT INTO assets (item_id, role, path, bytes) VALUES (?, 'cover', ?, 20)`,
albumID, coverPath); err != nil {
t.Fatalf("insert asset: %v", err)
}
key, err := apitoken.Mint(ctx, db, user.ID, apitoken.KindSubsonic, "test")
if err != nil {
t.Fatalf("Mint: %v", err)
}
authenticator := auth.NewAuthenticator(db, "vellum_session", 0, false, slog.New(slog.NewTextHandler(io.Discard, nil)))
h := NewHandlers(db, slog.New(slog.NewTextHandler(io.Discard, nil)), authenticator)
mux := http.NewServeMux()
h.Register(mux)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, &fixture{db: db, userID: user.ID, key: key, libID: libID, album: albumID, file: firstFile, root: root}
}
// authQuery is the preferred t=md5(key+salt) credential form.
func (f *fixture) authQuery() string {
salt := "abc123"
sum := md5.Sum([]byte(f.key + salt))
return "u=alice&s=" + salt + "&t=" + hex.EncodeToString(sum[:]) + "&v=1.16.1&c=test"
}
// get performs a request and returns the recorder.
func get(t *testing.T, url string) *http.Response {
t.Helper()
resp, err := http.Get(url)
if err != nil {
t.Fatalf("GET %s: %v", url, err)
}
return resp
}
func readBody(t *testing.T, resp *http.Response) string {
t.Helper()
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
return string(b)
}
// jsonEnvelope decodes a Subsonic JSON response into the root object.
func jsonEnvelope(t *testing.T, body string) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal([]byte(body), &out); err != nil {
t.Fatalf("decode json %q: %v", body, err)
}
root, ok := out["subsonic-response"].(map[string]any)
if !ok {
t.Fatalf("missing subsonic-response in %q", body)
}
return root
}
// ── auth ───────────────────────────────────────────────────────────────
// TestAuthTokenForm proves the preferred md5(key+salt) path succeeds.
func TestAuthTokenForm(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=json"))
root := jsonEnvelope(t, body)
if root["status"] != "ok" {
t.Fatalf("status = %v, want ok; body=%s", root["status"], body)
}
}
// TestAuthWrongTokenIsEnvelope pins the single most common integration failure:
// a bad token is HTTP 200 with status failed and error code 40, never a 401.
func TestAuthWrongTokenIsEnvelope(t *testing.T) {
srv, _ := newFixture(t)
resp := get(t, srv.URL+"/rest/ping.view?u=alice&s=abc123&t=deadbeef&v=1.16.1&c=test&f=json")
if resp.StatusCode != http.StatusOK {
t.Errorf("HTTP status = %d, want 200 (errors belong in the envelope)", resp.StatusCode)
}
body := readBody(t, resp)
root := jsonEnvelope(t, body)
if root["status"] != "failed" {
t.Fatalf("status = %v, want failed; body=%s", root["status"], body)
}
errObj, ok := root["error"].(map[string]any)
if !ok {
t.Fatalf("missing error object; body=%s", body)
}
if code, _ := errObj["code"].(float64); int(code) != 40 {
t.Errorf("error code = %v, want 40; body=%s", errObj["code"], body)
}
}
// TestAuthPlainKey proves p=<key> is accepted (Navidrome compatibility).
func TestAuthPlainKey(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/ping.view?u=alice&p="+f.key+"&v=1.16.1&c=test&f=json"))
if root := jsonEnvelope(t, body); root["status"] != "ok" {
t.Fatalf("p=<key> status = %v, want ok; body=%s", root["status"], body)
}
}
// TestAuthUnknownUserIsCode40 proves an unknown user is the same envelope error.
func TestAuthUnknownUserIsCode40(t *testing.T) {
srv, _ := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/ping.view?u=nobody&p=x&v=1.16.1&c=test&f=json"))
root := jsonEnvelope(t, body)
if root["status"] != "failed" {
t.Fatalf("unknown user status = %v, want failed", root["status"])
}
if code, _ := root["error"].(map[string]any)["code"].(float64); int(code) != 40 {
t.Errorf("unknown user code = %v, want 40", root["error"])
}
}
// ── system / browse ────────────────────────────────────────────────────
func TestPingJSONAndXML(t *testing.T) {
srv, f := newFixture(t)
jroot := jsonEnvelope(t, readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=json")))
if jroot["status"] != "ok" || jroot["version"] != dto.ProtocolVersion || jroot["type"] != dto.ServerType {
t.Errorf("json envelope = %v", jroot)
}
xmlBody := readBody(t, get(t, srv.URL+"/rest/ping.view?"+f.authQuery()+"&f=xml"))
var parsed struct {
XMLName xml.Name `xml:"subsonic-response"`
Status string `xml:"status,attr"`
Version string `xml:"version,attr"`
}
if err := xml.Unmarshal([]byte(xmlBody), &parsed); err != nil {
t.Fatalf("xml parse: %v; body=%s", err, xmlBody)
}
if parsed.Status != "ok" || parsed.Version != dto.ProtocolVersion {
t.Errorf("xml envelope = %+v", parsed)
}
}
func TestGetMusicFolders(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getMusicFolders.view?"+f.authQuery()+"&f=json"))
root := jsonEnvelope(t, body)
folders, ok := root["musicFolders"].(map[string]any)
if !ok {
t.Fatalf("missing musicFolders; body=%s", body)
}
list, ok := folders["musicFolder"].([]any)
if !ok || len(list) != 1 {
t.Fatalf("musicFolder = %v, want one entry; body=%s", folders["musicFolder"], body)
}
if list[0].(map[string]any)["name"] != "Music" {
t.Errorf("folder = %v", list[0])
}
}
func TestGetIndexesAndArtists(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getArtists.view?"+f.authQuery()+"&f=json"))
root := jsonEnvelope(t, body)
artists := root["artists"].(map[string]any)
indexes := artists["index"].([]any)
if len(indexes) != 1 {
t.Fatalf("indexes = %v", indexes)
}
first := indexes[0].(map[string]any)
if first["name"] != "A" {
t.Errorf("index name = %v, want A (The Artist)", first["name"])
}
if first["artist"].([]any)[0].(map[string]any)["name"] != "The Artist" {
t.Errorf("artist = %v", first["artist"])
}
// getIndexes returns the same index but a leaner artist element.
body = readBody(t, get(t, srv.URL+"/rest/getIndexes.view?"+f.authQuery()+"&f=json"))
root = jsonEnvelope(t, body)
if _, ok := root["indexes"]; !ok {
t.Fatalf("missing indexes; body=%s", body)
}
}
func TestSearch3(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/search3.view?"+f.authQuery()+"&query=Artist&f=json"))
root := jsonEnvelope(t, body)
result := root["searchResult3"].(map[string]any)
albums := result["album"].([]any)
if len(albums) != 1 {
t.Fatalf("albums = %v, want one; body=%s", albums, body)
}
if albums[0].(map[string]any)["name"] != "Album" {
t.Errorf("album = %v", albums[0])
}
if len(result["song"].([]any)) != 3 {
t.Errorf("songs = %v, want three", result["song"])
}
if len(result["artist"].([]any)) != 1 {
t.Errorf("artists = %v, want one", result["artist"])
}
}
func TestGetAlbumList2Newest(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getAlbumList2.view?"+f.authQuery()+"&type=newest&f=json"))
root := jsonEnvelope(t, body)
list := root["albumList2"].(map[string]any)
albums := list["album"].([]any)
if len(albums) != 1 {
t.Fatalf("albums = %v; body=%s", albums, body)
}
if albums[0].(map[string]any)["songCount"].(float64) != 3 {
t.Errorf("songCount = %v, want 3", albums[0].(map[string]any)["songCount"])
}
}
func TestGetAlbumAndSong(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getAlbum.view?"+f.authQuery()+"&id="+dto.AlbumID(f.album)+"&f=json"))
root := jsonEnvelope(t, body)
album := root["album"].(map[string]any)
if len(album["song"].([]any)) != 3 {
t.Errorf("getAlbum songs = %v", album["song"])
}
body = readBody(t, get(t, srv.URL+"/rest/getSong.view?"+f.authQuery()+"&id="+dto.FileID(f.file)+"&f=json"))
root = jsonEnvelope(t, body)
song := root["song"].(map[string]any)
if song["album"] != "Album" || song["artist"] != "The Artist" {
t.Errorf("song = %v", song)
}
}
func TestGetMusicDirectory(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getMusicDirectory.view?"+f.authQuery()+"&id="+dto.FolderID(f.libID)+"&f=json"))
root := jsonEnvelope(t, body)
dir := root["directory"].(map[string]any)
children := dir["child"].([]any)
if len(children) != 1 || children[0].(map[string]any)["isDir"] != true {
t.Fatalf("root children = %v; body=%s", children, body)
}
// Drill into the album directory and confirm its songs appear.
body = readBody(t, get(t, srv.URL+"/rest/getMusicDirectory.view?"+f.authQuery()+"&id="+dto.AlbumID(f.album)+"&f=json"))
root = jsonEnvelope(t, body)
dir = root["directory"].(map[string]any)
if len(dir["child"].([]any)) != 3 {
t.Errorf("album directory children = %v", dir["child"])
}
}
func TestGetUser(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getUser.view?"+f.authQuery()+"&f=json"))
root := jsonEnvelope(t, body)
user := root["user"].(map[string]any)
if user["username"] != "alice" {
t.Errorf("username = %v", user["username"])
}
if user["adminRole"] != false || user["streamRole"] != true {
t.Errorf("roles = %v", user)
}
}
func TestGetPlaylistsEmpty(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/getPlaylists.view?"+f.authQuery()+"&f=json"))
root := jsonEnvelope(t, body)
pl, ok := root["playlists"].(map[string]any)
if !ok {
t.Fatalf("missing playlists; body=%s", body)
}
if list, ok := pl["playlist"].([]any); !ok || len(list) != 0 {
t.Errorf("playlist = %v, want empty array", pl["playlist"])
}
}
// ── media ──────────────────────────────────────────────────────────────
func TestStreamRawAndRange(t *testing.T) {
srv, f := newFixture(t)
resp := get(t, srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+dto.FileID(f.file)+"&format=raw")
if resp.StatusCode != http.StatusOK {
t.Fatalf("stream status = %d, want 200", resp.StatusCode)
}
if got := readBody(t, resp); len(got) != 100 {
t.Fatalf("stream body length = %d, want 100", len(got))
}
req, _ := http.NewRequest(http.MethodGet,
srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+dto.FileID(f.file)+"&format=raw", nil)
req.Header.Set("Range", "bytes=0-9")
rangeResp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("range request: %v", err)
}
defer rangeResp.Body.Close()
if rangeResp.StatusCode != http.StatusPartialContent {
t.Fatalf("range status = %d, want 206", rangeResp.StatusCode)
}
if cr := rangeResp.Header.Get("Content-Range"); cr != "bytes 0-9/100" {
t.Errorf("Content-Range = %q, want bytes 0-9/100", cr)
}
body, _ := io.ReadAll(rangeResp.Body)
if len(body) != 10 {
t.Errorf("range body length = %d, want 10", len(body))
}
}
// TestStreamRejectsTranscode proves a non-raw format is refused honestly rather
// than silently served untranscoded.
func TestStreamRejectsTranscode(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+dto.FileID(f.file)+"&format=mp3&f=json"))
root := jsonEnvelope(t, body)
if root["status"] != "failed" {
t.Fatalf("format=mp3 status = %v, want failed; body=%s", root["status"], body)
}
if !strings.Contains(root["error"].(map[string]any)["message"].(string), "transcod") {
t.Errorf("error message = %v, want a transcoding refusal", root["error"])
}
}
func TestGetCoverArt(t *testing.T) {
srv, f := newFixture(t)
resp := get(t, srv.URL+"/rest/getCoverArt.view?"+f.authQuery()+"&id="+dto.AlbumID(f.album))
if resp.StatusCode != http.StatusOK {
t.Fatalf("cover status = %d, want 200", resp.StatusCode)
}
if got := readBody(t, resp); !strings.HasPrefix(got, "\x89PNG") {
t.Errorf("cover bytes = %q", got)
}
}
// TestScrobbleWritesProgress proves scrobble lands in the progress store.
func TestScrobbleWritesProgress(t *testing.T) {
srv, f := newFixture(t)
body := readBody(t, get(t, srv.URL+"/rest/scrobble.view?"+f.authQuery()+
"&id="+dto.FileID(f.file)+"&submission=true&position=30000&f=json"))
if root := jsonEnvelope(t, body); root["status"] != "ok" {
t.Fatalf("scrobble status = %v; body=%s", root["status"], body)
}
var position, duration int64
if err := f.db.QueryRow(
`SELECT position, duration FROM progress WHERE user_id = ? AND item_id = ? AND file_id = ?`,
f.userID, f.album, f.file).Scan(&position, &duration); err != nil {
t.Fatalf("progress row: %v", err)
}
if position != 30000 || duration != 60000 {
t.Errorf("progress = (%d,%d), want (30000,60000)", position, duration)
}
}
// TestAccessScopedToGrants proves a user without a grant cannot stream.
func TestAccessScopedToGrants(t *testing.T) {
srv, f := newFixture(t)
// Revoke the grant, then request the same file.
if _, err := f.db.Exec(`DELETE FROM library_grants WHERE user_id = ?`, f.userID); err != nil {
t.Fatal(err)
}
body := readBody(t, get(t, srv.URL+"/rest/stream.view?"+f.authQuery()+"&id="+dto.FileID(f.file)+"&format=raw&f=json"))
root := jsonEnvelope(t, body)
if root["status"] != "failed" {
t.Fatalf("revoked grant stream status = %v, want failed; body=%s", root["status"], body)
}
}