release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m44s
release / android (push) Successful in 4m54s
release / Build signed APK (push) Successful in 5m10s
release / integration (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
The change feed dropped every row older than 30 days, and an app syncing from cursor 0 builds its library from the feed, so a fresh install, cleared data or a 410 reset got only what had changed lately. The compactor now keeps each entity's latest change however old. It drops a row once a newer one for the same entity exists, and a delete once it is 30 days old, raising a stored mark (library_changes_trimmed) past it; an app whose cursor is below the mark is sent back to 0, as before. Migration 0101 adds the index the compactor needs, seeds the mark where the old compactor left off, and gives every artist, album and track whose latest row is not an upsert a fresh one, so the rows the old compactor dropped come back. The backup carries the mark with the feed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
288 lines
9.2 KiB
Go
288 lines
9.2 KiB
Go
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())
|
|
}
|
|
}
|