481 lines
18 KiB
Go
481 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, "<script>")
|
|
}
|
|
|
|
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"},
|
|
{"audio_incomplete", "ends much earlier than"},
|
|
}
|
|
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.")
|
|
})
|
|
}
|
|
}
|