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) 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") }