Add the lane-C reader surface: list, detail, and an action button-group
fragment over the lane-A store reads/actions, behind the web.Auth seam.
- Templ components (base layout, list+filters, detail, ActionButtons) with
committed *_templ.go so go build/task check work without the templ binary;
`task generate` regenerates. Filters and action toggles are HTMX-swapped and
degrade to plain form GET/POST (POST→303→GET) without JS.
- Handlers (internal/web): GET / (channel+date filters, in-memory),
GET /v/{videoId}, POST /v/{videoId}/action (re-click clears, else SetAction;
store enforces watched↔skipped exclusion), GET /healthz (no auth). Store ops
run as the configured UserID; Auth only gates.
- `tapir serve` wires store + StubAuth{Subject: cfg.UserID} + http.Server on
TAPIR_HTTP_ADDR (default :8080), graceful shutdown on signal. Handlers depend
only on web.Auth — Conductor swaps StubAuth → oidc.DexAuth at merge (one line
in cmdServe).
- Handler tests: real store (embedded-postgres) + StubAuth — list rows+state,
HTMX fragment vs full page, channel filter, detail highlights/takeaways,
404, action toggle+clear, no-JS redirect, bad-verb 400.
New dep: github.com/a-h/templ — the house default for typed server-rendered
HTML (CLAUDE.md stack, ui-spec.md §3). Generated code is committed so the
templ binary is build-time-optional.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
278 lines
8.6 KiB
Go
278 lines
8.6 KiB
Go
package web_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"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) {
|
|
const port = 54330 // distinct from the store package's embedded PG (54329)
|
|
dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port)
|
|
|
|
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().Port(port))
|
|
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.Exit(code)
|
|
}
|
|
|
|
const (
|
|
userID = "11111111-1111-1111-1111-111111111111"
|
|
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)
|
|
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
|
|
}
|
|
|
|
func resetDB(t *testing.T, p *pgxpool.Pool) {
|
|
t.Helper()
|
|
_, err := p.Exec(context.Background(),
|
|
`TRUNCATE summary_actions, sink_deliveries, summaries, transcripts, videos, users CASCADE`)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// newApp builds the App under test: the real store, StubAuth (allow-all) keyed to
|
|
// the configured user. This is exactly cmd/tapir's serve wiring minus Dex.
|
|
func newApp(t *testing.T) *web.App {
|
|
t.Helper()
|
|
return &web.App{
|
|
Store: newStore(t),
|
|
Auth: web.StubAuth{U: web.User{Subject: userID}},
|
|
UserID: userID,
|
|
}
|
|
}
|
|
|
|
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, "—", "missing published date renders an em dash")
|
|
}
|
|
|
|
func TestListHTMXReturnsFragment(t *testing.T) {
|
|
ctx := context.Background()
|
|
app := newApp(t)
|
|
resetDB(t, rawPool(t))
|
|
require.NoError(t, deliver(ctx, app, videoX, "body x"))
|
|
|
|
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, "<table")
|
|
require.NotContains(t, html, "<html", "HTMX request gets only the table 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{})
|
|
|
|
// Channel is "youtube" for seeded rows; a non-matching filter hides them.
|
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/?channel=vimeo", 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=youtube", 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")
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|