package api import ( "context" "encoding/json" "net/http" "net/http/httptest" "strconv" "strings" "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]) } } } } // A server restored from an older backup has handed out fewer cursors than an // app remembers; the app is told to reset rather than wait for ids it would // then skip. A compacted log is not that: the cursor the server handed out // still counts. func TestLibrarySync_ACursorPastTheServersLastChangeIsReset(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) u := seedUser(t, pool, "alice", "x", false) ctx := context.Background() var artistID string if err := pool.QueryRow(ctx, `INSERT INTO artists (name, sort_name) VALUES ('A', 'A') RETURNING id::text`).Scan(&artistID); err != nil { t.Fatal(err) } tx, err := pool.Begin(ctx) if err != nil { t.Fatal(err) } if err := sync.LogChange(ctx, tx, sync.EntityArtist, artistID, sync.OpUpsert); err != nil { t.Fatal(err) } if err := tx.Commit(ctx); err != nil { t.Fatal(err) } var last int64 if err := pool.QueryRow(ctx, `SELECT max(id) FROM library_changes`).Scan(&last); err != nil { t.Fatal(err) } w := callLibrarySync(h, u, strconv.FormatInt(last+50, 10)) if w.Code != http.StatusGone || !strings.Contains(w.Body.String(), "cursor_ahead") { t.Errorf("a cursor past the last change: status = %d body = %s, want 410 cursor_ahead", w.Code, w.Body.String()) } // The compactor trims every row; the app's cursor is still one this // server handed out. if _, err := pool.Exec(ctx, `DELETE FROM library_changes`); err != nil { t.Fatal(err) } if w := callLibrarySync(h, u, strconv.FormatInt(last, 10)); w.Code != http.StatusNoContent { t.Errorf("the last cursor after compaction: status = %d body = %s, want 204", w.Code, w.Body.String()) } } // An app is sent back to 0 only when its cursor is below a delete the log has // dropped, which it may have missed. A cursor at that delete has seen it, and // one between dropped and kept rows replays what is still there. func TestLibrarySync_ACursorBelowADroppedDeleteIsReset(t *testing.T) { h, pool := testHandlers(t) truncateLibrary(t, pool) u := seedUser(t, pool, "alice", "x", false) ctx := context.Background() for i := 0; i < 5; i++ { if _, err := pool.Exec(ctx, `INSERT INTO library_changes (entity_type, entity_id, op) VALUES ('artist', $1, 'upsert')`, strconv.Itoa(i)); err != nil { t.Fatal(err) } } var dropped int64 if err := pool.QueryRow(ctx, `SELECT min(id) + 2 FROM library_changes`).Scan(&dropped); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `UPDATE library_changes_trimmed SET through = $1`, dropped); err != nil { t.Fatal(err) } if w := callLibrarySync(h, u, strconv.FormatInt(dropped-1, 10)); w.Code != http.StatusGone || !strings.Contains(w.Body.String(), "cursor_too_old") { t.Errorf("a cursor below the dropped delete: status = %d body = %s, want 410 cursor_too_old", w.Code, w.Body.String()) } if w := callLibrarySync(h, u, strconv.FormatInt(dropped, 10)); w.Code != http.StatusOK { t.Errorf("a cursor at the dropped delete: status = %d body = %s, want 200", w.Code, w.Body.String()) } if w := callLibrarySync(h, u, "0"); w.Code != http.StatusOK { t.Errorf("a sync from 0: status = %d body = %s, want 200", w.Code, w.Body.String()) } }