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>
142 lines
4.4 KiB
Go
142 lines
4.4 KiB
Go
package store_test
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"gitea.d-ma.be/mathias/tapir/internal/domain"
|
|
)
|
|
|
|
func ytVideo(userID, provVideoID, title string) domain.Video {
|
|
return domain.Video{
|
|
UserID: userID,
|
|
Provider: domain.ProviderYouTube,
|
|
ProviderVideoID: provVideoID,
|
|
Title: title,
|
|
URL: "https://www.youtube.com/watch?v=" + provVideoID,
|
|
PublishedAt: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC),
|
|
// SubscriptionID is a provider resource id, not a UUID — must not be
|
|
// written to the UUID column. Set it to prove UpsertVideo ignores it.
|
|
SubscriptionID: "yt-subscription-resource-id",
|
|
}
|
|
}
|
|
|
|
func TestUpsertVideo_ReturnsStableID(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newStore(t)
|
|
resetDB(t, rawPool(t))
|
|
|
|
id1, err := s.UpsertVideo(ctx, ytVideo(userA, "dQw4w9WgXcQ", "first title"))
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, id1)
|
|
|
|
// Same (user, provider, provider_video_id) -> same row, same id, updated meta.
|
|
id2, err := s.UpsertVideo(ctx, ytVideo(userA, "dQw4w9WgXcQ", "updated title"))
|
|
require.NoError(t, err)
|
|
require.Equal(t, id1, id2, "idempotent upsert must return the same durable id")
|
|
|
|
p := rawPool(t)
|
|
var (
|
|
title string
|
|
count int
|
|
)
|
|
require.NoError(t, p.QueryRow(ctx,
|
|
`SELECT title FROM videos WHERE id = $1`, id1).Scan(&title))
|
|
require.Equal(t, "updated title", title, "second upsert must update metadata in place")
|
|
|
|
require.NoError(t, p.QueryRow(ctx,
|
|
`SELECT count(*) FROM videos WHERE user_id = $1`, userA).Scan(&count))
|
|
require.Equal(t, 1, count, "must not duplicate the row")
|
|
}
|
|
|
|
func TestUpsertVideo_IDMatchesSummaryDedup(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newStore(t)
|
|
resetDB(t, rawPool(t))
|
|
|
|
// Upsert assigns the durable video id; a summary delivered under that id
|
|
// must then show up in SeenVideoIDs — this is the cross-restart dedup chain.
|
|
id, err := s.UpsertVideo(ctx, ytVideo(userA, "abc123", "t"))
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, s.Deliver(ctx, summary(userA, id, "the summary")))
|
|
|
|
seen, err := s.SeenVideoIDs(ctx, userA)
|
|
require.NoError(t, err)
|
|
require.True(t, seen[id], "the upserted video id must match the summary dedup key")
|
|
}
|
|
|
|
func TestUpsertVideo_PerUserIsolation(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newStore(t)
|
|
resetDB(t, rawPool(t))
|
|
|
|
idA, err := s.UpsertVideo(ctx, ytVideo(userA, "same-provider-id", "a"))
|
|
require.NoError(t, err)
|
|
idB, err := s.UpsertVideo(ctx, ytVideo(userB, "same-provider-id", "b"))
|
|
require.NoError(t, err)
|
|
|
|
require.NotEqual(t, idA, idB, "same provider video for two users must be two distinct rows")
|
|
}
|
|
|
|
func TestNewestUnsummarizedVideoIDs(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newStore(t)
|
|
resetDB(t, rawPool(t))
|
|
|
|
mk := func(user, pid string, day int) string {
|
|
v := ytVideo(user, pid, pid)
|
|
v.PublishedAt = time.Date(2026, 6, day, 12, 0, 0, 0, time.UTC)
|
|
id, err := s.UpsertVideo(ctx, v)
|
|
require.NoError(t, err)
|
|
return id
|
|
}
|
|
|
|
_ = mk(userA, "a1vid000001", 1)
|
|
id2 := mk(userA, "a2vid000002", 2)
|
|
id3 := mk(userA, "a3vid000003", 3)
|
|
id4 := mk(userA, "a4vid000004", 4)
|
|
mk(userB, "b1vid000009", 9) // userB's newest — must never leak via RLS
|
|
|
|
// The newest (v4) is summarized, so it's excluded from "unsummarized".
|
|
require.NoError(t, s.Deliver(ctx, summary(userA, id4, "done")))
|
|
|
|
// Cap 2, newest-first unsummarized: v3 then v2 (v4 excluded; userB excluded).
|
|
got, err := s.NewestUnsummarizedVideoIDs(ctx, userA, 2)
|
|
require.NoError(t, err)
|
|
require.Equal(t, []string{id3, id2}, got)
|
|
|
|
none, err := s.NewestUnsummarizedVideoIDs(ctx, userA, 0)
|
|
require.NoError(t, err)
|
|
require.Empty(t, none, "limit 0 returns nothing")
|
|
}
|
|
|
|
func TestUpsertVideoPersistsChannelAndDistinctChannels(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newStore(t)
|
|
resetDB(t, rawPool(t))
|
|
|
|
mk := func(pid, channel string) {
|
|
v := ytVideo(userA, pid, pid)
|
|
v.ChannelTitle = channel
|
|
_, err := s.UpsertVideo(ctx, v)
|
|
require.NoError(t, err)
|
|
}
|
|
mk("aa11111aaaa", "Acme Talks")
|
|
mk("bb22222bbbb", "Acme Talks") // same channel
|
|
mk("cc33333cccc", "Zeta Channel")
|
|
// userB's channel must not leak.
|
|
vb := ytVideo(userB, "dd44444dddd", "x")
|
|
vb.ChannelTitle = "Bravo Only"
|
|
_, err := s.UpsertVideo(ctx, vb)
|
|
require.NoError(t, err)
|
|
|
|
got, err := s.DistinctChannels(ctx, userA)
|
|
require.NoError(t, err)
|
|
require.Equal(t, []string{"Acme Talks", "Zeta Channel"}, got,
|
|
"distinct, alphabetical, user-scoped (no Bravo Only)")
|
|
}
|