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
376 lines
16 KiB
Go
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)
|
|
}
|