package web_test import ( "errors" "net/http" "net/http/httptest" "net/url" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "git.d-ma.be/mathias/tapir/internal/adapters/chat" "git.d-ma.be/mathias/tapir/internal/web" ) // Chat over an uploaded transcript REUSES the ADR-027 chat path (the same // Chatter, the same panel) — only the lookup differs: the row comes from the // user-scoped private store, so another user's upload is a 404 and, crucially, no // model is ever called with it. Transcript text is synthetic; this repo is public. const meetingText = "Anders ansvarar för virket. Maria undersöker leverantörer." func newUploadChatApp(t *testing.T) (*web.App, *fakeChatter) { t.Helper() app, _ := newUploadApp(t, audioOK, 200<<20) fc := &fakeChatter{ models: []string{"koala/phi4-mini", "iguana/gemma4-26b"}, reply: chat.Reply{Answer: "Anders owns the timber order."}, } app.Chat = fc app.UploadChatMaxChars = 120000 app.UploadChatModels = fc.models return app, fc } func chatPost(path string, kv url.Values) *http.Request { req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(kv.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") return req } func TestUploadChatPage_OwnerOfAFinishedUploadSeesThePanelPostingToTheUploadRoute(t *testing.T) { app, _ := newUploadChatApp(t) id := seedUpload(t, userID, "Roadmap sync", "done", meetingText) rec := do(t, app, get("/uploads/"+id+"/chat")) require.Equal(t, http.StatusOK, rec.Code) b := body(t, rec) assert.Contains(t, b, `action="/uploads/`+id+`/chat"`, "the form posts to the UPLOAD route, not /v/{id}/chat") assert.Contains(t, b, "Roadmap sync") assert.Contains(t, b, "koala/phi4-mini") assert.Contains(t, b, "recording", "wording says recording, not video") } func TestUploadChatPage_AnUnfinishedUploadRedirectsToItsStatusPage(t *testing.T) { app, fc := newUploadChatApp(t) id := seedUpload(t, userID, "m", "transcribing", "") rec := do(t, app, get("/uploads/"+id+"/chat")) assert.Equal(t, http.StatusSeeOther, rec.Code) assert.Equal(t, "/uploads/"+id, rec.Header().Get("Location")) assert.Zero(t, fc.callCount) } func TestUploadChat_AnotherUsersUploadIs404AndNoModelIsCalled(t *testing.T) { app, fc := newUploadChatApp(t) theirs := seedUpload(t, otherUserID, "Their private meeting", "done", meetingText) getRec := do(t, app, get("/uploads/"+theirs+"/chat")) postRec := do(t, app, chatPost("/uploads/"+theirs+"/chat", url.Values{"question": {"what did they say?"}})) assert.Equal(t, http.StatusNotFound, getRec.Code) assert.Equal(t, http.StatusNotFound, postRec.Code) assert.NotContains(t, body(t, getRec), "Their private meeting") assert.Zero(t, fc.callCount, "someone else's transcript must never reach a model") } func TestUploadChatPost_SendsTheOwnersTranscriptWithTheUploadBudget(t *testing.T) { app, fc := newUploadChatApp(t) app.UploadChatMaxChars = 77777 id := seedUpload(t, userID, "Roadmap sync", "done", meetingText) rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{ "question": {"Who owns the timber order?"}, "model": {"iguana/gemma4-26b"}, })) require.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, 1, fc.callCount) assert.Equal(t, meetingText, fc.gotReq.Transcript) assert.Equal(t, "Who owns the timber order?", fc.gotReq.Question) assert.Equal(t, "iguana/gemma4-26b", fc.gotReq.Model) assert.Equal(t, 77777, fc.gotReq.MaxChars, "an uploaded transcript takes its own budget: the 18,000-char caption default would cut an hour to its first third") assert.Contains(t, body(t, rec), "Anders owns the timber order.") } func TestUploadChatPost_AnUnofferedModelFallsBackToTheDefault(t *testing.T) { app, fc := newUploadChatApp(t) id := seedUpload(t, userID, "m", "done", meetingText) do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}, "model": {"evil/model"}})) assert.Equal(t, "koala/phi4-mini", fc.gotReq.Model) } func TestUploadChatPost_UnfinishedUploadCallsNoModel(t *testing.T) { app, fc := newUploadChatApp(t) id := seedUpload(t, userID, "m", "queued", "") rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}})) assert.Equal(t, http.StatusSeeOther, rec.Code) assert.Zero(t, fc.callCount) } func TestUploadChatPost_PriorTurnsRideAlongAndATruncatedReplyIsSaidSo(t *testing.T) { app, fc := newUploadChatApp(t) fc.reply = chat.Reply{Answer: "second answer", Truncated: true} id := seedUpload(t, userID, "m", "done", meetingText) rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{ "question": {"and who else?"}, "hq": {"first question"}, "ha": {"first answer"}, })) require.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, []chat.Turn{{Question: "first question", Answer: "first answer"}}, fc.gotReq.History) b := body(t, rec) assert.Contains(t, b, "first answer") assert.Contains(t, b, "second answer") assert.Contains(t, b, "end of the recording", "a long recording cut to a budget must say so, in its own words") } func TestUploadChatPost_AModelFailureIsShownInlineNotAs500(t *testing.T) { app, fc := newUploadChatApp(t) fc.err = errors.New("gateway down") id := seedUpload(t, userID, "m", "done", meetingText) rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}, "hq": {"earlier"}, "ha": {"kept"}})) require.Equal(t, http.StatusOK, rec.Code) b := body(t, rec) assert.Contains(t, b, "answer just now", "the failure is inline (the apostrophe is HTML-escaped, so match the tail)") assert.Contains(t, b, "kept", "the conversation survives a failed turn") } func TestUploadChatRoutes_AreNotMountedWithoutAChatBackend(t *testing.T) { app, _ := newUploadApp(t, audioOK, 200<<20) id := seedUpload(t, userID, "m", "done", meetingText) assert.Equal(t, http.StatusNotFound, do(t, app, get("/uploads/"+id+"/chat")).Code) } func TestUploadStatus_DoneOffersTheChatLinkOnlyWhenChatIsWired(t *testing.T) { appWith, _ := newUploadChatApp(t) id := seedUpload(t, userID, "m", "done", meetingText) assert.Contains(t, body(t, do(t, appWith, get("/uploads/"+id+"/status"))), `href="/uploads/`+id+`/chat"`) appWithout, _ := newUploadApp(t, audioOK, 200<<20) id2 := seedUpload(t, userID, "m2", "done", meetingText) assert.NotContains(t, body(t, do(t, appWithout, get("/uploads/"+id2+"/status"))), "/chat") } func TestUploadChat_OpensWithTheUploadChatModelNotTheSummarizerDefault(t *testing.T) { // koala/phi4-mini is first in the offered list (it is the summarizer default) and // has an 8,192-token context: an hour of speech returns HTTP 400 there. Chat over // an upload must open with the model configured for it. app, fc := newUploadChatApp(t) app.UploadChatModel = "iguana/gemma4-26b" id := seedUpload(t, userID, "m", "done", meetingText) page := body(t, do(t, app, get("/uploads/"+id+"/chat"))) do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}})) assert.Contains(t, page, `