Files
tapir/internal/web/uploads_chat_handler_test.go
T
mathias 4a2067063d
CI / Deploy via GitOps (push) Successful in 7s
CI / Lint / Test / Vet (push) Successful in 33s
CI / Build & Import (push) Successful in 23s
feat: upload audio, get a private Swedish transcript, discuss it (#33, ADR-033) (#39)
Upload a recording (audio, or video with the camera off), get a private Swedish transcript, and chat about it.

- ADR-033: RLS-scoped private transcript store; ffprobe/ffmpeg intake; gateway transcription (iguana kb-whisper, berget fallback); in-process single-replica worker; chat over the transcript (local models only).
- ADR-034: optional two-engine mode (pianissimo + kb-whisper) reconciled by a LOCAL model; off unless TAPIR_RECONCILE_BASE_MODEL is set.
- ADR-035: real progress (phases, running/quiet/lost), phase logging, every judged spot stored, merged segments.
- Hardening from four adversarial reviews: errors logged and stored as kinds never text; per-engine deadlines; converted-length check; bounded queue; client hang-ups are not errors; strict local-alias check.
- Dockerfile: static ffmpeg/ffprobe 7.0.2 pinned by sha256 (#34). Built and inspected by hand; CI does not build the image on a PR, so its first CI build is this merge.

Uploads stay OFF until TAPIR_UPLOADS_ENABLED is set in the deployment.

Closes #33, #34, #42, #43, #44.

Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-03 14:13:37 +00:00

184 lines
7.0 KiB
Go

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, `<option value="iguana/gemma4-26b" selected`, "the page opens on the upload chat model")
assert.Equal(t, "iguana/gemma4-26b", fc.gotReq.Model, "a post that names no model uses it too")
}