feat(web): real channel filter — multi-select of the user's channels
The free-text 'channel' filter was dead: it exact-matched SummaryRow.Channel,
which is just the provider ('youtube'), because videos never stored their source
channel. Now they do.
- migration 014: videos.channel_title (nullable; existing rows backfill on the
next discovery pass, pasted videos immediately).
- discovery (NewVideos) + paste (VideoByID) populate channel_title; UpsertVideo
persists it, preserving an existing title when an update arrives empty.
- store.DistinctChannels lists a user's channels (RLS-scoped); SummaryRow carries
ChannelTitle via the shared projection.
- Filter: single Channel -> Channels []string, matching on ChannelTitle; the feed
renders a multi-select of DistinctChannels (hidden until channels exist).
- migrate tests: 014 reversibility + fixed the relative-step counts in the 010/011
up/down tests (014 shifted the topology).
TDD throughout: channel persist + distinct, adapter channel wiring, multi-channel
filter match, handler channel filter, migration up/down.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package web
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -64,7 +65,7 @@ func TestParseYouTubeVideoID(t *testing.T) {
|
||||
func TestListPageShowsPasteFormOnlyWhenConnected(t *testing.T) {
|
||||
render := func(connected bool) string {
|
||||
var buf bytes.Buffer
|
||||
if err := ListPage(listBuckets{}, Filter{}, PipelineStats{}, "", connected).Render(context.Background(), &buf); err != nil {
|
||||
if err := ListPage(listBuckets{}, Filter{}, PipelineStats{}, "", connected, nil).Render(context.Background(), &buf); err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
@@ -78,3 +79,19 @@ func TestListPageShowsPasteFormOnlyWhenConnected(t *testing.T) {
|
||||
t.Errorf("disconnected feed must not show the paste form")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMatchesMultipleChannels(t *testing.T) {
|
||||
f := Filter{Channels: []string{"Acme", "Zeta"}}
|
||||
row := func(ch string) store.SummaryRow { return store.SummaryRow{ChannelTitle: ch, Summarized: true} }
|
||||
|
||||
rows := []store.SummaryRow{row("Acme"), row("Beta"), row("Zeta")}
|
||||
got := f.apply(rows)
|
||||
if len(got) != 2 || got[0].ChannelTitle != "Acme" || got[1].ChannelTitle != "Zeta" {
|
||||
t.Fatalf("multi-channel filter = %+v, want Acme+Zeta only", got)
|
||||
}
|
||||
|
||||
// Empty selection = no channel constraint (all pass).
|
||||
if n := len(Filter{}.apply(rows)); n != 3 {
|
||||
t.Fatalf("no channel filter should pass all rows, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user