Files
tapir/internal/web/uploads_handler_test.go
T
mathiasandClaude Sonnet 5.5 7aec2538be test(web): a video file is accepted and the user is told the video was ignored (red)
Video with no audio is rejected for having none. A file with a video track is
accepted and marked had_video; every state of its page (queued, transcribing,
done, failed) says only its audio was used and the video was discarded, and a
plain audio file shows no such notice.

Red: the had_video column does not exist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 18:42:33 +02:00

339 lines
12 KiB
Go

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