package store_test import ( "context" "fmt" "net" "os" "path/filepath" "testing" embeddedpostgres "github.com/fergusstrange/embedded-postgres" "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/require" "git.d-ma.be/mathias/tapir/internal/adapters/store" "git.d-ma.be/mathias/tapir/internal/domain" "git.d-ma.be/mathias/tapir/internal/ports" ) // Static check: Store satisfies the Sink port. var _ ports.Sink = (*store.Store)(nil) // dsn points at the in-process Postgres started in TestMain. Tests run against // real SQL (constraints, ON CONFLICT, jsonb) — not a mock — without docker or // live-cluster credentials (embedded-postgres downloads its own PG binary). var dsn string func TestMain(m *testing.M) { // Runtime/data dirs are per-process (PID-named) and the port is OS-assigned, // so concurrent `go test` runs on a shared CI host don't collide on either. // CachePath is shared so the PG archive is downloaded once, not per process. pg, port, rt, err := startTestPostgres(fmt.Sprintf("tapir-epg-store-%d", os.Getpid())) if err != nil { fmt.Fprintf(os.Stderr, "embedded-postgres start: %v\n", err) os.Exit(1) } dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port) code := m.Run() if err := pg.Stop(); err != nil { fmt.Fprintf(os.Stderr, "embedded-postgres stop: %v\n", err) } _ = os.RemoveAll(rt) os.Exit(code) } // freePort asks the OS for an unused TCP port. The port is released before // return, so another process can grab it first; startTestPostgres retries. func freePort() (uint32, error) { l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return 0, fmt.Errorf("listen for free port: %w", err) } defer func() { _ = l.Close() }() return uint32(l.Addr().(*net.TCPAddr).Port), nil } // startTestPostgres starts an embedded Postgres on an OS-assigned free port and // returns it with that port and its runtime dir (caller removes it). A port // taken between freePort and Start (shared CI host) is retried with a new one. func startTestPostgres(name string) (*embeddedpostgres.EmbeddedPostgres, uint32, string, error) { rt := filepath.Join(os.TempDir(), name) var lastErr error for range 3 { port, err := freePort() if err != nil { return nil, 0, rt, err } pg := embeddedpostgres.NewDatabase( embeddedpostgres.DefaultConfig(). Port(port). RuntimePath(rt). DataPath(filepath.Join(rt, "data")). BinariesPath(filepath.Join(rt, "bin")). CachePath(filepath.Join(os.TempDir(), "tapir-epg-cache")), ) if lastErr = pg.Start(); lastErr == nil { return pg, port, rt, nil } lastErr = fmt.Errorf("start on port %d: %w", port, lastErr) } return nil, 0, rt, lastErr } // uuids — fixed so tests are deterministic. user_id/video_id are UUID columns. const ( userA = "11111111-1111-1111-1111-111111111111" userB = "22222222-2222-2222-2222-222222222222" videoX = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" videoY = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" ) func newStore(t *testing.T) *store.Store { t.Helper() s, err := store.New(context.Background(), dsn) require.NoError(t, err, "migrations must apply clean and the pool must connect") t.Cleanup(s.Close) return s } // rawPool is a direct connection for test introspection (counts, truncation), // kept out of the production Store API. Closed via t.Cleanup. func rawPool(t *testing.T) *pgxpool.Pool { t.Helper() p, err := pgxpool.New(context.Background(), dsn) require.NoError(t, err) t.Cleanup(p.Close) return p } // resetDB truncates between tests so each starts from a known state. Schema is // shared across the run (migrations are idempotent via New). func resetDB(t *testing.T, p *pgxpool.Pool) { t.Helper() _, err := p.Exec(context.Background(), `TRUNCATE login_events, summary_actions, sink_deliveries, summaries, transcripts, videos, users CASCADE`) require.NoError(t, err) } func summary(userID, videoID, text string) domain.Summary { return domain.Summary{ UserID: userID, VideoID: videoID, Summary: text, Highlights: []string{"h1", "h2"}, Takeaways: []string{"t1"}, AIProvider: "local", AIModel: "qwen", } } func TestName(t *testing.T) { require.Equal(t, "store", newStore(t).Name()) } func TestDeliverInsertsSummary(t *testing.T) { ctx := context.Background() s := newStore(t) resetDB(t, rawPool(t)) require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "first"))) ok, err := s.HasSummary(ctx, userA, videoX) require.NoError(t, err) require.True(t, ok) } func TestDeliverIsIdempotentOnUserVideo(t *testing.T) { ctx := context.Background() s := newStore(t) p := rawPool(t) resetDB(t, p) require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "first"))) // Re-deliver the same (user, video): must update in place, not duplicate or error. require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "second"))) var count int require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM summaries WHERE user_id = $1 AND video_id = $2`, userA, videoX).Scan(&count)) require.Equal(t, 1, count, "second delivery must update, not duplicate") var text string require.NoError(t, p.QueryRow(ctx, `SELECT summary FROM summaries WHERE user_id = $1 AND video_id = $2`, userA, videoX).Scan(&text)) require.Equal(t, "second", text, "second delivery must overwrite the summary text") // Exactly one store-delivery row for the summary (ON CONFLICT update). var deliveries int require.NoError(t, p.QueryRow(ctx, `SELECT count(*) FROM sink_deliveries d JOIN summaries m ON m.id = d.summary_id WHERE m.user_id = $1 AND m.video_id = $2 AND d.sink = 'store'`, userA, videoX).Scan(&deliveries)) require.Equal(t, 1, deliveries) } func TestSeenVideoIDsReturnsUsersSet(t *testing.T) { ctx := context.Background() s := newStore(t) resetDB(t, rawPool(t)) require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "x"))) require.NoError(t, s.Deliver(ctx, summary(userA, videoY, "y"))) seen, err := s.SeenVideoIDs(ctx, userA) require.NoError(t, err) require.Equal(t, map[string]bool{videoX: true, videoY: true}, seen) } func TestPerUserIsolation(t *testing.T) { ctx := context.Background() s := newStore(t) resetDB(t, rawPool(t)) require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "a-owns-this"))) // User B must not see user A's video, by either dedup read. seenB, err := s.SeenVideoIDs(ctx, userB) require.NoError(t, err) require.Empty(t, seenB, "user B must not see user A's videos") hasB, err := s.HasSummary(ctx, userB, videoX) require.NoError(t, err) require.False(t, hasB, "the same video_id under another user must be invisible") hasA, err := s.HasSummary(ctx, userA, videoX) require.NoError(t, err) require.True(t, hasA) }