From 5c10bbb90e25e437d3f5d00913fdff2c61128bd5 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 29 Oct 2026 18:17:51 -0400 Subject: [PATCH] =?UTF-8?q?test(podcasts):=20reorder-safety=20pin=20?= =?UTF-8?q?=E2=80=94=20progress=20survives=20position=20renumbering=20(tas?= =?UTF-8?q?k=202.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invariant 3: progress anchors on the episode row (guid identity + chapter/offset), never the ordinal. Renumbering feed_position (the fetcher's per-fetch renumber case) leaves the progress row untouched and the sequence membership stable — downloaded-only with no gaps. --- cmd/server/tests/podcast_test.go | 80 ++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index 18d4257b..873a9e1c 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -12,6 +12,7 @@ package main import ( "bytes" "context" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -583,3 +584,82 @@ func TestPodcastScannerExclusion(t *testing.T) { assert.Empty(t, scanner.FolderCount(), "PIN: podcasts download roots are never scanner folders") assert.NotZero(t, lib.ID, "library must exist for the pin to be meaningful") } + +// ---- invariant 3 / task 2.6: order flips never move progress ---- + +func TestPodcastReorderSafety(t *testing.T) { + setup := setupTestServer(t) + adminCtx := adminSeedCtx(t, setup) + lib, _ := createPodcastsLibrary(t, setup, "test Reorder Podcasts") + + feed, err := setup.DB.CreatePodcastFeed(adminCtx, database.CreatePodcastFeedParams{ + LibraryID: lib.ID, + FeedUrl: "https://feed.invalid/reorder.xml", + Title: "Reorder Feed", + }) + require.NoError(t, err) + e1 := upsertTestEpisode(t, setup, feed, "ro-1", "Reorder One", 1) + e2 := upsertTestEpisode(t, setup, feed, "ro-2", "Reorder Two", 2) + + // Mark both DOWNLOADED so they enter the sequence (the lifecycle + // itself is pinned by TestPodcastDownloadWorker). + adminScopeExec(t, setup, adminCtxValue(t, setup), + `UPDATE media_items SET download_state = 'downloaded', file_size = 1000 + WHERE id IN ($1, $2) AND source = 'podcast_episode'`, e1.ID, e2.ID) + + adminID := adminCtxValue(t, setup) + + // Listener is 40% through episode TWO (direct row write — the pin + // is about the row's LIFE under renumbering, not the write API). + adminScopeExec(t, setup, adminID, + `INSERT INTO reading_progress (media_item_id, user_id, percentage, chapter, character_offset) + VALUES ($1, $2, 0.4, 0, 0) + ON CONFLICT (media_item_id, user_id) DO UPDATE SET percentage = 0.4, last_read_at = NOW()`, + e2.ID, adminID) + + before, err := setup.DB.GetReadingProgress(adminCtx, database.GetReadingProgressParams{ + MediaItemID: e2.ID, UserID: adminID, + }) + require.NoError(t, err) + pctBefore := before.Percentage.Float64 + + // FLIP the feed's native positions (the fetcher renumbering case: + // the publisher reorders; positions renumber on the next fetch). + adminScopeExec(t, setup, adminID, + `UPDATE media_items SET feed_position = CASE id WHEN $1 THEN 2 WHEN $2 THEN 1 END + WHERE id IN ($1, $2)`, e1.ID, e2.ID) + + after, err := setup.DB.GetReadingProgress(adminCtx, database.GetReadingProgressParams{ + MediaItemID: e2.ID, UserID: adminID, + }) + require.NoError(t, err) + assert.Equal(t, pctBefore, after.Percentage.Float64, + "PIN: progress anchors on the episode (guid/identity), never the ordinal — reordering must not move it") + + // The sequence still lists both downloaded episodes; the override + // only changes ORDER, never membership. + memberID := getRegularUserID(t, setup.DB) + subscribeRaw(t, setup, memberID, feed.ID) + _, body := podcastAuthed(t, setup, setup.Token, "GET", + "/api/podcasts/feeds/"+uuid.UUID(feed.ID.Bytes).String()+"/sequence", "") + var seq struct { + Episodes []struct { + ID string `json:"id"` + } `json:"episodes"` + Gaps []interface{} `json:"gaps"` + } + require.NoError(t, json.Unmarshal(body, &seq)) + assert.Len(t, seq.Episodes, 2, "sequence membership survives renumbering") + assert.Empty(t, seq.Gaps, "no un-downloaded rows → no gaps") +} + +// adminScopeExec runs raw SQL under the first-admin identity (helper +// for pins that need direct row surgery the Queries API lacks). +func adminScopeExec(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, sql string, args ...any) { + t.Helper() + scope := database.NewScopedDB(setup.DBPool) + _, err := scope.Exec(rls.WithUser(context.Background(), adminID), sql, args...) + require.NoError(t, err) +} + +