package web_test import ( "context" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/require" "git.d-ma.be/mathias/tapir/internal/adapters/chat" "git.d-ma.be/mathias/tapir/internal/adapters/store" "git.d-ma.be/mathias/tapir/internal/domain" "git.d-ma.be/mathias/tapir/internal/web" ) // videoZ is a video id used by the isolation test for a DIFFERENT user's video. const videoZ = "33333333-3333-3333-3333-333333333333" // --- chat test doubles ----------------------------------------------------- // fakeChatter is a web.Chatter that records the request it received and returns a // canned reply. It performs NO network and NO fetch — it stands in for the real // chat.Service so the handler behaviour is what's under test. type fakeChatter struct { models []string reply chat.Reply err error gotReq chat.Request callCount int } func (f *fakeChatter) Models() []string { return f.models } func (f *fakeChatter) DefaultModel(summaryModel string) string { for _, m := range f.models { if m == summaryModel { return summaryModel } } if len(f.models) > 0 { return f.models[0] } return "" } func (f *fakeChatter) Answer(_ context.Context, req chat.Request) (chat.Reply, error) { f.callCount++ f.gotReq = req if f.err != nil { return chat.Reply{}, f.err } return f.reply, nil } // tripwireProcessor and tripwireFetcher are the YouTube-reaching collaborators // (summarize → caption fetch, and the paste metadata fetch). Wired into the App // for the safety test, they fail it the instant chat routes into either — the // behavioural proof that chat never triggers a fetch (ADR-027). type tripwireProcessor struct{ t *testing.T } func (p tripwireProcessor) ProcessVideo(context.Context, string, string) error { p.t.Fatal("chat triggered summarization (→ caption fetch) — must never happen (ADR-027)") return nil } type tripwireFetcher struct{ t *testing.T } func (f tripwireFetcher) FetchVideo(context.Context, string, string) (domain.Video, error) { f.t.Fatal("chat triggered a YouTube video fetch — must never happen (ADR-027)") return domain.Video{}, nil } // --- helpers --------------------------------------------------------------- // newChatApp builds the App under test as the registered stub user, with a Chat // backend wired. Mirrors newApp but adds chat (and any extra wiring via mutate). func newChatApp(t *testing.T, chatter web.Chatter, mutate func(*web.App)) *web.App { t.Helper() s := newStore(t) app := &web.App{ Store: s, Identity: s, Auth: web.StubAuth{U: web.User{Subject: stubSubject}}, Chat: chatter, } if mutate != nil { mutate(app) } return app } // seededProviderVideoID mirrors seedVideo's derivation so the chat path's // (provider, providerVideoID) transcript key matches the seeded video row. func seededProviderVideoID(videoID string) string { return "pv-" + videoID[:8] } // seedTranscript stores a shared (provider, providerVideoID) transcript — the // ADR-021 stored content the chat reads. Captions source = usable text. func seedTranscript(t *testing.T, s *store.Store, videoID, content string) { t.Helper() err := s.SaveTranscript(context.Background(), "youtube", seededProviderVideoID(videoID), domain.Transcript{Source: domain.SourceCaptions, Language: "en", Content: content}) require.NoError(t, err) } func getChat(t *testing.T, app *web.App, videoID string) *httptest.ResponseRecorder { t.Helper() return do(t, app, httptest.NewRequest(http.MethodGet, "/v/"+videoID+"/chat", nil)) } func postChat(t *testing.T, app *web.App, videoID string, form url.Values, htmx bool) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, "/v/"+videoID+"/chat", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if htmx { req.Header.Set("HX-Request", "true") } return do(t, app, req) } // --- scenarios ------------------------------------------------------------- // The "dig deeper" affordance appears on a summary detail view only when chat is // enabled, and links to that video's chat — the single entry point (ADR-027 §1). func TestChatEntryAffordanceOnSummaryView(t *testing.T) { ctx := context.Background() withChat := newChatApp(t, &fakeChatter{models: []string{"phi4-mini"}}, nil) p := rawPool(t) resetDB(t, p) require.NoError(t, deliver(ctx, withChat, videoX, "body x")) seedVideo(t, p, videoX, "X Title", "https://x", time.Time{}) html := body(t, do(t, withChat, httptest.NewRequest(http.MethodGet, "/v/"+videoX, nil))) require.Contains(t, html, "Dig deeper", "the deeper-dive affordance is shown when chat is enabled") require.Contains(t, html, "/v/"+videoX+"/chat", "it links to this video's chat") require.Contains(t, html, `id="chat-section"`, "the dock lives on the detail page") require.Contains(t, html, `hx-get="/v/`+videoX+`/chat"`, "it opens the chat in place (HTMX), not a navigation") // With no chat backend wired the affordance is absent (routes unmounted). noChat := newApp(t) require.NoError(t, deliver(ctx, noChat, videoX, "body x")) html = body(t, do(t, noChat, httptest.NewRequest(http.MethodGet, "/v/"+videoX, nil))) require.NotContains(t, html, "Dig deeper", "no affordance when chat is disabled") } // The summary and the chat live together (the integrated UX): the no-JS chat page // renders the full summary alongside the chat, and the HTMX reveal returns just // the open chat section as a fragment so it docks in below the summary already on // screen — the summary is never navigated away from. func TestChatIntegratedWithSummaryOnSamePage(t *testing.T) { ctx := context.Background() chatter := &fakeChatter{models: []string{"phi4-mini", "gemma4-26b"}} app := newChatApp(t, chatter, nil) p := rawPool(t) resetDB(t, p) require.NoError(t, deliver(ctx, app, videoX, "SUMMARY-BODY-MARKER")) seedVideo(t, p, videoX, "X Title", "https://x", time.Time{}) seedTranscript(t, app.Store.(*store.Store), videoX, "the transcript") // No-JS full page: the summary payload and the chat are on one page. html := body(t, getChat(t, app, videoX)) require.Contains(t, html, "SUMMARY-BODY-MARKER", "the summary text is shown on the chat page") require.Contains(t, html, "takeaway one", "takeaways shown alongside the chat") require.Contains(t, html, "highlight one", "highlights shown alongside the chat") require.Contains(t, html, "Ask about this video", "the chat sits on the same page as the summary") require.Contains(t, html, `name="question"`, "the ask form is present") // HTMX reveal: the open chat section ONLY (a fragment) — no full-page chrome and // no duplicated summary, so it swaps in below the summary already rendered. req := httptest.NewRequest(http.MethodGet, "/v/"+videoX+"/chat", nil) req.Header.Set("HX-Request", "true") frag := body(t, do(t, app, req)) require.NotContains(t, frag, "