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" // 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, "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 manual. 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, "Manual", "default mode shown") require.Contains(t, html, "Switch to automatic") // Toggle to automatic via HTMX returns the refreshed control. req := httptest.NewRequest(http.MethodPost, "/account/summarize-mode", strings.NewReader("enabled=true")) 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, "Automatic") require.Contains(t, html, "Switch to manual") got, err := app.Store.GetAutoSummarize(ctx, userID) require.NoError(t, err) require.True(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) }