Files
tapir/internal/web/chat_handler_test.go
T
mathiasandClaude Sonnet 4.6 38f222c931
CI / Lint / Test / Vet (push) Successful in 11s
CI / Build & Import (push) Successful in 12s
chore: rename Go module path gitea.d-ma.be → git.d-ma.be
Infra ADR-0004 renamed the Gitea host. Bulk replace across go.mod and
all .go import paths. Build and tests pass unchanged.

Closes #20

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dt6aHEDWRjkK14Voi6HnGh
2026-07-02 14:37:33 +02:00

376 lines
16 KiB
Go

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, "<html", "the reveal is a fragment, not a full page")
require.NotContains(t, frag, "SUMMARY-BODY-MARKER", "the reveal does not re-send the summary (it's already on screen)")
require.Contains(t, frag, `id="chat-section"`, "the fragment replaces the dock in place")
require.Contains(t, frag, `name="question"`, "the ask form is in the revealed section")
}
// 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)
}