From 10ca45fc0a0c6f1e58c31d400c4f0b8152cfddba Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 1 Oct 2026 16:57:47 +0200 Subject: [PATCH] test(web): chat over an uploaded transcript reuses the ADR-027 path (red) Owner sees the panel posting to /uploads/{id}/chat; an unfinished upload redirects to its status; ANOTHER USER'S upload is 404 on GET and POST and no model is ever called with it; the owner's transcript is sent with the upload budget (not the 18,000-char caption default); an unoffered model falls back; a model failure is inline and keeps the conversation; routes are absent without a chat backend; the done card links to chat only when chat is wired. Red: App.UploadChatMaxChars is undefined. Co-Authored-By: Claude Sonnet 5.5 --- internal/web/uploads_chat_handler_test.go | 167 ++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 internal/web/uploads_chat_handler_test.go diff --git a/internal/web/uploads_chat_handler_test.go b/internal/web/uploads_chat_handler_test.go new file mode 100644 index 0000000..e03d8f5 --- /dev/null +++ b/internal/web/uploads_chat_handler_test.go @@ -0,0 +1,167 @@ +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 + 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, "couldn't answer") + 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") +}