ListSummaries (recent-first, user-scoped, limit) and GetSummaryByVideo LEFT JOIN videos for title/url/published_at, null-safe when no videos row exists. Channel mirrors provider for now — channel_title lives on the not-yet-migrated subscriptions table (data-model.md). New file so it does not collide with Worker F's concurrent edits to store.go. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
149 lines
4.9 KiB
Go
149 lines
4.9 KiB
Go
package store_test
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
|
)
|
|
|
|
// seedVideo inserts a videos row whose id matches a summary's video_id, so the
|
|
// read-side LEFT JOIN has metadata to attach. published_at may be the zero time
|
|
// to exercise the NULL path.
|
|
func seedVideo(t *testing.T, p *pgxpool.Pool, userID, videoID, title, provider, url string, published time.Time) {
|
|
t.Helper()
|
|
var pub any
|
|
if !published.IsZero() {
|
|
pub = published
|
|
}
|
|
_, err := p.Exec(context.Background(),
|
|
`INSERT INTO videos (id, user_id, provider, provider_video_id, title, url, published_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
videoID, userID, provider, "pv-"+videoID[:8], title, url, pub)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// setCreatedAt forces a summary's created_at so ordering is deterministic.
|
|
func setCreatedAt(t *testing.T, p *pgxpool.Pool, userID, videoID string, at time.Time) {
|
|
t.Helper()
|
|
_, err := p.Exec(context.Background(),
|
|
`UPDATE summaries SET created_at = $3 WHERE user_id = $1 AND video_id = $2`,
|
|
userID, videoID, at)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestListSummariesRecentFirstWithVideoMetadata(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newStore(t)
|
|
p := rawPool(t)
|
|
resetDB(t, p)
|
|
|
|
require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "older")))
|
|
require.NoError(t, s.Deliver(ctx, summary(userA, videoY, "newer")))
|
|
seedVideo(t, p, userA, videoX, "X Title", "youtube", "https://x", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
|
|
seedVideo(t, p, userA, videoY, "Y Title", "youtube", "https://y", time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC))
|
|
// Force ordering: videoY is the most recent.
|
|
setCreatedAt(t, p, userA, videoX, time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC))
|
|
setCreatedAt(t, p, userA, videoY, time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC))
|
|
|
|
rows, err := s.ListSummaries(ctx, userA, 50)
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 2)
|
|
|
|
require.Equal(t, videoY, rows[0].VideoID, "most recent created_at first")
|
|
require.Equal(t, "Y Title", rows[0].Title)
|
|
require.Equal(t, "youtube", rows[0].Channel)
|
|
require.Equal(t, "https://y", rows[0].URL)
|
|
require.Equal(t, 2026, rows[0].PublishedAt.Year())
|
|
|
|
require.Equal(t, videoX, rows[1].VideoID)
|
|
require.Equal(t, "X Title", rows[1].Title)
|
|
}
|
|
|
|
func TestListSummariesWithoutVideoRowIsNullSafe(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newStore(t)
|
|
resetDB(t, rawPool(t))
|
|
|
|
// Summary exists but no matching videos row (run loop hasn't populated it).
|
|
require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "orphan")))
|
|
|
|
rows, err := s.ListSummaries(ctx, userA, 50)
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
require.Empty(t, rows[0].Title, "no videos row -> empty title (caller falls back to id)")
|
|
require.Empty(t, rows[0].Channel)
|
|
require.Empty(t, rows[0].URL)
|
|
require.True(t, rows[0].PublishedAt.IsZero(), "absent published_at -> zero time")
|
|
require.Equal(t, "orphan", rows[0].Summary)
|
|
}
|
|
|
|
func TestListSummariesRespectsLimit(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newStore(t)
|
|
p := rawPool(t)
|
|
resetDB(t, p)
|
|
|
|
require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "x")))
|
|
require.NoError(t, s.Deliver(ctx, summary(userA, videoY, "y")))
|
|
setCreatedAt(t, p, userA, videoX, time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC))
|
|
setCreatedAt(t, p, userA, videoY, time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC))
|
|
|
|
rows, err := s.ListSummaries(ctx, userA, 1)
|
|
require.NoError(t, err)
|
|
require.Len(t, rows, 1)
|
|
require.Equal(t, videoY, rows[0].VideoID, "limit keeps the most recent")
|
|
}
|
|
|
|
func TestListSummariesIsUserScoped(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newStore(t)
|
|
resetDB(t, rawPool(t))
|
|
|
|
require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "a-only")))
|
|
|
|
rows, err := s.ListSummaries(ctx, userB, 50)
|
|
require.NoError(t, err)
|
|
require.Empty(t, rows, "user B must not see user A's summaries")
|
|
}
|
|
|
|
func TestGetSummaryByVideoReturnsFullRow(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newStore(t)
|
|
resetDB(t, rawPool(t))
|
|
|
|
require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "full body")))
|
|
|
|
row, err := s.GetSummaryByVideo(ctx, userA, videoX)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "full body", row.Summary)
|
|
require.Equal(t, []string{"h1", "h2"}, row.Highlights)
|
|
require.Equal(t, []string{"t1"}, row.Takeaways)
|
|
require.Equal(t, "local", row.AIProvider)
|
|
require.Equal(t, "qwen", row.AIModel)
|
|
}
|
|
|
|
func TestGetSummaryByVideoNotFound(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newStore(t)
|
|
resetDB(t, rawPool(t))
|
|
|
|
_, err := s.GetSummaryByVideo(ctx, userA, videoX)
|
|
require.ErrorIs(t, err, store.ErrNotFound)
|
|
}
|
|
|
|
func TestGetSummaryByVideoIsUserScoped(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newStore(t)
|
|
resetDB(t, rawPool(t))
|
|
|
|
require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "a-owns")))
|
|
|
|
_, err := s.GetSummaryByVideo(ctx, userB, videoX)
|
|
require.ErrorIs(t, err, store.ErrNotFound, "the same video under another user is invisible")
|
|
}
|