package web_test import ( "context" "net/http" "net/http/httptest" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/require" "gitea.d-ma.be/mathias/tapir/internal/web" ) // fakeProcessor records ProcessVideo calls. With block set it parks until the // channel is closed, so a test can observe the handler return before the // background work finishes (proving it ran in a goroutine). type fakeProcessor struct { block chan struct{} done chan struct{} calls []string } func (f *fakeProcessor) ProcessVideo(_ context.Context, _, videoID string) error { if f.block != nil { <-f.block } f.calls = append(f.calls, videoID) if f.done != nil { close(f.done) } return nil } func TestRequestSummarizeImmediateProcessing(t *testing.T) { app := newApp(t) p := rawPool(t) resetDB(t, p) seedVideo(t, p, videoX, "Pending Title", "https://x", time.Time{}) fp := &fakeProcessor{block: make(chan struct{}), done: make(chan struct{})} app.Processor = fp rec := postSummarize(t, app, videoX, true) require.Equal(t, http.StatusOK, rec.Code) html := body(t, rec) // The processing card came back while ProcessVideo is still parked on block: // the work runs in a goroutine, the handler did not wait for it. require.Contains(t, html, "Summarizing", "processing card returned") require.Contains(t, html, "╭", "charm box rendered") require.Contains(t, html, "▓", "tapir body block chars rendered") require.Contains(t, html, "∩", "wiggling snout frame rendered") require.Contains(t, html, web.CharmPurple, "charm palette applied to the border") require.Contains(t, html, "/v/"+videoX+"/status", "card polls the status endpoint") require.Contains(t, html, `hx-trigger="every 2s"`, "card auto-polls every 2s") require.NotContains(t, html, "Queued", "not the queue-only card") close(fp.block) select { case <-fp.done: case <-time.After(2 * time.Second): t.Fatal("ProcessVideo was not called in the background") } require.Equal(t, []string{videoX}, fp.calls) } func TestStatusProcessingThenDone(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{}) // Park ProcessVideo so the video stays in-flight while we poll status. fp := &fakeProcessor{block: make(chan struct{})} app.Processor = fp require.Equal(t, http.StatusOK, postSummarize(t, app, videoX, true).Code) // Processing: status returns the animation card, still polling. rec := getStatus(t, app, videoX) require.Equal(t, http.StatusOK, rec.Code) html := body(t, rec) require.Contains(t, html, "Summarizing", "in-flight → animation card") require.Contains(t, html, `hx-trigger="every 2s"`, "still polling") close(fp.block) // Done: once a summary exists, status returns the summary card with no poll. require.NoError(t, deliver(ctx, app, videoX, "the summary body")) rec = getStatus(t, app, videoX) require.Equal(t, http.StatusOK, rec.Code) html = body(t, rec) require.NotContains(t, html, "Summarizing", "done → no animation") require.NotContains(t, html, "every 2s", "done card does not poll (polling stops)") require.Contains(t, html, "/v/"+videoX+"\"", "links to the detail page") } func TestStatusQueuedWhenNotInFlight(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{}) // Flag set but nothing in-flight (e.g. queue-only, or after a restart). require.NoError(t, app.Store.RequestSummarize(ctx, userID, videoX)) rec := getStatus(t, app, videoX) require.Equal(t, http.StatusOK, rec.Code) html := body(t, rec) require.Contains(t, html, "Queued", "queued chip card") require.NotContains(t, html, "Summarizing", "not processing") require.NotContains(t, html, "every 2s", "queued card does not poll") } func TestStatusNotFound(t *testing.T) { app := newApp(t) resetDB(t, rawPool(t)) rec := getStatus(t, app, videoX) require.Equal(t, http.StatusNotFound, rec.Code) } func getStatus(t *testing.T, app *web.App, videoID string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, "/v/"+videoID+"/status", nil) req.Header.Set("HX-Request", "true") rec := httptest.NewRecorder() app.Router().ServeHTTP(rec, req) return rec } // setTranscriptStatus stamps videos.transcript_status directly (bypassing RLS via // the super pool) so a test can drive the status endpoint into a given state. func setTranscriptStatus(t *testing.T, p *pgxpool.Pool, videoID, status string) { t.Helper() _, err := p.Exec(context.Background(), `UPDATE videos SET transcript_status = $2 WHERE id = $1`, videoID, status) require.NoError(t, err) } // TestStatusRateLimitedShowsWaitingCard: a click that hit YouTube's rate limit // must surface the honest "waiting, will retry" card that keeps polling — not a // silent revert to the Summarize button. func TestStatusRateLimitedShowsWaitingCard(t *testing.T) { app := newApp(t) p := rawPool(t) resetDB(t, p) seedVideo(t, p, videoX, "Throttled Title", "https://x", time.Time{}) setTranscriptStatus(t, p, videoX, "rate_limited") html := body(t, getStatus(t, app, videoX)) require.Contains(t, html, "Waiting on YouTube rate limits", "honest rate-limit copy") require.Contains(t, html, `hx-trigger="every 30s"`, "waiting card keeps polling so it self-resolves") require.NotContains(t, html, "Summarize this video", "must not revert to the Summarize button") } // TestStatusNoCaptionsTerminal: a no-captions outcome is terminal — an honest // message, no poll, no button to click back into the same dead end. func TestStatusNoCaptionsTerminal(t *testing.T) { app := newApp(t) p := rawPool(t) resetDB(t, p) seedVideo(t, p, videoX, "Silent Title", "https://x", time.Time{}) setTranscriptStatus(t, p, videoX, "none") html := body(t, getStatus(t, app, videoX)) require.Contains(t, html, "No captions available", "honest terminal copy") require.NotContains(t, html, "hx-trigger", "terminal card must stop polling") }