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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"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.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")
|
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