Files
minstrel/internal/api/library_sync_test.go
T
bvandeusenandClaude Opus 5.5 466912e202
release / govulncheck (push) Successful in 25s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m47s
release / integration (push) Successful in 4m35s
release / android (push) Successful in 7m5s
release / Build signed APK (releases and dev) (push) Successful in 7m16s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m1s
release / Verify release artifacts (tag releases only) (push) Skipped
docs: plan-era "v1", "Task N" and "Phase N" wording restated as facts (#6277)
The marker list behind the earlier server and web passes did not catch
"for v1", "Task 5", "Phase 2" or "today: Flutter". Those comments now say
what the code does.

Corrected: coverart said "both v1 providers" ship on. There are four
providers (MBCAA, TheAudioDB, Deezer, Last.fm), and Last.fm, which needs a
key, ships off.

Non-comment tokens unchanged against HEAD: 10 Go files, 7 TS/Svelte files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:05:03 -04:00

207 lines
6.1 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
func callLibrarySync(h *handlers, user dbq.User, since string) *httptest.ResponseRecorder {
url := "/api/library/sync"
if since != "" {
url += "?since=" + since
}
req := httptest.NewRequest(http.MethodGet, url, nil)
req = req.WithContext(context.WithValue(req.Context(), auth.UserCtxKeyForTest(), user))
w := httptest.NewRecorder()
h.handleLibrarySync(w, req)
return w
}
func TestLibrarySync_NoChanges_Returns204(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
u := seedUser(t, pool, "alice", "x", false)
w := callLibrarySync(h, u, "0")
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
}
func TestLibrarySync_AfterArtistUpsert_ReturnsHydratedPayload(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
u := seedUser(t, pool, "alice", "x", false)
// Seed an artist + write a change row in the same tx — the invariant
// library writes keep, and the handler relies on.
ctx := context.Background()
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var artistID string
if err := tx.QueryRow(ctx,
`INSERT INTO artists (name, sort_name) VALUES ($1, $2) RETURNING id::text`,
"Boards of Canada", "Boards of Canada",
).Scan(&artistID); err != nil {
t.Fatalf("insert artist: %v", err)
}
if err := sync.LogChange(ctx, tx, sync.EntityArtist, artistID, sync.OpUpsert); err != nil {
t.Fatalf("LogChange: %v", err)
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("commit: %v", err)
}
w := callLibrarySync(h, u, "0")
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
var body struct {
Cursor int64 `json:"cursor"`
Upserts map[string][]json.RawMessage `json:"upserts"`
Deletes map[string][]string `json:"deletes"`
}
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Cursor <= 0 {
t.Fatalf("expected positive cursor, got %d", body.Cursor)
}
if len(body.Upserts["artist"]) != 1 {
t.Fatalf("expected 1 artist upsert, got %d", len(body.Upserts["artist"]))
}
}
func TestLibrarySync_CursorMonotonic(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
u := seedUser(t, pool, "alice", "x", false)
ctx := context.Background()
tx, _ := pool.Begin(ctx)
var artistID string
_ = tx.QueryRow(ctx,
`INSERT INTO artists (name, sort_name) VALUES ($1, $2) RETURNING id::text`,
"A", "A",
).Scan(&artistID)
_ = sync.LogChange(ctx, tx, sync.EntityArtist, artistID, sync.OpUpsert)
_ = tx.Commit(ctx)
w1 := callLibrarySync(h, u, "0")
if w1.Code != http.StatusOK {
t.Fatalf("first call status = %d", w1.Code)
}
var body1 struct {
Cursor int64 `json:"cursor"`
}
if err := json.NewDecoder(w1.Body).Decode(&body1); err != nil {
t.Fatalf("decode: %v", err)
}
w2 := callLibrarySync(h, u, strconv.FormatInt(body1.Cursor, 10))
if w2.Code != http.StatusNoContent {
t.Fatalf("second call status = %d body = %s", w2.Code, w2.Body.String())
}
}
func TestLibrarySync_DeleteIsReflected(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
u := seedUser(t, pool, "alice", "x", false)
// Just write a delete change row directly (no underlying mutation).
// Library writes couple real deletes to LogChange; here we test the
// handler reflects what's in the log.
ctx := context.Background()
tx, _ := pool.Begin(ctx)
_ = sync.LogChange(ctx, tx, sync.EntityArtist, "deadbeef-dead-beef-dead-beefdeadbeef", sync.OpDelete)
_ = tx.Commit(ctx)
w := callLibrarySync(h, u, "0")
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
var body struct {
Cursor int64 `json:"cursor"`
Upserts map[string][]json.RawMessage `json:"upserts"`
Deletes map[string][]string `json:"deletes"`
}
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if got := body.Deletes["artist"]; len(got) != 1 || got[0] != "deadbeef-dead-beef-dead-beefdeadbeef" {
t.Fatalf("expected one artist delete; got %#v", body.Deletes)
}
}
func TestLibrarySync_LikeRowScopedToUser(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
u := seedUser(t, pool, "alice", "x", false)
other := seedUser(t, pool, "bob", "x", false)
// Two like_track rows: one for u, one for other. u's call should
// see only their own row.
ctx := context.Background()
tx, _ := pool.Begin(ctx)
_ = sync.LogChange(ctx, tx, sync.EntityLikeTrack,
sync.EncodeLikeID(sync.FormatUUID(u.ID), "11111111-1111-1111-1111-111111111111"),
sync.OpUpsert)
_ = sync.LogChange(ctx, tx, sync.EntityLikeTrack,
sync.EncodeLikeID(sync.FormatUUID(other.ID), "22222222-2222-2222-2222-222222222222"),
sync.OpUpsert)
_ = tx.Commit(ctx)
w := callLibrarySync(h, u, "0")
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
var body struct {
Upserts map[string][]json.RawMessage `json:"upserts"`
}
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
got := body.Upserts["like_track"]
if len(got) != 1 {
t.Fatalf("expected 1 like row scoped to alice, got %d", len(got))
}
}
// Pure unit tests below — run even with -short.
func TestSplitOnce(t *testing.T) {
cases := []struct {
in, sep string
want []string
}{
{"a:b", ":", []string{"a", "b"}},
{"a:b:c", ":", []string{"a", "b:c"}},
{"abc", ":", []string{"abc"}},
{":x", ":", []string{"", "x"}},
}
for _, c := range cases {
got := splitOnce(c.in, c.sep)
if len(got) != len(c.want) {
t.Errorf("splitOnce(%q,%q): len got=%d want=%d", c.in, c.sep, len(got), len(c.want))
continue
}
for i := range got {
if got[i] != c.want[i] {
t.Errorf("splitOnce(%q,%q)[%d] = %q want %q", c.in, c.sep, i, got[i], c.want[i])
}
}
}
}