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>
This commit is contained in:
2026-06-02 20:58:04 +02:00
co-authored by Claude Opus 4.8
parent 2695b5d91e
commit c153ff35ce
2 changed files with 316 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
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")
}