merge: CLI reader — tapir list/show (Worker E, agent/cli-reader)
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||
)
|
||||
|
||||
// Env var names for the read-only CLI. DSN and user id are never hardcoded — the
|
||||
// store holds confidential summaries, so the caller supplies both.
|
||||
const (
|
||||
envDSN = "TAPIR_DB_DSN"
|
||||
envUserID = "TAPIR_USER_ID"
|
||||
)
|
||||
|
||||
// openStoreFromEnv reads TAPIR_DB_DSN and TAPIR_USER_ID and opens a store. The
|
||||
// returned close func releases the pool; callers defer it. Both vars are
|
||||
// required: an empty value is a usage error, not a silent default.
|
||||
func openStoreFromEnv(ctx context.Context) (s *store.Store, userID string, closeFn func(), err error) {
|
||||
dsn := os.Getenv(envDSN)
|
||||
if dsn == "" {
|
||||
return nil, "", nil, fmt.Errorf("%s is required", envDSN)
|
||||
}
|
||||
userID = os.Getenv(envUserID)
|
||||
if userID == "" {
|
||||
return nil, "", nil, fmt.Errorf("%s is required", envUserID)
|
||||
}
|
||||
s, err = store.New(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
return s, userID, s.Close, nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||
)
|
||||
|
||||
func TestFormatListColumnsAndOrdering(t *testing.T) {
|
||||
rows := []store.SummaryRow{
|
||||
{
|
||||
VideoID: "vid-newer",
|
||||
Title: "Newer Title",
|
||||
Channel: "youtube",
|
||||
AIProvider: "anthropic",
|
||||
PublishedAt: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
VideoID: "vid-older",
|
||||
Title: "Older Title",
|
||||
Channel: "youtube",
|
||||
AIProvider: "local",
|
||||
FallbackUsed: true,
|
||||
PublishedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
require.NoError(t, formatList(&b, rows))
|
||||
out := b.String()
|
||||
|
||||
require.Contains(t, out, "DATE")
|
||||
require.Contains(t, out, "TITLE")
|
||||
require.Contains(t, out, "CHANNEL")
|
||||
require.Contains(t, out, "FALLBACK")
|
||||
require.Contains(t, out, "Newer Title")
|
||||
require.Contains(t, out, "2026-02-01")
|
||||
require.Contains(t, out, "anthropic")
|
||||
|
||||
// formatList preserves caller ordering (the store query sorts recent-first).
|
||||
require.Less(t, strings.Index(out, "Newer Title"), strings.Index(out, "Older Title"))
|
||||
|
||||
// fallback flag rendered per row.
|
||||
older := lineContaining(t, out, "Older Title")
|
||||
require.Contains(t, older, "yes")
|
||||
newer := lineContaining(t, out, "Newer Title")
|
||||
require.Contains(t, newer, "no")
|
||||
}
|
||||
|
||||
func TestFormatListFallsBackToVideoIDWhenNoTitle(t *testing.T) {
|
||||
rows := []store.SummaryRow{{VideoID: "raw-video-id", CreatedAt: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)}}
|
||||
|
||||
var b strings.Builder
|
||||
require.NoError(t, formatList(&b, rows))
|
||||
out := b.String()
|
||||
|
||||
require.Contains(t, out, "raw-video-id", "missing title falls back to video id")
|
||||
require.Contains(t, out, "2026-03-01", "missing publish date falls back to created_at")
|
||||
}
|
||||
|
||||
func TestFormatListEmpty(t *testing.T) {
|
||||
var b strings.Builder
|
||||
require.NoError(t, formatList(&b, nil))
|
||||
require.Contains(t, b.String(), "no summaries yet")
|
||||
}
|
||||
|
||||
func TestFormatShowFullSummary(t *testing.T) {
|
||||
row := store.SummaryRow{
|
||||
VideoID: "vid-1",
|
||||
Title: "Deep Dive",
|
||||
Channel: "youtube",
|
||||
URL: "https://example/watch",
|
||||
PublishedAt: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC),
|
||||
Summary: "The core argument.",
|
||||
Highlights: []string{"first point", "second point"},
|
||||
Takeaways: []string{"do this"},
|
||||
AIProvider: "local",
|
||||
AIModel: "qwen",
|
||||
FallbackUsed: true,
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
require.NoError(t, formatShow(&b, row))
|
||||
out := b.String()
|
||||
|
||||
require.Contains(t, out, "Deep Dive")
|
||||
require.Contains(t, out, "2026-05-02")
|
||||
require.Contains(t, out, "https://example/watch")
|
||||
require.Contains(t, out, "vid-1")
|
||||
require.Contains(t, out, "local/qwen")
|
||||
require.Contains(t, out, "fallback")
|
||||
require.Contains(t, out, "The core argument.")
|
||||
require.Contains(t, out, "first point")
|
||||
require.Contains(t, out, "second point")
|
||||
require.Contains(t, out, "do this")
|
||||
}
|
||||
|
||||
func TestFormatShowOmitsEmptySections(t *testing.T) {
|
||||
row := store.SummaryRow{
|
||||
VideoID: "vid-2",
|
||||
Summary: "Body only.",
|
||||
AIProvider: "local",
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
require.NoError(t, formatShow(&b, row))
|
||||
out := b.String()
|
||||
|
||||
require.Contains(t, out, "vid-2", "no title -> falls back to video id")
|
||||
require.Contains(t, out, "Body only.")
|
||||
require.NotContains(t, out, "Highlights:")
|
||||
require.NotContains(t, out, "Takeaways:")
|
||||
require.NotContains(t, out, "URL:")
|
||||
require.NotContains(t, out, "Channel:")
|
||||
}
|
||||
|
||||
func lineContaining(t *testing.T, text, needle string) string {
|
||||
t.Helper()
|
||||
for _, ln := range strings.Split(text, "\n") {
|
||||
if strings.Contains(ln, needle) {
|
||||
return ln
|
||||
}
|
||||
}
|
||||
t.Fatalf("no line containing %q in:\n%s", needle, text)
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||
)
|
||||
|
||||
// runList prints the user's stored summaries as a table, most recent first.
|
||||
// Read-only. Usage: tapir list [-limit N].
|
||||
func runList(ctx context.Context, args []string) error {
|
||||
fs := flag.NewFlagSet("list", flag.ContinueOnError)
|
||||
limit := fs.Int("limit", 50, "maximum number of summaries to show")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s, userID, closeFn, err := openStoreFromEnv(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeFn()
|
||||
|
||||
rows, err := s.ListSummaries(ctx, userID, *limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return formatList(os.Stdout, rows)
|
||||
}
|
||||
|
||||
// formatList renders rows as an aligned table. Pure: no DB, no env — so the
|
||||
// column layout and fallbacks are unit-testable without Postgres. Title falls
|
||||
// back to the video id and Channel to "-" when the videos row is absent.
|
||||
func formatList(w io.Writer, rows []store.SummaryRow) error {
|
||||
if len(rows) == 0 {
|
||||
_, err := fmt.Fprintln(w, "no summaries yet")
|
||||
return err
|
||||
}
|
||||
|
||||
// Writes to the tabwriter buffer; any error surfaces on Flush.
|
||||
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
||||
_, _ = fmt.Fprintln(tw, "DATE\tTITLE\tCHANNEL\tAI\tFALLBACK")
|
||||
for _, r := range rows {
|
||||
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
|
||||
listDate(r),
|
||||
truncate(displayTitle(r), 60),
|
||||
orDash(r.Channel),
|
||||
orDash(r.AIProvider),
|
||||
fallbackFlag(r.FallbackUsed),
|
||||
)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
// listDate prefers the video's publish date; absent that, when the summary was
|
||||
// created. Date only — the table is for scanning, not timestamps.
|
||||
func listDate(r store.SummaryRow) string {
|
||||
t := r.PublishedAt
|
||||
if t.IsZero() {
|
||||
t = r.CreatedAt
|
||||
}
|
||||
if t.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// displayTitle falls back to the video id when no title is stored, so a summary
|
||||
// produced before the run loop populated videos is still identifiable.
|
||||
func displayTitle(r store.SummaryRow) string {
|
||||
if strings.TrimSpace(r.Title) != "" {
|
||||
return r.Title
|
||||
}
|
||||
return r.VideoID
|
||||
}
|
||||
|
||||
func fallbackFlag(used bool) string {
|
||||
if used {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
|
||||
func orDash(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "-"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
if max <= 1 {
|
||||
return s[:max]
|
||||
}
|
||||
return s[:max-1] + "…"
|
||||
}
|
||||
+36
-5
@@ -1,10 +1,41 @@
|
||||
// Command tapir is the service entrypoint. Scaffold: it identifies itself so
|
||||
// the CI smoke test has something to grep for, and exits. Wiring the HTTP
|
||||
// server, watcher, adapters, and config is part of the build.
|
||||
// Command tapir is the service entrypoint and CLI. Subcommands are dispatched off
|
||||
// os.Args[1]; each lives in its own file (list.go, show.go, …). The switch is kept
|
||||
// deliberately flat so concurrently-added subcommands union cleanly.
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("tapir: scaffold — not yet implemented")
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
var err error
|
||||
switch os.Args[1] {
|
||||
case "list":
|
||||
err = runList(ctx, os.Args[2:])
|
||||
case "show":
|
||||
err = runShow(ctx, os.Args[2:])
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "tapir:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "usage: tapir <command> [args]")
|
||||
fmt.Fprintln(os.Stderr, "commands:")
|
||||
fmt.Fprintln(os.Stderr, " list [-limit N] list stored summaries, recent first")
|
||||
fmt.Fprintln(os.Stderr, " show <video-id> show one summary in full")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||
)
|
||||
|
||||
// runShow prints the full summary for one video: text, highlights, takeaways,
|
||||
// and provenance. Read-only. Usage: tapir show <video-id>.
|
||||
func runShow(ctx context.Context, args []string) error {
|
||||
if len(args) != 1 || strings.TrimSpace(args[0]) == "" {
|
||||
return errors.New("usage: tapir show <video-id>")
|
||||
}
|
||||
videoID := args[0]
|
||||
|
||||
s, userID, closeFn, err := openStoreFromEnv(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeFn()
|
||||
|
||||
row, err := s.GetSummaryByVideo(ctx, userID, videoID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return fmt.Errorf("no summary for video %q", videoID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return formatShow(os.Stdout, *row)
|
||||
}
|
||||
|
||||
// formatShow renders a single summary for terminal reading. Pure: no DB, no env,
|
||||
// so it is unit-testable. Empty sections are omitted rather than printed blank.
|
||||
func formatShow(w io.Writer, r store.SummaryRow) error {
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, "%s\n", displayTitle(r))
|
||||
if d := listDate(r); d != "-" {
|
||||
fmt.Fprintf(&b, "Date: %s\n", d)
|
||||
}
|
||||
if strings.TrimSpace(r.Channel) != "" {
|
||||
fmt.Fprintf(&b, "Channel: %s\n", r.Channel)
|
||||
}
|
||||
if strings.TrimSpace(r.URL) != "" {
|
||||
fmt.Fprintf(&b, "URL: %s\n", r.URL)
|
||||
}
|
||||
fmt.Fprintf(&b, "Video ID: %s\n", r.VideoID)
|
||||
fmt.Fprintf(&b, "AI: %s\n", aiProvenance(r))
|
||||
|
||||
fmt.Fprintf(&b, "\n%s\n", strings.TrimSpace(r.Summary))
|
||||
|
||||
writeList(&b, "Highlights", r.Highlights)
|
||||
writeList(&b, "Takeaways", r.Takeaways)
|
||||
|
||||
_, err := io.WriteString(w, b.String())
|
||||
return err
|
||||
}
|
||||
|
||||
// aiProvenance summarises which model produced the summary and whether the local
|
||||
// stack fell back to a cloud provider — the Stage-0 "is local good enough?" signal.
|
||||
func aiProvenance(r store.SummaryRow) string {
|
||||
parts := orDash(r.AIProvider)
|
||||
if strings.TrimSpace(r.AIModel) != "" {
|
||||
parts += "/" + r.AIModel
|
||||
}
|
||||
if r.FallbackUsed {
|
||||
parts += " (fallback)"
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func writeList(b *strings.Builder, heading string, items []string) {
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(b, "\n%s:\n", heading)
|
||||
for _, it := range items {
|
||||
fmt.Fprintf(b, " - %s\n", it)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned by GetSummaryByVideo when no summary exists for the
|
||||
// (userID, videoID) pair scoped to that user.
|
||||
var ErrNotFound = errors.New("store: summary not found")
|
||||
|
||||
// SummaryRow is a read-side view of a stored summary, enriched with the video's
|
||||
// metadata when a matching videos row exists. It is deliberately separate from
|
||||
// domain.Summary: it carries display fields (Title, Channel, URL, PublishedAt)
|
||||
// that the engine never sees, sourced via a LEFT JOIN so a summary with no
|
||||
// videos row still renders (Title/URL empty, PublishedAt zero).
|
||||
//
|
||||
// Channel currently mirrors the video provider ("youtube"): the per-channel
|
||||
// channel_title lives on the subscriptions table (docs/data-model.md), which is
|
||||
// not part of the Stage-0 store slice yet. When that table is migrated, swap the
|
||||
// JOIN source — callers already fall back gracefully on an empty Channel.
|
||||
type SummaryRow struct {
|
||||
VideoID string
|
||||
Title string // videos.title; empty when no videos row
|
||||
Channel string // videos.provider for now; empty when no videos row
|
||||
URL string // videos.url; empty when no videos row
|
||||
PublishedAt time.Time // videos.published_at; zero when absent
|
||||
Summary string
|
||||
Highlights []string
|
||||
Takeaways []string
|
||||
AIProvider string
|
||||
AIModel string
|
||||
FallbackUsed bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// selectSummary is the shared projection for both reads. videos is LEFT JOINed
|
||||
// on its UUID id (= summaries.video_id) and the same user_id, so the join never
|
||||
// crosses users and a missing videos row yields nulls, not a dropped summary.
|
||||
const selectSummary = `
|
||||
SELECT s.video_id,
|
||||
COALESCE(v.title, ''),
|
||||
COALESCE(v.provider, ''),
|
||||
COALESCE(v.url, ''),
|
||||
v.published_at,
|
||||
s.summary,
|
||||
s.highlights,
|
||||
s.takeaways,
|
||||
COALESCE(s.ai_provider, ''),
|
||||
COALESCE(s.ai_model, ''),
|
||||
s.fallback_used,
|
||||
s.created_at
|
||||
FROM summaries s
|
||||
LEFT JOIN videos v ON v.id = s.video_id AND v.user_id = s.user_id`
|
||||
|
||||
// ListSummaries returns the user's summaries, most recent first, capped at limit.
|
||||
// A non-positive limit defaults to 50. Scoped by user_id: one user never sees
|
||||
// another's summaries (per-user isolation, docs/data-model.md).
|
||||
func (s *Store) ListSummaries(ctx context.Context, userID string, limit int) ([]SummaryRow, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.pool.Query(ctx,
|
||||
selectSummary+`
|
||||
WHERE s.user_id = $1
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT $2`,
|
||||
userID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list summaries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []SummaryRow
|
||||
for rows.Next() {
|
||||
row, err := scanSummaryRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: iterate summaries: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetSummaryByVideo returns the full summary for (userID, videoID), including
|
||||
// highlights and takeaways. Returns ErrNotFound when the user has no such
|
||||
// summary. Scoped by user_id.
|
||||
func (s *Store) GetSummaryByVideo(ctx context.Context, userID, videoID string) (*SummaryRow, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
selectSummary+`
|
||||
WHERE s.user_id = $1 AND s.video_id = $2`,
|
||||
userID, videoID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: get summary: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if !rows.Next() {
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: get summary: %w", err)
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
row, err := scanSummaryRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// scanSummaryRow reads one row in the selectSummary column order. published_at is
|
||||
// nullable (no videos row, or an unset date) so it scans through a pointer.
|
||||
func scanSummaryRow(rows pgx.Row) (SummaryRow, error) {
|
||||
var (
|
||||
row SummaryRow
|
||||
highlights []byte
|
||||
takeaways []byte
|
||||
publishedAt *time.Time
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&row.VideoID,
|
||||
&row.Title,
|
||||
&row.Channel,
|
||||
&row.URL,
|
||||
&publishedAt,
|
||||
&row.Summary,
|
||||
&highlights,
|
||||
&takeaways,
|
||||
&row.AIProvider,
|
||||
&row.AIModel,
|
||||
&row.FallbackUsed,
|
||||
&row.CreatedAt,
|
||||
); err != nil {
|
||||
return SummaryRow{}, fmt.Errorf("store: scan summary: %w", err)
|
||||
}
|
||||
if publishedAt != nil {
|
||||
row.PublishedAt = *publishedAt
|
||||
}
|
||||
var err error
|
||||
if row.Highlights, err = unmarshalList(highlights); err != nil {
|
||||
return SummaryRow{}, fmt.Errorf("store: unmarshal highlights: %w", err)
|
||||
}
|
||||
if row.Takeaways, err = unmarshalList(takeaways); err != nil {
|
||||
return SummaryRow{}, fmt.Errorf("store: unmarshal takeaways: %w", err)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// unmarshalList decodes a jsonb array column into a string slice, mirroring
|
||||
// marshalList in store.go. Empty/NULL bytes decode to a nil slice.
|
||||
func unmarshalList(b []byte) ([]string, error) {
|
||||
if len(b) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var xs []string
|
||||
if err := json.Unmarshal(b, &xs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return xs, nil
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user