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:
1 parent
f68fb19afb
commit
abb7c84833
17 files changed
+694
-204
No files matched your search
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user