From e1ed6ea6f481e0b124b7dc0ac908ec3fb10bc889 Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 1 Oct 2026 16:41:42 +0200 Subject: [PATCH] test(web): upload routes -- form, multipart POST, rejections, owner-only, honest status (red) Real Router + real Intake + real Postgres; only ffprobe and the worker are faked. Covers: the form states the limits up front; accepted audio queues a row and redirects; video / too-long / too-large are rejected synchronously (422/422/413) with a message and NO row; a non-owner gets 404 without the title leaking; unfinished states keep polling and terminal states stop (no stuck spinner); a done transcript names the endpoint that served it. Red: App.Uploads / App.UploadLimits are undefined. Co-Authored-By: Claude Sonnet 5.5 --- internal/web/uploads_handler_test.go | 276 +++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 internal/web/uploads_handler_test.go diff --git a/internal/web/uploads_handler_test.go b/internal/web/uploads_handler_test.go new file mode 100644 index 0000000..2914e2c --- /dev/null +++ b/internal/web/uploads_handler_test.go @@ -0,0 +1,276 @@ +package web_test + +import ( + "bytes" + "context" + "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}, 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 names issue 28", + probe: uploads.Probe{Duration: time.Minute, HasAudio: true, HasVideo: true}, + maxBytes: 1 << 20, + content: "x", + wantStatus: http.StatusUnprocessableEntity, + wantInBody: []string{"video", "#28"}, + }, + { + 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) + for _, s := range tt.wantInBody { + assert.Contains(t, body(t, rec), 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") +} + +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") +}