feat(podcasts): smart episode collections — ALL/ANY match modes + episode rule fields (item 2.4)

The daily briefing needs AND: today's rules engine was OR-only. The
match semantics become a per-collection column
(collections.rule_match_mode, 'any' legacy default | 'all'), flipped on
its own endpoint (PUT /api/collections/:id/match-mode, owner-only —
nothing else on the row changes) and surfaced in the rules page as an
ANY/ALL toggle with matching copy.

Episode rule fields join the vocabulary (plan item 2.4): feed (the
multi-select rides the new `in` operator — comma-separated feed ids),
played (the canonical finished predicate — viewer progress ≥ 0.999,
same as hide_finished), downloaded, published_within (N days), plus
title/description contains. The rules candidate query now carries the
viewer's progress (LEFT JOIN reading_progress) so played/unplayed is
per-user, and TestRules/preview speak the SAME evaluator: the handler's
duplicated field switch is deleted and delegates to
CollectionService.FieldMatches — one vocabulary, one semantics.

Legitimate semantics note: in ANY mode `played:false` matches unplayed
BOOKS too (everything with no progress row) — correct per-rule
behavior, and exactly why the briefing mode exists.

Backward compatibility: every legacy row defaults to 'any' (the old
OR), UpdateCollection keeps the stored mode when the form doesn't send
one, and the evaluator treats absent data honestly (no date → no
match; no progress → unplayed).

Pinned by TestPodcastSmartCollections: the AND-mode briefing
(feed∈{A} ∧ unplayed ∧ <7d = exactly the new episode), OR-mode
widening, owner-only flips, the preview honoring the mode, and
downloaded/contains rules. Also fixes a latent bug the new query
surfaced: UpdateCollectionViewSettings referenced a nonexistent
updated_at column (never exercised until now — the new sibling query
was written without it).
This commit is contained in:
John O'Keefe committed 2026-10-30 16:54:36 -04:00
1 parent f68fb19afb
commit abb7c84833
17 files changed
+694 -204

No files matched your search

+129
View File
@@ -765,6 +765,135 @@ func TestPodcastEpisodeCollections(t *testing.T) {
})
}
// ---- item 2.4: smart episode collections — episode rule fields +
// the ANY/ALL match modes (the Daily Fiction building blocks) ----
func TestPodcastSmartCollections(t *testing.T) {
setup := setupTestServer(t)
regUUID := getRegularUserID(t, setup.DB)
regToken := setup.RegularToken
adminToken := setup.Token
lib, _ := createPodcastsLibrary(t, setup, "test SmartColl Podcasts")
marker := fmt.Sprintf("smartcoll%d", time.Now().UnixNano()%100000)
feedA, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-a.xml",
Title: "SmartColl Feed A " + marker,
})
require.NoError(t, err)
feedB, err := setup.DB.CreatePodcastFeed(adminSeedCtx(t, setup), database.CreatePodcastFeedParams{
LibraryID: lib.ID,
FeedUrl: "https://feed.invalid/" + marker + "-b.xml",
Title: "SmartColl Feed B " + marker,
})
require.NoError(t, err)
aNew := upsertTestEpisode(t, setup, feedA, marker+"-new", "SmartColl A New", 1)
aOld := upsertTestEpisode(t, setup, feedA, marker+"-old", "SmartColl A Old", 2)
aPlayed := upsertTestEpisode(t, setup, feedA, marker+"-played", "SmartColl A Played", 3)
bEp := upsertTestEpisode(t, setup, feedB, marker+"-b", "SmartColl B Ep", 1)
// Seed the episode facts the rules read: publication dates + the
// played marker (viewer progress ≥ 0.999 = the canonical finished
// definition, same predicate as hide_finished).
adminScopeExec(t, setup, adminCtxValue(t, setup), `
UPDATE media_items SET download_state = 'downloaded', file_size = 1000,
date_published = CURRENT_DATE WHERE id IN ($1, $2, $3)`, aNew.ID, aPlayed.ID, bEp.ID)
adminScopeExec(t, setup, adminCtxValue(t, setup), `
UPDATE media_items SET download_state = 'downloaded', file_size = 1000,
date_published = CURRENT_DATE - 30 WHERE id = $1`, aOld.ID)
adminScopeExec(t, setup, adminCtxValue(t, setup), `
INSERT INTO reading_progress (media_item_id, user_id, percentage)
VALUES ($1, $2, 1.0) ON CONFLICT (media_item_id, user_id)
DO UPDATE SET percentage = 1.0`, aPlayed.ID, regUUID)
subscribeRaw(t, setup, regUUID, feedA.ID)
feedAID := uuid.UUID(feedA.ID.Bytes).String()
// Smart collection with the briefing's AND shape, created through
// the real API.
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections",
`{"name":"test Daily Fiction `+marker+`","collection_type":"smart","auto_assign_rules":[
{"field":"feed","operator":"in","value":"`+feedAID+`"},
{"field":"played","operator":"equals","value":"false"},
{"field":"published_within","operator":"greater_than","value":"7"}
]}`)
require.Equal(t, http.StatusCreated, code, "%s", string(body))
var coll struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(body, &coll))
collID := coll.ID
// ALL mode (AND): the daily briefing — feed ∈ {A} ∧ unplayed ∧ <7d.
code, body = podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"all"}`)
require.Equal(t, http.StatusOK, code, "%s", string(body))
t.Run("all_mode_is_the_briefing", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
require.Equal(t, http.StatusOK, code)
var out struct {
Books []struct{ Title string `json:"title"` } `json:"books"`
IsSmart bool `json:"is_smart"`
MatchAll string `json:"rule_match_mode"`
}
require.NoError(t, json.Unmarshal(body, &out))
require.True(t, out.IsSmart)
titles := map[string]bool{}
for _, b := range out.Books {
titles[b.Title] = true
}
assert.Equal(t, "all", out.MatchAll, "the match mode round-trips")
assert.True(t, titles["SmartColl A New"], "feed-A + unplayed + <7d matches")
assert.Len(t, out.Books, 1, "exactly the briefing set — aOld fails <7d, aPlayed fails unplayed, bEp is RLS-dropped")
})
t.Run("any_mode_widens_to_or", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"any"}`)
require.Equal(t, http.StatusOK, code)
code, body = podcastAuthed(t, setup, regToken, "GET", "/api/collections/"+collID, "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, string(body), "SmartColl A Old", "OR: aOld matches unplayed even though it's 30d old")
assert.Contains(t, string(body), "SmartColl A New")
assert.Contains(t, string(body), "SmartColl A Played", "OR: aPlayed matches via the feed rule alone")
assert.NotContains(t, string(body), "SmartColl B Ep", "unsubscribed feed's episode never reaches evaluation (RLS)")
})
t.Run("match_mode_is_owner_only", func(t *testing.T) {
code, body := podcastAuthed(t, setup, adminToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"all"}`)
assert.Equal(t, http.StatusForbidden, code, "only the owner flips the mode: %s", string(body))
})
t.Run("test_rules_preview_honors_mode", func(t *testing.T) {
// The rules page preview speaks the same evaluator: all-mode
// requires every rule (aNew passes feed∧unplayed∧<7d; aOld
// fails published_within).
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections/test-rules",
`{"match_mode":"all","rules":[
{"field":"feed","operator":"in","value":"`+feedAID+`"},
{"field":"played","operator":"equals","value":"false"},
{"field":"published_within","operator":"greater_than","value":"7"}
]}`)
require.Equal(t, http.StatusOK, code, "%s", string(body))
assert.Contains(t, string(body), "SmartColl A New")
assert.NotContains(t, string(body), "SmartColl A Old")
})
t.Run("downloaded_and_contains_rules", func(t *testing.T) {
code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections/test-rules",
`{"match_mode":"all","rules":[
{"field":"downloaded","operator":"equals","value":"true"},
{"field":"title","operator":"contains","value":"SmartColl A"}
]}`)
require.Equal(t, http.StatusOK, code, "%s", string(body))
assert.Contains(t, string(body), "SmartColl A New")
assert.Contains(t, string(body), "SmartColl A Played", "played but downloaded — the downloaded rule alone doesn't exclude it")
assert.NotContains(t, string(body), "SmartColl B Ep")
})
}
// ---- download worker: state machine, idempotency, file layout ----
func TestPodcastDownloadWorker(t *testing.T) {