Files
tapir/internal/adapters/store/reads_test.go
T
mathiasandClaude Opus 4.8 c153ff35ce feat(store): add read methods for stored summaries
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>
2026-06-02 20:58:04 +02:00

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")
}