Files
tapir/internal/web/uploads_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

480 lines
18 KiB
Go

package web_test
import (
"bytes"
"context"
"fmt"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/uploads"
"git.d-ma.be/mathias/tapir/internal/web"
)
// Upload route tests drive the real Router, the real Intake and the real Postgres
// store; only the process boundary (ffprobe) and the transcription worker are
// faked. All audio bytes and transcript text are synthetic — this repo is public
// and no real recording or transcript may appear in it (#33).
const otherUserID = "22222222-2222-2222-2222-222222222222"
type uploadProber struct{ probe uploads.Probe }
func (p uploadProber) Probe(context.Context, string) (uploads.Probe, error) { return p.probe, nil }
type uploadSubmitter struct{ jobs []uploads.Job }
func (s *uploadSubmitter) Submit(j uploads.Job) error { s.jobs = append(s.jobs, j); return nil }
func newUploadApp(t *testing.T, probe uploads.Probe, maxBytes int64) (*web.App, *uploadSubmitter) {
t.Helper()
app := newApp(t)
resetDB(t, rawPool(t))
sub := &uploadSubmitter{}
limits := uploads.Limits{MaxBytes: maxBytes, MaxDuration: 90 * time.Minute}
s := newStore(t)
app.Uploads = uploads.NewIntake(s, uploadProber{probe: probe}, sub, limits, t.TempDir())
app.UploadLimits = limits
return app, sub
}
var audioOK = uploads.Probe{Duration: 45 * time.Minute, HasAudio: true}
func uploadReq(t *testing.T, title, filename string, content []byte) *http.Request {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
if title != "" {
require.NoError(t, mw.WriteField("title", title))
}
if filename != "" {
fw, err := mw.CreateFormFile("file", filename)
require.NoError(t, err)
_, err = fw.Write(content)
require.NoError(t, err)
}
require.NoError(t, mw.Close())
req := httptest.NewRequest(http.MethodPost, "/uploads", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
return req
}
func seedUpload(t *testing.T, owner, title, status, content string) string {
t.Helper()
p := rawPool(t)
_, err := p.Exec(context.Background(), `INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING`, owner)
require.NoError(t, err)
var id string
switch status {
case "done":
require.NoError(t, p.QueryRow(context.Background(),
`INSERT INTO uploaded_transcripts (user_id, title, status, content, endpoint, audio_seconds, finished_at)
VALUES ($1, $2, 'done', $3, 'berget/kb-whisper', 2700, NOW()) RETURNING id`,
owner, title, content).Scan(&id))
case "failed":
require.NoError(t, p.QueryRow(context.Background(),
`INSERT INTO uploaded_transcripts (user_id, title, status, error_kind, finished_at)
VALUES ($1, $2, 'failed', $3, NOW()) RETURNING id`,
owner, title, content).Scan(&id)) // content carries the error_kind here
default:
require.NoError(t, p.QueryRow(context.Background(),
`INSERT INTO uploaded_transcripts (user_id, title, status) VALUES ($1, $2, $3) RETURNING id`,
owner, title, status).Scan(&id))
}
return id
}
func get(path string) *http.Request { return httptest.NewRequest(http.MethodGet, path, nil) }
func TestUploadsPage_ShowsAMultipartFormWithAFileField(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
rec := do(t, app, get("/uploads"))
require.Equal(t, http.StatusOK, rec.Code)
b := body(t, rec)
assert.Contains(t, b, `enctype="multipart/form-data"`)
assert.Contains(t, b, `name="file"`, "the multipart field is `file`")
assert.Contains(t, b, "200 MB", "the size limit is stated up front, not discovered by failing")
assert.Contains(t, b, "1 h 30 min", "the length limit is stated up front")
}
func TestUploadsPage_ListsOnlyTheCallersUploads(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
seedUpload(t, userID, "My weekly sync", "queued", "")
seedUpload(t, otherUserID, "Someone else's board meeting", "queued", "")
b := body(t, do(t, app, get("/uploads")))
assert.Contains(t, b, "My weekly sync")
assert.NotContains(t, b, "Someone else's board meeting")
}
func TestUploadPost_AcceptedAudioQueuesARowAndRedirectsToIt(t *testing.T) {
app, sub := newUploadApp(t, audioOK, 200<<20)
rec := do(t, app, uploadReq(t, "Sprint review", "recording.m4a", []byte("synthetic audio bytes")))
require.Equal(t, http.StatusSeeOther, rec.Code)
var id, status, title string
require.NoError(t, rawPool(t).QueryRow(context.Background(),
`SELECT id, status, title FROM uploaded_transcripts WHERE user_id = $1`, userID).Scan(&id, &status, &title))
assert.Equal(t, "queued", status)
assert.Equal(t, "Sprint review", title)
assert.Equal(t, "/uploads/"+id, rec.Header().Get("Location"))
require.Len(t, sub.jobs, 1)
assert.Equal(t, uploads.Job{UserID: userID, UploadID: id, Path: sub.jobs[0].Path, Duration: audioOK.Duration}, sub.jobs[0])
spooled, err := os.ReadFile(sub.jobs[0].Path)
require.NoError(t, err)
assert.Equal(t, "synthetic audio bytes", string(spooled))
}
func TestUploadPost_WithoutATitleUsesTheFilenameMinusItsExtension(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
rec := do(t, app, uploadReq(t, "", "Teams meeting 2026-09-30.mp4", []byte("x")))
require.Equal(t, http.StatusSeeOther, rec.Code)
var title string
require.NoError(t, rawPool(t).QueryRow(context.Background(),
`SELECT title FROM uploaded_transcripts WHERE user_id = $1`, userID).Scan(&title))
assert.Equal(t, "Teams meeting 2026-09-30", title)
}
func TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow(t *testing.T) {
tests := []struct {
name string
probe uploads.Probe
maxBytes int64
content string
wantStatus int
wantInBody []string
}{
{
name: "video with no audio is rejected for having no audio",
probe: uploads.Probe{Duration: time.Minute, HasVideo: true},
maxBytes: 1 << 20,
content: "x",
wantStatus: http.StatusUnprocessableEntity,
wantInBody: []string{"no audio"},
},
{
name: "too long states both lengths",
probe: uploads.Probe{Duration: 2 * time.Hour, HasAudio: true},
maxBytes: 1 << 20,
content: "x",
wantStatus: http.StatusUnprocessableEntity,
wantInBody: []string{"2 h", "1 h 30 min"},
},
{
name: "too large is 413",
probe: audioOK,
maxBytes: 4,
content: "more than four bytes",
wantStatus: http.StatusRequestEntityTooLarge,
wantInBody: []string{"limit"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
app, sub := newUploadApp(t, tt.probe, tt.maxBytes)
rec := do(t, app, uploadReq(t, "t", "r.m4a", []byte(tt.content)))
assert.Equal(t, tt.wantStatus, rec.Code)
b := body(t, rec)
for _, s := range tt.wantInBody {
assert.Contains(t, b, s)
}
var n int
require.NoError(t, rawPool(t).QueryRow(context.Background(),
`SELECT count(*) FROM uploaded_transcripts`).Scan(&n))
assert.Zero(t, n, "a rejected upload leaves no row")
assert.Empty(t, sub.jobs)
})
}
}
func TestUploadPost_WithNoFilePartIs400(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
rec := do(t, app, uploadReq(t, "only a title", "", nil))
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Contains(t, body(t, rec), "Choose an audio file")
}
func TestUploadRoutes_AreNotMountedWithoutAnUploader(t *testing.T) {
app := newApp(t)
resetDB(t, rawPool(t))
assert.Equal(t, http.StatusNotFound, do(t, app, get("/uploads")).Code)
}
func TestUploadPage_OwnerSeesTitleStatusAndNonOwnerGetsNotFound(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
mine := seedUpload(t, userID, "Roadmap sync", "queued", "")
theirs := seedUpload(t, otherUserID, "Their private meeting", "queued", "")
own := do(t, app, get("/uploads/"+mine))
require.Equal(t, http.StatusOK, own.Code)
assert.Contains(t, body(t, own), "Roadmap sync")
for _, path := range []string{"/uploads/" + theirs, "/uploads/" + theirs + "/status"} {
rec := do(t, app, get(path))
assert.Equal(t, http.StatusNotFound, rec.Code, path)
assert.NotContains(t, body(t, rec), "Their private meeting", "a 404 must not leak the title")
}
}
func TestUploadStatus_UnfinishedStatesKeepPollingTerminalStatesStop(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
tests := []struct {
name, status, content string
polls bool
wantInBody string
}{
{"queued", "queued", "", true, "Queued"},
{"transcribing", "transcribing", "", true, "ranscrib"},
{"done", "done", "Synthetic transcript text.", false, "Synthetic transcript text."},
{"failed interrupted", "failed", "interrupted", false, "upload it again"},
{"failed transcription", "failed", "transcription_failed", false, "Transcription failed"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
id := seedUpload(t, userID, "m-"+tt.name, tt.status, tt.content)
rec := do(t, app, get("/uploads/"+id+"/status"))
require.Equal(t, http.StatusOK, rec.Code)
b := body(t, rec)
assert.Contains(t, b, tt.wantInBody)
if tt.polls {
assert.Contains(t, b, `hx-trigger="every`, "an unfinished upload must keep polling")
} else {
assert.NotContains(t, b, "hx-trigger", "a finished upload must not poll forever: no stuck spinner")
}
})
}
}
func TestUploadStatus_DoneNamesTheEndpointThatServedIt(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.Contains(t, b, "berget/kb-whisper",
"which backend served a transcript is a quality fact, and berget means the audio left the homelab")
assert.True(t, strings.Contains(b, "45 min"), "the audio length is shown")
}
func markHadVideo(t *testing.T, id string) {
t.Helper()
_, err := rawPool(t).Exec(context.Background(), `UPDATE uploaded_transcripts SET had_video = TRUE WHERE id = $1`, id)
require.NoError(t, err)
}
func TestUploadPost_AFileWithAVideoTrackIsAcceptedAndTheRowRemembersIt(t *testing.T) {
// A Teams meeting with the camera off is still an .mp4 with a video track.
app, sub := newUploadApp(t, uploads.Probe{Duration: 62 * time.Minute, HasAudio: true, HasVideo: true}, 200<<20)
rec := do(t, app, uploadReq(t, "Project notes", "Teams meeting.mp4", []byte("synthetic bytes")))
require.Equal(t, http.StatusSeeOther, rec.Code, "it must be accepted, not rejected as video")
var hadVideo bool
require.NoError(t, rawPool(t).QueryRow(context.Background(),
`SELECT had_video FROM uploaded_transcripts WHERE user_id = $1`, userID).Scan(&hadVideo))
assert.True(t, hadVideo)
assert.Len(t, sub.jobs, 1)
}
func TestUploadPost_AudioOnlyIsNotMarkedAsHavingHadVideo(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
do(t, app, uploadReq(t, "Voice memo", "memo.m4a", []byte("x")))
var hadVideo bool
require.NoError(t, rawPool(t).QueryRow(context.Background(),
`SELECT had_video FROM uploaded_transcripts WHERE user_id = $1`, userID).Scan(&hadVideo))
assert.False(t, hadVideo)
}
func TestUploadStatus_TellsTheUserTheVideoWasIgnoredInEveryStateAndOnlyWhenItWas(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
states := []struct{ status, content string }{
{"queued", ""}, {"transcribing", ""}, {"done", "Synthetic transcript."}, {"failed", "interrupted"},
}
for _, st := range states {
t.Run(st.status, func(t *testing.T) {
withVideo := seedUpload(t, userID, "camera off "+st.status, st.status, st.content)
markHadVideo(t, withVideo)
audioOnly := seedUpload(t, userID, "voice only "+st.status, st.status, st.content)
notice := body(t, do(t, app, get("/uploads/"+withVideo+"/status")))
plain := body(t, do(t, app, get("/uploads/"+audioOnly+"/status")))
assert.Contains(t, notice, "video track")
assert.Contains(t, notice, "Only its audio was used", "say what happened, not just that something did")
assert.Contains(t, notice, "discarded")
assert.NotContains(t, plain, "video track", "no notice when there was no video")
})
}
}
func TestUploadPage_AlsoCarriesTheVideoNotice(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "camera off", "done", "Synthetic transcript.")
markHadVideo(t, id)
assert.Contains(t, body(t, do(t, app, get("/uploads/"+id))), "Only its audio was used")
}
func TestUploadPost_ARejectionIsLoggedWithItsReasonAndNeverTheFilenameOrTitle(t *testing.T) {
// Rejections are synchronous and leave no row, so without a log line an operator
// cannot tell "the user never retried" from "the page refused it". The reason is
// operational; the filename and title are the user's private content.
var buf bytes.Buffer
app, _ := newUploadApp(t, uploads.Probe{Duration: time.Minute, HasVideo: true}, 1<<20)
app.Log = slog.New(slog.NewTextHandler(&buf, nil))
rec := do(t, app, uploadReq(t, "Secret project notes", "client-acme-roadmap.mp4", []byte("x")))
require.Equal(t, http.StatusUnprocessableEntity, rec.Code)
logged := buf.String()
assert.Contains(t, logged, "upload rejected")
assert.Contains(t, logged, "reason=no_audio")
assert.NotContains(t, logged, "Secret project notes", "the title is private content")
assert.NotContains(t, logged, "client-acme-roadmap", "the filename is private content")
}
func setReconciliation(t *testing.T, id, json string) {
t.Helper()
_, err := rawPool(t).Exec(context.Background(), `UPDATE uploaded_transcripts SET reconciliation = $2::jsonb WHERE id = $1`, id, json)
require.NoError(t, err)
}
const reconciledJSON = `{"status":"reconciled","base":"iguana/pianissimo","alt":"iguana/kb-whisper","reconciler":"iguana/gemma4-26b",
"disputes":380,"auto_resolved":90,"adjudicated":285,"changed":120,"unsure":5,"errors":0,
"contested":[
{"seconds":2210.5,"base":"Axel","alt":"Alex","used":"alt","unsure":false},
{"seconds":3400,"base":"A+2","alt":"en A","used":"base","unsure":true}]}`
func TestUploadStatus_ReconciledSaysWhatWasDoneInPlainWords(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
setReconciliation(t, id, reconciledJSON)
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.Contains(t, b, "Reconciled from two transcripts")
assert.Contains(t, b, "iguana/pianissimo")
assert.Contains(t, b, "iguana/kb-whisper")
assert.Contains(t, b, "iguana/gemma4-26b", "who decided is part of the account")
assert.Contains(t, b, "380 differences")
assert.Contains(t, b, "90 settled automatically")
assert.Contains(t, b, "285 decided by the model")
assert.Contains(t, b, "5 uncertain")
}
func TestUploadStatus_ListsTheContestedSpotsWithAMinuteMarkBothReadingsAndWhichWasUsed(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
setReconciliation(t, id, reconciledJSON)
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.Contains(t, b, "~37 min", "2210.5 s")
assert.Contains(t, b, "Axel")
assert.Contains(t, b, "Alex")
assert.Contains(t, b, "~57 min", "3400 s")
assert.Contains(t, b, "A+2")
assert.Contains(t, b, "en A")
assert.Contains(t, b, "uncertain", "an unsure spot is marked so the reader knows to check it")
}
func TestUploadStatus_ASingleEngineTranscriptDoesNotClaimASecondOpinion(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
assert.NotContains(t, body(t, do(t, app, get("/uploads/"+id+"/status"))), "Reconciled", "no reconciliation, no claim")
}
func TestUploadStatus_WhenOnlyOneEngineWorkedItSaysWhyTheSecondOpinionIsMissing(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
setReconciliation(t, id, `{"status":"single_engine","reason":"iguana/pianissimo failed: wedged","base":"iguana/pianissimo","alt":"iguana/kb-whisper","reconciler":"iguana/gemma4-26b"}`)
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.Contains(t, b, "Only one engine")
assert.Contains(t, b, "iguana/pianissimo failed")
assert.NotContains(t, b, "Reconciled from two")
}
func TestUploadStatus_WhenTheModelWasUnavailableItSaysTheTextIsUnreconciled(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
setReconciliation(t, id, `{"status":"skipped","reason":"the model failed 3 times in a row: gateway down","base":"iguana/pianissimo","alt":"iguana/kb-whisper","reconciler":"iguana/gemma4-26b","disputes":380,"errors":3}`)
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.Contains(t, b, "could not be reconciled")
assert.Contains(t, b, "iguana/pianissimo", "which engine's text is being shown")
assert.Contains(t, b, "gateway down")
}
func TestUploadStatus_ContestedReadingsAreEscaped(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":1,"unsure":1,
"contested":[{"seconds":60,"base":"<script>alert(1)</script>","alt":"ok","used":"base","unsure":true}]}`)
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.NotContains(t, b, "<script>alert(1)</script>", "a reading is words from a recording, never markup")
assert.Contains(t, b, "&lt;script&gt;")
}
func TestUploadStatus_AVeryLongListOfContestedSpotsIsShortenedWithItsTrueCount(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
var spots []string
for i := 0; i < 120; i++ {
spots = append(spots, fmt.Sprintf(`{"seconds":%d,"base":"b%d","alt":"a%d","used":"base","unsure":true}`, i*10, i, i))
}
setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":400,"unsure":120,"contested":[`+strings.Join(spots, ",")+`]}`)
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.Contains(t, b, "b0", "the first spots are shown")
assert.NotContains(t, b, ">b119<", "the page does not render every one")
assert.Contains(t, b, "70 more", "120 stored, 50 shown")
}
func TestUploadStatus_ALengthFailureIsExplainedInWordsAndNotAsAGenericError(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
tests := []struct{ kind, want string }{
{"audio_too_long", "longer than the limit"},
}
for _, tt := range tests {
t.Run(tt.kind, func(t *testing.T) {
id := seedUpload(t, userID, "m-"+tt.kind, "failed", tt.kind)
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
assert.Contains(t, b, tt.want)
assert.NotContains(t, b, "This upload failed. Please upload it again.")
})
}
}