feat(podcasts): reverse order — position DESC, date-agnostic; single order helper

The 'Oldest first' order sorts by PUB DATE, which breaks on Scribl's
migrated podiobooks feeds (messy/all-equal dates). New order value
'reverse' = feed position DESC with a date-DESC tiebreak — dates are
not involved, so nothing can break.

- services.OrderEpisodes: THE single order source of truth (§0 items
  3+5a). The four surfaces (sequence JSON, reader chain context, SSR
  page list) previously carried three near-identical handwritten
  comparators — all now route through one generic helper. The SSR page
  list gains a SortDate key (lexical 2006-01-02) so the pub-date
  tiebreak is actually chronological; the old comparator compared
  'Jan 2, 2006' display strings alphabetically (latent drift bug).
- Dropdown becomes four options: Feed order / Feed order (reversed) /
  Oldest first (pub date) / Newest first (pub date).
- Serial suggestion button renamed 'Reverse feed order' (sets reverse,
  not oldest) and its response out-of-band-swaps the order select
  (PodcastOrderSelectOOB — same mechanism as Download All's policy
  select), so the dropdown mirrors the server state immediately.
- Validation: oneof=native reverse oldest newest everywhere.
- Tests: reverse binds via the form wire; messy-dates sequence
  returns 3,2,1; existing reorder-safety stays green.
This commit is contained in:
John O'Keefe committed 2026-10-30 09:15:03 -04:00
1 parent 7c7c08748e
commit e3675536aa
9 files changed
+708 -463

No files matched your search

+77 -15
View File
@@ -11,12 +11,12 @@ package main
import (
"bytes"
"net/url"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
@@ -105,18 +105,18 @@ func createPodcastsLibrary(t *testing.T, setup *TestServerSetup, name string) (d
func upsertTestEpisode(t *testing.T, setup *TestServerSetup, feed database.PodcastFeeds, guid, title string, position int) database.MediaItems {
t.Helper()
ep, err := setup.DB.UpsertPodcastEpisode(adminSeedCtx(t, setup), database.UpsertPodcastEpisodeParams{
LibraryID: feed.LibraryID,
Title: title,
Authors: []string{},
AuthorsSearch: []string{},
FilePath: fmt.Sprintf("test-feed/%04d-%s.mp3", position, guid),
Tags: []string{},
SourceFeedID: feed.ID,
EpisodeGuid: pgtype.Text{String: guid, Valid: true},
FeedPosition: pgtype.Int4{Int32: int32(position), Valid: true},
EnclosureUrl: pgtype.Text{String: "https://cdn.invalid/" + guid + ".mp3", Valid: true},
Hosts: []string{},
Guests: []string{},
LibraryID: feed.LibraryID,
Title: title,
Authors: []string{},
AuthorsSearch: []string{},
FilePath: fmt.Sprintf("test-feed/%04d-%s.mp3", position, guid),
Tags: []string{},
SourceFeedID: feed.ID,
EpisodeGuid: pgtype.Text{String: guid, Valid: true},
FeedPosition: pgtype.Int4{Int32: int32(position), Valid: true},
EnclosureUrl: pgtype.Text{String: "https://cdn.invalid/" + guid + ".mp3", Valid: true},
Hosts: []string{},
Guests: []string{},
})
require.NoError(t, err, "seed episode %q", title)
return ep
@@ -664,8 +664,6 @@ func adminScopeExec(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, s
require.NoError(t, err)
}
// ---- task 1.9 residue: the htmx wire format is FORM-encoded (issue #2) ----
func TestPodcastOrderFormEncoding(t *testing.T) {
@@ -695,10 +693,74 @@ func TestPodcastOrderFormEncoding(t *testing.T) {
assert.Equal(t, http.StatusOK, formPut("order=oldest"), "form-encoded order must bind")
assert.Equal(t, http.StatusOK, formPut("order=native"), "native binds too")
assert.Equal(t, http.StatusOK, formPut("order=reverse"), "reverse binds too (§0 item 3)")
assert.Equal(t, http.StatusBadRequest, formPut(""), "missing order stays rejected (required)")
assert.Equal(t, http.StatusBadRequest, formPut("order=sideways"), "invalid order stays rejected")
}
// ---- §0 item 3: reverse = feed position DESC (no pub dates involved) ----
func TestPodcastReverseOrder(t *testing.T) {
setup := setupTestServer(t)
adminCtx := adminSeedCtx(t, setup)
adminID := adminCtxValue(t, setup)
lib, _ := createPodcastsLibrary(t, setup, "test ReverseOrder Podcasts")
feed, err := setup.DB.CreatePodcastFeed(adminCtx, database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/reverse-order.xml",
Title: "Reverse Order Feed",
})
require.NoError(t, err)
feedIDStr := uuid.UUID(feed.ID.Bytes).String()
// Episodes with MESSY dates (the Scribl condition): all equal.
// reverse must be driven by feed position alone — position 3 first.
epA := upsertTestEpisode(t, setup, feed, "rr-1", "Reverse Alpha", 1)
epB := upsertTestEpisode(t, setup, feed, "rr-2", "Reverse Beta", 2)
epC := upsertTestEpisode(t, setup, feed, "rr-3", "Reverse Gamma", 3)
adminScopeExec(t, setup, adminID, `
UPDATE media_items SET
download_state = 'downloaded',
file_size = 1000,
date_published = '2020-01-01T00:00:00Z'::timestamptz
WHERE id IN ($1, $2, $3) AND source = 'podcast_episode'`, epA.ID, epB.ID, epC.ID)
subscribeRaw(t, setup, uuid.UUID(adminID.Bytes), feed.ID)
// Set the override to reverse (the wire the "Reverse feed order"
// button uses).
formPut := func(body string) int {
req, _ := http.NewRequest(http.MethodPut,
setup.Server.URL+"/api/podcasts/feeds/"+feedIDStr+"/order",
strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
return resp.StatusCode
}
require.Equal(t, http.StatusOK, formPut("order=reverse"))
// The SEQUENCE endpoint must return position 3, 2, 1 — driven by
// feed position DESC, dates being all equal (the Scribl guard).
_, body := podcastAuthed(t, setup, setup.Token, "GET",
"/api/podcasts/feeds/"+feedIDStr+"/sequence", "")
var seq struct {
Order string `json:"order"`
Episodes []struct {
Title string `json:"title"`
FeedPosition int `json:"feed_position"`
} `json:"episodes"`
}
require.NoError(t, json.Unmarshal(body, &seq))
assert.Equal(t, "reverse", seq.Order, "the endpoint echoes the effective order")
require.Len(t, seq.Episodes, 3)
assert.Equal(t, 3, seq.Episodes[0].FeedPosition, "PIN: reverse = position DESC, date-agnostic")
assert.Equal(t, 1, seq.Episodes[2].FeedPosition)
assert.Equal(t, "Reverse Gamma", seq.Episodes[0].Title,
"position 3 first — a messy-date feed reorders by position, not dates")
}
// ---- issue #8: page search hits EPISODE titles/descriptions too ----
func TestPodcastPageSearchEpisodes(t *testing.T) {