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 <noreply@anthropic.com>
277 lines
9.5 KiB
Go
277 lines
9.5 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 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")
|
|
}
|