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_PersistsAndPreservesDuration(t *testing.T) { ctx := context.Background() s := newStore(t) resetDB(t, rawPool(t)) // First upsert carries a known duration (ADR-028: discovery enriches it). v := ytVideo(userA, "dur0000001x", "with duration") v.DurationSeconds = 750 id, err := s.UpsertVideo(ctx, v) require.NoError(t, err) p := rawPool(t) readDuration := func() *int { var d *int require.NoError(t, p.QueryRow(ctx, `SELECT duration_s FROM videos WHERE id = $1`, id).Scan(&d)) return d } require.NotNil(t, readDuration()) require.Equal(t, 750, *readDuration(), "duration must persist") // A later upsert that does NOT know the duration (0) must not clobber it — // the channel_title backfill stance (migration 014): COALESCE-preserve. v2 := ytVideo(userA, "dur0000001x", "title updated, duration unknown") v2.DurationSeconds = 0 _, err = s.UpsertVideo(ctx, v2) require.NoError(t, err) require.NotNil(t, readDuration(), "a 0/unknown re-upsert must not erase a known duration") require.Equal(t, 750, *readDuration()) } 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 TestOnboardBurstVideoIDs(t *testing.T) { ctx := context.Background() s := newStore(t) resetDB(t, rawPool(t)) mk := func(user, pid string, day, dur int) string { v := ytVideo(user, pid, pid) v.PublishedAt = time.Date(2026, 6, day, 12, 0, 0, 0, time.UTC) v.DurationSeconds = dur // 0 == unknown (NULL) id, err := s.UpsertVideo(ctx, v) require.NoError(t, err) return id } good1 := mk(userA, "good0000001", 5, 600) // 10m, newest known-good tooLong := mk(userA, "toolong0001", 4, 20000) // > maxSeconds -> dropped _ = tooLong tooShort := mk(userA, "tooshort001", 3, 30) // < minSeconds -> dropped _ = tooShort unknown := mk(userA, "unknown0001", 2, 0) // NULL duration -> kept, ranked last good2 := mk(userA, "good0000002", 1, 800) // known-good but oldest mk(userB, "bvid0000009", 9, 600) // userB -> must not leak via RLS const minSec, maxSec = 60, 14400 // Known-good ranked before unknown, each newest-first within its group; the // too-long and too-short videos are excluded by their known duration. got, err := s.OnboardBurstVideoIDs(ctx, userA, 5, minSec, maxSec) require.NoError(t, err) require.Equal(t, []string{good1, good2, unknown}, got, "known-good first (newest-first), then unknown-duration; junk excluded") // Cap is honoured. capped, err := s.OnboardBurstVideoIDs(ctx, userA, 2, minSec, maxSec) require.NoError(t, err) require.Equal(t, []string{good1, good2}, capped) // Bounds disabled (0/0) == pure newest-first, nothing excluded. all, err := s.OnboardBurstVideoIDs(ctx, userA, 10, 0, 0) require.NoError(t, err) require.ElementsMatch(t, []string{good1, tooLong, tooShort, unknown, good2}, all, "0/0 bounds disable the duration filter (prior newest-first behaviour)") // limit <= 0 returns nothing. none, err := s.OnboardBurstVideoIDs(ctx, userA, 0, minSec, maxSec) require.NoError(t, err) require.Empty(t, none) } 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)") }