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" "gitea.d-ma.be/mathias/tapir/internal/adapters/chat" "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" ) // 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") // 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 KEY SAFETY ASSERTION (ADR-027): a chat answer is produced entirely from the // stored transcript — the model receives the stored text, and neither the // summarize→fetch path nor the YouTube fetch path is ever touched. The tripwire // collaborators t.Fatal the test if chat reaches them. func TestChatAnswersFromStoredTranscriptWithoutAnyFetch(t *testing.T) { ctx := context.Background() const transcript = "STORED-TRANSCRIPT-MARKER: the host explains attention budgets." chatter := &fakeChatter{ models: []string{"phi4-mini"}, reply: chat.Reply{Answer: "It is about attention budgets."}, } app := newChatApp(t, chatter, func(a *web.App) { a.Processor = tripwireProcessor{t} // fails the test if chat summarizes/fetches a.Fetcher = tripwireFetcher{t} // fails the test if chat fetches video metadata }) p := rawPool(t) resetDB(t, p) require.NoError(t, deliver(ctx, app, videoX, "summary body")) seedVideo(t, p, videoX, "X Title", "https://x", time.Time{}) seedTranscript(t, app.Store.(*store.Store), videoX, transcript) form := url.Values{"question": {"what is it about?"}, "model": {"phi4-mini"}} rec := postChat(t, app, videoX, form, true) require.Equal(t, http.StatusOK, rec.Code) require.Contains(t, body(t, rec), "It is about attention budgets.", "the answer is rendered") require.Equal(t, 1, chatter.callCount, "the model was asked exactly once") require.Equal(t, transcript, chatter.gotReq.Transcript, "the model answered from the STORED transcript, not a fetched one") // The tripwires never firing IS the no-fetch / no-YouTube proof. } // A video with no stored transcript yields an honest "not available" — and never // a fetch, never a model call (ADR-027 §2: do not add an on-demand-fetch path). func TestChatUnavailableWhenNoStoredTranscript(t *testing.T) { ctx := context.Background() chatter := &fakeChatter{models: []string{"phi4-mini"}} app := newChatApp(t, chatter, func(a *web.App) { a.Processor = tripwireProcessor{t} a.Fetcher = tripwireFetcher{t} }) p := rawPool(t) resetDB(t, p) require.NoError(t, deliver(ctx, app, videoX, "summary body")) seedVideo(t, p, videoX, "X Title", "https://x", time.Time{}) // NB: no seedTranscript — the transcript is absent. rec := getChat(t, app, videoX) require.Equal(t, http.StatusOK, rec.Code) require.Contains(t, body(t, rec), "isn't available", "honest not-available copy") // Asking anyway still triggers nothing: no model call, no fetch. rec = postChat(t, app, videoX, url.Values{"question": {"hi"}, "model": {"phi4-mini"}}, true) require.Equal(t, http.StatusOK, rec.Code) require.Equal(t, 0, chatter.callCount, "no model call without a stored transcript") } // Chat is reachable ONLY from the user's own summary view: opening chat for a // video that belongs to another user 404s (the summary read is RLS-scoped), so a // guessed/arbitrary video id is not a chat surface (ADR-027 §5 isolation). func TestChatOnlyReachableForOwnVideo(t *testing.T) { chatter := &fakeChatter{models: []string{"phi4-mini"}} app := newChatApp(t, chatter, func(a *web.App) { a.Processor = tripwireProcessor{t} a.Fetcher = tripwireFetcher{t} }) p := rawPool(t) resetDB(t, p) // A summarized video with a stored transcript owned by ANOTHER user. const other = "22222222-2222-2222-2222-222222222222" seedForeignSummaryWithTranscript(t, p, other, videoZ, "Foreign Title") rec := getChat(t, app, videoZ) require.Equal(t, http.StatusNotFound, rec.Code, "cannot open chat for another user's video") require.Equal(t, 0, chatter.callCount, "no model call for a non-owned video") rec = postChat(t, app, videoZ, url.Values{"question": {"hi"}, "model": {"phi4-mini"}}, true) require.Equal(t, http.StatusNotFound, rec.Code, "cannot post chat to another user's video") } // The switcher offers exactly the backend's models, defaults to the summary's own // model, and a switch re-runs against the SAME transcript with the chosen model // (ADR-027 §3 — model-comparison instrumentation). A model dropped from the offer // set (e.g. cloud disabled) is not rendered. func TestChatModelSwitcherDefaultAndSwitch(t *testing.T) { ctx := context.Background() // The seeded summary's model is "phi4-mini" (see handlers_test summary()). chatter := &fakeChatter{ models: []string{"phi4-mini", "gemma4-26b"}, // note: no cloud model offered reply: chat.Reply{Answer: "answer"}, } app := newChatApp(t, chatter, nil) p := rawPool(t) resetDB(t, p) require.NoError(t, deliver(ctx, app, videoX, "summary body")) seedVideo(t, p, videoX, "X Title", "https://x", time.Time{}) seedTranscript(t, app.Store.(*store.Store), videoX, "the transcript text") html := body(t, getChat(t, app, videoX)) require.Contains(t, html, "gemma4-26b", "every offered model is in the switcher") require.NotContains(t, html, "mistral", "a non-offered (cloud-disabled) model is absent") require.Contains(t, html, `value="phi4-mini" selected`, "defaults to the summary's own model") // Switching to gemma re-runs against the same stored transcript. form := url.Values{"question": {"q"}, "model": {"gemma4-26b"}} rec := postChat(t, app, videoX, form, true) require.Equal(t, http.StatusOK, rec.Code) require.Equal(t, "gemma4-26b", chatter.gotReq.Model, "the chosen model answers") require.Equal(t, "the transcript text", chatter.gotReq.Transcript, "against the same transcript") } // A truncated transcript surfaces the honest bounded-context note (ADR-027 §2). func TestChatTruncationNoteShown(t *testing.T) { ctx := context.Background() chatter := &fakeChatter{ models: []string{"phi4-mini"}, reply: chat.Reply{Answer: "answer", Truncated: true}, } app := newChatApp(t, chatter, nil) p := rawPool(t) resetDB(t, p) require.NoError(t, deliver(ctx, app, videoX, "summary body")) seedVideo(t, p, videoX, "X Title", "https://x", time.Time{}) seedTranscript(t, app.Store.(*store.Store), videoX, "long transcript") rec := postChat(t, app, videoX, url.Values{"question": {"q"}, "model": {"phi4-mini"}}, true) require.Contains(t, body(t, rec), "bounded portion", "the truncation note is shown") } // A multi-turn conversation is carried in the request (hidden fields), not the DB: // prior turns ride back into the next answer, and nothing is persisted (ADR-027 §4). func TestChatMultiTurnHistoryIsEphemeral(t *testing.T) { ctx := context.Background() chatter := &fakeChatter{models: []string{"phi4-mini"}, reply: chat.Reply{Answer: "second answer"}} app := newChatApp(t, chatter, nil) p := rawPool(t) resetDB(t, p) require.NoError(t, deliver(ctx, app, videoX, "summary body")) seedVideo(t, p, videoX, "X Title", "https://x", time.Time{}) seedTranscript(t, app.Store.(*store.Store), videoX, "the transcript") // Second turn posts the prior exchange as hidden history fields. form := url.Values{ "question": {"follow-up question"}, "model": {"phi4-mini"}, "hq": {"first question"}, "ha": {"first answer"}, } rec := postChat(t, app, videoX, form, true) require.Equal(t, http.StatusOK, rec.Code) require.Len(t, chatter.gotReq.History, 1, "the prior turn was carried into the request") require.Equal(t, "first question", chatter.gotReq.History[0].Question) require.Equal(t, "first answer", chatter.gotReq.History[0].Answer) html := body(t, rec) require.Contains(t, html, "second answer", "the new answer renders") require.Contains(t, html, "first question", "the conversation persists in page state") // Nothing was written to the DB — there is no chat table; the summaries/actions // are unchanged by a chat turn. row, err := app.Store.GetSummaryByVideo(ctx, userID, videoX) require.NoError(t, err) require.Equal(t, "summary body", row.Summary, "chat never mutates stored data") } // --- foreign-user seeding (isolation) -------------------------------------- // seedForeignSummaryWithTranscript creates a fully-summarized, transcript-backed // video owned by a DIFFERENT user via the raw pool (which bypasses RLS), so the // isolation test can confirm the requesting user cannot open chat for it. func seedForeignSummaryWithTranscript(t *testing.T, p *pgxpool.Pool, otherUserID, videoID, title string) { t.Helper() ctx := context.Background() _, err := p.Exec(ctx, `INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`, otherUserID) require.NoError(t, err) _, err = p.Exec(ctx, `INSERT INTO videos (id, user_id, provider, provider_video_id, title, url) VALUES ($1, $2, 'youtube', $3, $4, 'https://z')`, videoID, otherUserID, seededProviderVideoID(videoID), title) require.NoError(t, err) _, err = p.Exec(ctx, `INSERT INTO summaries (user_id, video_id, summary, ai_provider, ai_model) VALUES ($1, $2, 'foreign summary', 'local', 'phi4-mini')`, otherUserID, videoID) require.NoError(t, err) _, err = p.Exec(ctx, `INSERT INTO transcripts (provider, provider_video_id, source, language, content) VALUES ('youtube', $1, 'captions', 'en', 'foreign transcript')`, seededProviderVideoID(videoID)) require.NoError(t, err) }