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.
502 lines
17 KiB
Go
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)
|
|
}
|
|
}
|