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:
1 parent
49b0b6eeb9
commit
5c10bbb90e
1 file changed
+80
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user