test(podcasts): reorder-safety pin — progress survives position renumbering (task 2.6)

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.
This commit is contained in:
John O'Keefe committed 2026-10-29 18:17:51 -04:00
1 parent 49b0b6eeb9
commit 5c10bbb90e
1 file changed
+80
+80
View File
@@ -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)
}