Files
tapir/internal/web/handlers_test.go
T
mathiasandClaude Opus 4.8 fe56e2fe01
CI / Lint / Test / Vet (push) Successful in 9s
CI / Build & Import (push) Successful in 10s
test: make embedded-postgres per-process so concurrent CI runs don't collide
A push to main and its version tag fire two CI runs for the same commit. Both ran
`go test ./...`, which starts embedded-postgres on a FIXED port (54329/54330) and
a shared data dir. -p 1 serialises packages WITHIN a run, not across two
concurrent runs — so when the two runs overlapped they collided on the port/data
dir and BOTH failed the Lint/Test job (no image built). Prior commits passed only
because their two runs happened not to overlap.

Derive the port and runtime/data dirs from the PID; share only CachePath so the
PG archive downloads once. Proven: two concurrent `go test` of the store package
now both pass. Unblocks the v0.21.0 (Pillar A) build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 07:26:33 +02:00

527 lines
19 KiB
Go

package web_test
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/domain"
"gitea.d-ma.be/mathias/tapir/internal/web"
)
// dsn points at the in-process Postgres started in TestMain. Handler tests run
// against the real store (real SQL, the lane-A actions) behind StubAuth — the
// full read/write path minus live Dex.
var dsn string
func TestMain(m *testing.M) {
// Per-process port + dirs so concurrent `go test` runs (e.g. a push-run and a
// tag-run in CI) never collide on a fixed port or shared data dir. Base 55000
// keeps web's range distinct from the store package (54000). Shared CachePath
// downloads the PG archive once.
port := uint32(55000 + os.Getpid()%1000)
dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port)
rt := filepath.Join(os.TempDir(), fmt.Sprintf("tapir-epg-web-%d", os.Getpid()))
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 err := pg.Start(); err != nil {
fmt.Fprintf(os.Stderr, "embedded-postgres start: %v\n", err)
os.Exit(1)
}
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)
}
const (
userID = "11111111-1111-1111-1111-111111111111"
videoX = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
videoY = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
// stubSubject is the StubAuth Dex subject the registration gate resolves to
// the fixed userID (mapping seeded by resetDB).
stubSubject = "stub-subject-xyz"
)
func newStore(t *testing.T) *store.Store {
t.Helper()
s, err := store.New(context.Background(), dsn)
require.NoError(t, err)
t.Cleanup(s.Close)
return s
}
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
}
// truncateAll wipes every table to a pristine state (user_identities is cleared
// via the ON DELETE CASCADE from users). Registration tests use this directly so
// no subject is pre-registered.
func truncateAll(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)
}
// resetDB truncates, then seeds the StubAuth identity (stubSubject → userID) so
// the registration gate resolves the stub user and the existing handler tests can
// keep seeding and scoping by the fixed userID.
func resetDB(t *testing.T, p *pgxpool.Pool) {
t.Helper()
truncateAll(t, p)
ctx := context.Background()
_, err := p.Exec(ctx, `INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, userID)
require.NoError(t, err)
_, err = p.Exec(ctx,
`INSERT INTO user_identities (dex_subject, user_id) VALUES ($1, $2)
ON CONFLICT (dex_subject) DO NOTHING`, stubSubject, userID)
require.NoError(t, err)
}
// newApp builds the App under test as the registered stub user (subject
// stubSubject, resolved to userID by resetDB). This is cmd/tapir's serve wiring
// minus Dex: the store is both the Store and the Identity port.
func newApp(t *testing.T) *web.App {
t.Helper()
return newAppAs(t, stubSubject)
}
// newAppAs builds the App under test with a specific StubAuth Dex subject, so
// registration-gate tests can drive registered vs unregistered subjects.
func newAppAs(t *testing.T, subject string) *web.App {
t.Helper()
s := newStore(t)
return &web.App{
Store: s,
Identity: s,
Auth: web.StubAuth{U: web.User{Subject: subject}},
}
}
func summary(videoID, text string) domain.Summary {
return domain.Summary{
UserID: userID,
VideoID: videoID,
Summary: text,
Highlights: []string{"highlight one", "highlight two"},
Takeaways: []string{"takeaway one"},
AIProvider: "local",
AIModel: "phi4-mini",
}
}
func seedVideo(t *testing.T, p *pgxpool.Pool, videoID, title, 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, 'youtube', $3, $4, $5, $6)`,
videoID, userID, "pv-"+videoID[:8], title, url, pub)
require.NoError(t, err)
}
func do(t *testing.T, app *web.App, req *http.Request) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
app.Router().ServeHTTP(rec, req)
return rec
}
func body(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
b, err := io.ReadAll(rec.Body)
require.NoError(t, err)
return string(b)
}
func TestHealthzNoAuth(t *testing.T) {
app := newApp(t)
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/healthz", nil))
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "ok", body(t, rec))
}
func TestListRendersRowsAndActionState(t *testing.T) {
ctx := context.Background()
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
require.NoError(t, app.Store.SetAction(ctx, userID, videoX, "watched")) // action exists pre-summary
require.NoError(t, deliver(ctx, app, videoX, "body x"))
require.NoError(t, deliver(ctx, app, videoY, "body y"))
seedVideo(t, p, videoX, "X Title", "https://x", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
seedVideo(t, p, videoY, "Y Title", "https://y", time.Time{})
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, "<html", "full page on a non-HTMX request")
require.Contains(t, html, "X Title")
require.Contains(t, html, "Y Title")
require.Contains(t, html, "youtube")
require.Contains(t, html, "watched", "current action state shown in the list")
require.Contains(t, html, "2026-01-01")
require.Contains(t, html, `class="card"`, "rows render as cards, not a table")
}
func TestListHTMXReturnsFragment(t *testing.T) {
ctx := context.Background()
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
require.NoError(t, deliver(ctx, app, videoX, "body x"))
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("HX-Request", "true")
rec := do(t, app, req)
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, `class="cards"`)
require.NotContains(t, html, "<html", "HTMX request gets only the list fragment")
}
func TestListChannelFilter(t *testing.T) {
ctx := context.Background()
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
require.NoError(t, deliver(ctx, app, videoX, "body x"))
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
_, err := p.Exec(ctx, `UPDATE videos SET channel_title = 'Acme Channel' WHERE id = $1`, videoX)
require.NoError(t, err)
// Selecting a different channel hides the row; selecting its channel shows it.
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/?channel=Other+Channel", nil))
require.Equal(t, http.StatusOK, rec.Code)
require.NotContains(t, body(t, rec), "X Title")
rec = do(t, app, httptest.NewRequest(http.MethodGet, "/?channel=Acme+Channel", nil))
require.Contains(t, body(t, rec), "X Title")
}
func TestDetailRendersHighlightsAndTakeaways(t *testing.T) {
ctx := context.Background()
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
require.NoError(t, deliver(ctx, app, videoX, "the full summary body"))
seedVideo(t, p, videoX, "X Title", "https://x", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX, nil))
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, "the full summary body")
require.Contains(t, html, "highlight one")
require.Contains(t, html, "takeaway one")
require.Contains(t, html, `id="action-buttons"`, "action button group present")
require.Contains(t, html, "Watched")
require.Contains(t, html, `class="segmented"`, "watched/skipped render as a segmented control (UX review C5)")
require.Less(t, strings.Index(html, "Skipped"), strings.Index(html, "Saved"),
"Saved sits after the watched/skipped segment")
require.Contains(t, html, "← Summaries", "back link to the list (UX review C3)")
// The detail page leads with the attention-saving payload (UX review A8):
// Takeaways ("is it worth my time?") above Highlights above the full Summary.
takeaways := strings.Index(html, "takeaway one")
highlights := strings.Index(html, "highlight one")
summary := strings.Index(html, "the full summary body")
require.Less(t, takeaways, highlights, "Takeaways render before Highlights")
require.Less(t, highlights, summary, "Highlights render before the full Summary")
}
func TestDetailNotFound(t *testing.T) {
app := newApp(t)
resetDB(t, rawPool(t))
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoX, nil))
require.Equal(t, http.StatusNotFound, rec.Code)
}
func TestActionToggleReturnsFragment(t *testing.T) {
ctx := context.Background()
app := newApp(t)
resetDB(t, rawPool(t))
require.NoError(t, deliver(ctx, app, videoX, "body x"))
// First click: sets "watched", returns the fragment with it active.
rec := postAction(t, app, videoX, "watched", true)
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, `id="action-buttons"`)
require.Contains(t, html, "✓ Watched", "active verb marked")
got, err := app.Store.ActionsFor(ctx, userID, []string{videoX})
require.NoError(t, err)
require.Equal(t, map[string][]string{videoX: {"watched"}}, got)
// Second click on the same verb: clears it.
rec = postAction(t, app, videoX, "watched", true)
require.Equal(t, http.StatusOK, rec.Code)
require.NotContains(t, body(t, rec), "✓ Watched", "re-click cleared the action")
got, err = app.Store.ActionsFor(ctx, userID, []string{videoX})
require.NoError(t, err)
require.Empty(t, got, "action cleared in the store")
}
func TestActionNonHTMXRedirects(t *testing.T) {
ctx := context.Background()
app := newApp(t)
resetDB(t, rawPool(t))
require.NoError(t, deliver(ctx, app, videoX, "body x"))
rec := postAction(t, app, videoX, "saved", false)
require.Equal(t, http.StatusSeeOther, rec.Code)
require.Equal(t, "/v/"+videoX, rec.Header().Get("Location"))
got, err := app.Store.ActionsFor(ctx, userID, []string{videoX})
require.NoError(t, err)
require.Equal(t, map[string][]string{videoX: {"saved"}}, got, "action persisted on the no-JS path")
}
func TestActionRejectsUnknownVerb(t *testing.T) {
app := newApp(t)
resetDB(t, rawPool(t))
rec := postAction(t, app, videoX, "bookmarked", true)
require.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestListShowsSummarizeButtonForUnsummarized(t *testing.T) {
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
// A discovered-but-unsummarized video (no summary delivered).
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, "Pending Title", "unsummarized videos are listed too")
require.Contains(t, html, "Summarize", "a Summarize button is offered")
require.Contains(t, html, "/v/"+videoX+"/summarize", "button posts to the queue endpoint")
require.Contains(t, html, "card-pending", "muted pending treatment")
require.NotContains(t, html, "Queued", "not queued yet")
}
// TestListCollapsesOlderAndNoCaption verifies the feed IA (UX review B3/B4):
// summarized + recent un-summarized cards lead inline; older un-summarized
// videos collapse into a single disclosure; caption-less videos collapse into a
// one-line count instead of dead cards.
func TestListCollapsesOlderAndNoCaption(t *testing.T) {
ctx := context.Background()
app := newApp(t)
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
app.RecencyWindow = 7 * 24 * time.Hour
app.Now = func() time.Time { return now }
p := rawPool(t)
resetDB(t, p)
const (
vRecent = "cccccccc-cccc-cccc-cccc-cccccccccccc" // 2d old, unsummarized → inline
vOld = "dddddddd-dddd-dddd-dddd-dddddddddddd" // 30d old, unsummarized → disclosure
vNoCap = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" // caption-less → collapsed line
)
require.NoError(t, deliver(ctx, app, videoX, "body x")) // summarized, recent
seedVideo(t, p, videoX, "Summarized X", "https://x", now.Add(-24*time.Hour))
seedVideo(t, p, vRecent, "Recent Pending", "https://r", now.Add(-2*24*time.Hour))
seedVideo(t, p, vOld, "Old Pending", "https://o", now.Add(-30*24*time.Hour))
seedVideo(t, p, vNoCap, "No Caption Vid", "https://n", now.Add(-40*24*time.Hour))
_, err := p.Exec(ctx, `UPDATE videos SET transcript_status = 'none' WHERE id = $1`, vNoCap)
require.NoError(t, err)
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
disclosure := strings.Index(html, "Show 1 older videos")
require.GreaterOrEqual(t, disclosure, 0, "older-videos disclosure present")
// Summarized + recent un-summarized lead inline, above the disclosure.
require.Less(t, strings.Index(html, "Summarized X"), disclosure, "summarized card is inline")
require.Less(t, strings.Index(html, "Recent Pending"), disclosure, "recent pending is inline")
// The older video is hidden inside the disclosure, after its summary.
require.Greater(t, strings.Index(html, "Old Pending"), disclosure, "older video lives in the disclosure")
// Caption-less video is a one-line count, never a card.
require.Contains(t, html, "have no captions")
require.NotContains(t, html, "No Caption Vid", "caption-less video is collapsed, not a card")
}
// TestListHidesFilterBarWhenEmpty: a genuinely empty list shows no filter bar
// (the connect CTA stands alone), but a filter that matches nothing still shows
// the bar so it can be cleared (UX review C1).
func TestListHidesFilterBarWhenEmpty(t *testing.T) {
app := newApp(t)
resetDB(t, rawPool(t))
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/", nil))
require.Equal(t, http.StatusOK, rec.Code)
require.NotContains(t, body(t, rec), `class="filters"`, "no filter bar on an empty account")
rec = do(t, app, httptest.NewRequest(http.MethodGet, "/?channel=nope", nil))
require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, body(t, rec), `class="filters"`, "filtered-to-empty keeps the bar so it can be cleared")
}
func TestRequestSummarizeQueuesAndRendersCard(t *testing.T) {
ctx := context.Background()
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
rec := postSummarize(t, app, videoX, true)
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, "Queued", "card now shows the queued state")
require.NotContains(t, html, ">Summarize<", "the Summarize button is gone once queued")
// The flag is persisted, so the next run picks it up.
row, err := app.Store.GetVideoRow(ctx, userID, videoX)
require.NoError(t, err)
require.True(t, row.SummarizeRequested)
}
func TestRequestSummarizeNonHTMXRedirects(t *testing.T) {
ctx := context.Background()
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{})
rec := postSummarize(t, app, videoX, false)
require.Equal(t, http.StatusSeeOther, rec.Code)
require.Equal(t, "/", rec.Header().Get("Location"))
row, err := app.Store.GetVideoRow(ctx, userID, videoX)
require.NoError(t, err)
require.True(t, row.SummarizeRequested, "queued on the no-JS path too")
}
func TestRequestSummarizeNotFound(t *testing.T) {
app := newApp(t)
resetDB(t, rawPool(t))
rec := postSummarize(t, app, videoX, true)
require.Equal(t, http.StatusNotFound, rec.Code, "queuing an unknown video is a 404")
}
func TestSummarizeModeToggle(t *testing.T) {
ctx := context.Background()
app := newApp(t)
resetDB(t, rawPool(t))
// Account page defaults to automatic (ADR-018: onboarded users get
// zero-friction discovery — the list fills and summarizes itself).
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/account", nil))
require.Equal(t, http.StatusOK, rec.Code)
html := body(t, rec)
require.Contains(t, html, "Automatic", "default mode shown")
require.Contains(t, html, "Switch to manual")
// Toggle to manual via HTMX returns the refreshed control.
req := httptest.NewRequest(http.MethodPost, "/account/summarize-mode",
strings.NewReader("enabled=false"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
rec = do(t, app, req)
require.Equal(t, http.StatusOK, rec.Code)
html = body(t, rec)
require.Contains(t, html, "Manual")
require.Contains(t, html, "Switch to automatic")
got, err := app.Store.GetAutoSummarize(ctx, userID)
require.NoError(t, err)
require.False(t, got, "mode persisted")
}
func postSummarize(t *testing.T, app *web.App, videoID string, htmx bool) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/v/"+videoID+"/summarize", nil)
if htmx {
req.Header.Set("HX-Request", "true")
}
return do(t, app, req)
}
// deliver stores a summary through the App's store under test.
func deliver(ctx context.Context, app *web.App, videoID, text string) error {
return app.Store.(*store.Store).Deliver(ctx, summary(videoID, text))
}
func postAction(t *testing.T, app *web.App, videoID, action string, htmx bool) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/v/"+videoID+"/action",
strings.NewReader("action="+action))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if htmx {
req.Header.Set("HX-Request", "true")
}
return do(t, app, req)
}
// TestListManualModeBannerCopy: a manual-mode user with un-summarized videos
// sees the manual prompt (click Summarize), NOT the "summaries land
// automatically" copy that misled the first pilot user into waiting forever.
func TestListManualModeBannerCopy(t *testing.T) {
ctx := context.Background()
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{}) // pending, un-summarized
require.NoError(t, app.Store.SetAutoSummarize(ctx, userID, false))
html := body(t, do(t, app, httptest.NewRequest(http.MethodGet, "/", nil)))
require.Contains(t, html, "Manual mode")
require.Contains(t, html, "are not summarized automatically")
require.NotContains(t, html, "land gradually",
"manual-mode user must not be told summaries arrive automatically")
}
// TestListAutoModeBannerCopy: an auto-mode user with a backlog sees the
// gradual-delivery copy, not the manual prompt.
func TestListAutoModeBannerCopy(t *testing.T) {
ctx := context.Background()
app := newApp(t)
p := rawPool(t)
resetDB(t, p)
seedVideo(t, p, videoX, "X Title", "https://x", time.Time{})
require.NoError(t, app.Store.SetAutoSummarize(ctx, userID, true))
html := body(t, do(t, app, httptest.NewRequest(http.MethodGet, "/", nil)))
require.Contains(t, html, "land gradually")
require.NotContains(t, html, "are not summarized automatically")
}