Files
tapir/internal/web/uploads_progress_handler_test.go
mathias 4a2067063d
CI / Deploy via GitOps (push) Successful in 7s
CI / Lint / Test / Vet (push) Successful in 33s
CI / Build & Import (push) Successful in 23s
feat: upload audio, get a private Swedish transcript, discuss it (#33, ADR-033) (#39)
Upload a recording (audio, or video with the camera off), get a private Swedish transcript, and chat about it.

- ADR-033: RLS-scoped private transcript store; ffprobe/ffmpeg intake; gateway transcription (iguana kb-whisper, berget fallback); in-process single-replica worker; chat over the transcript (local models only).
- ADR-034: optional two-engine mode (pianissimo + kb-whisper) reconciled by a LOCAL model; off unless TAPIR_RECONCILE_BASE_MODEL is set.
- ADR-035: real progress (phases, running/quiet/lost), phase logging, every judged spot stored, merged segments.
- Hardening from four adversarial reviews: errors logged and stored as kinds never text; per-engine deadlines; converted-length check; bounded queue; client hang-ups are not errors; strict local-alias check.
- Dockerfile: static ffmpeg/ffprobe 7.0.2 pinned by sha256 (#34). Built and inspected by hand; CI does not build the image on a PR, so its first CI build is this merge.

Uploads stay OFF until TAPIR_UPLOADS_ENABLED is set in the deployment.

Closes #33, #34, #42, #43, #44.

Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-03 14:13:37 +00:00

288 lines
13 KiB
Go

package web_test
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/web"
)
// The page's progress display (ADR-035, #42): real phases from the row, and the three
// honest states the page can tell apart -- running, quiet (counted phase not advancing)
// and lost (the service stopped reporting). Real router, real Postgres, a fixed clock.
var progressNow = time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
func fixedClock(app *web.App) { app.Now = func() time.Time { return progressNow } }
// seedProgress writes the job's phases and the two timestamps onto an existing row.
func seedProgress(t *testing.T, id string, steps []domain.ProgressStep, progressAt, aliveAt time.Time) {
t.Helper()
b, err := json.Marshal(steps)
require.NoError(t, err)
var alive any
if !aliveAt.IsZero() {
alive = aliveAt
}
_, err = rawPool(t).Exec(context.Background(),
`UPDATE uploaded_transcripts SET progress = $2, progress_at = $3, alive_at = $4 WHERE id = $1`,
id, b, progressAt, alive)
require.NoError(t, err)
}
func step(key, label string, state domain.StepState, startedAgo, ranFor time.Duration, done, total int) domain.ProgressStep {
s := domain.ProgressStep{Key: key, Label: label, State: state, Done: done, Total: total}
if state != domain.StepPending {
s.StartedAt = progressNow.Add(-startedAgo)
}
if state == domain.StepDone || state == domain.StepFailed {
s.FinishedAt = s.StartedAt.Add(ranFor)
}
return s
}
// twoEngineRun is a job in its second engine: three steps done, one running, three to come.
func twoEngineRun() []domain.ProgressStep {
return []domain.ProgressStep{
step("received", "Received", domain.StepDone, 5*time.Minute, 0, 0, 0),
step("audio", "Preparing the audio", domain.StepDone, 5*time.Minute, 40*time.Second, 0, 0),
step("engine_a", "Transcribing with iguana/pianissimo", domain.StepDone, 4*time.Minute, 38*time.Second, 0, 0),
step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepRunning, 3*time.Minute+12*time.Second, 0, 0, 0),
step("compare", "Comparing the two transcripts", domain.StepPending, 0, 0, 0, 0),
step("decide", "Deciding the differences", domain.StepPending, 0, 0, 0, 0),
step("save", "Saving the transcript", domain.StepPending, 0, 0, 0, 0),
}
}
func statusBody(t *testing.T, app *web.App, id string) string {
t.Helper()
rec := do(t, app, get("/uploads/"+id+"/status"))
require.Equal(t, 200, rec.Code)
return body(t, rec)
}
func TestUploadProgress_ARunningJobShowsEveryPhaseInOrderWithTheRunningOneMarked(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "transcribing", "")
seedProgress(t, id, twoEngineRun(), progressNow.Add(-10*time.Second), progressNow.Add(-4*time.Second))
b := statusBody(t, app, id)
last := -1
for _, label := range []string{
"Received", "Preparing the audio", "Transcribing with iguana/pianissimo", "Transcribing with iguana/kb-whisper",
"Comparing the two transcripts", "Deciding the differences", "Saving the transcript",
} {
i := strings.Index(b, label)
require.Greater(t, i, last, "phase %q missing or out of order:\n%s", label, b)
last = i
}
assert.Equal(t, 3, strings.Count(b, "step-done"), "three finished phases collapse to a done line")
assert.Equal(t, 1, strings.Count(b, `aria-current="step"`), "exactly one phase is the current one")
assert.Contains(t, b, "3m 12s", "the running phase shows how long it has run")
assert.Contains(t, b, "38s", "a finished phase shows how long it took")
assert.Contains(t, b, `hx-trigger="every 2s"`, "an unfinished job re-asks every two seconds")
}
func TestUploadProgress_OnlyACountedPhaseGetsABarAndItShowsRealCounts(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "transcribing", "")
steps := twoEngineRun()
steps[3] = step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepDone, 3*time.Minute, 2*time.Minute, 0, 0)
steps[4] = step("compare", "Comparing the two transcripts", domain.StepDone, 1*time.Minute, time.Second, 0, 0)
steps[5] = step("decide", "Deciding the differences", domain.StepRunning, 50*time.Second, 0, 142, 282)
seedProgress(t, id, steps, progressNow.Add(-5*time.Second), progressNow.Add(-3*time.Second))
b := statusBody(t, app, id)
assert.Contains(t, b, `<progress`)
assert.Contains(t, b, `value="142"`)
assert.Contains(t, b, `max="282"`)
assert.Contains(t, b, "142 of 282")
// ...and a phase with no honest count never gets one: no percentage out of thin air.
app2, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app2)
id2 := seedUpload(t, userID, "m2", "transcribing", "")
seedProgress(t, id2, twoEngineRun(), progressNow.Add(-10*time.Second), progressNow.Add(-4*time.Second))
assert.NotContains(t, statusBody(t, app2, id2), "<progress", "an engine call is one request: there is nothing to count")
}
func TestUploadProgress_ALongEngineCallWithAFreshHeartbeatIsStillJustRunning(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "transcribing", "")
steps := twoEngineRun()
steps[3] = step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepRunning, 25*time.Minute, 0, 0, 0)
seedProgress(t, id, steps, progressNow.Add(-25*time.Minute), progressNow.Add(-3*time.Second))
b := statusBody(t, app, id)
assert.Contains(t, b, "25m 00s")
assert.NotContains(t, b, "step-health-lost")
assert.NotContains(t, b, "step-health-quiet",
"a single blocking request has no sub-progress to go quiet about; the heartbeat says the service is alive")
}
func TestUploadProgress_AHeartbeatThatStoppedMeansTheServiceIsLostAndTheJobIsNotCalledRunning(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "transcribing", "")
seedProgress(t, id, twoEngineRun(), progressNow.Add(-3*time.Minute), progressNow.Add(-3*time.Minute))
b := statusBody(t, app, id)
assert.Contains(t, b, "step-health-lost")
assert.Contains(t, b, "stopped reporting")
assert.Contains(t, b, "3 min", "how long ago it was last heard from")
assert.NotContains(t, b, "spinner", "a lost job must not wear the running spinner")
assert.Contains(t, b, "step-mark-warn", "the running phase of a lost job carries a warning mark, in the error colour, not the accent")
assert.Contains(t, b, `hx-trigger="every`, "it keeps asking: a restarted service comes back, or the start-up sweep fails the row")
}
func TestUploadProgress_ACountedPhaseThatStopsAdvancingGoesQuietNotLost(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "transcribing", "")
steps := twoEngineRun()
steps[5] = step("decide", "Deciding the differences", domain.StepRunning, 20*time.Minute, 0, 142, 282)
seedProgress(t, id, steps, progressNow.Add(-6*time.Minute), progressNow.Add(-3*time.Second))
b := statusBody(t, app, id)
assert.Contains(t, b, "step-health-quiet")
assert.Contains(t, b, "Nothing new for 6 min")
assert.NotContains(t, b, "stopped reporting", "the service is alive; it is the model that is slow")
}
func TestUploadProgress_BeforeTheFirstHeartbeatAJobIsRunningUntilItHasBeenSilentTooLong(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
fresh := seedUpload(t, userID, "fresh", "transcribing", "")
seedProgress(t, fresh, twoEngineRun(), progressNow.Add(-5*time.Second), time.Time{})
silent := seedUpload(t, userID, "silent", "transcribing", "")
seedProgress(t, silent, twoEngineRun(), progressNow.Add(-2*time.Minute), time.Time{})
assert.NotContains(t, statusBody(t, app, fresh), "step-health-lost", "the first beat has not been due yet")
assert.Contains(t, statusBody(t, app, silent), "step-health-lost", "no beat and no progress for two minutes is a lost service")
}
func TestUploadProgress_AQueuedJobShowsNoPhasesAndNoLostWarning(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "queued", "")
b := statusBody(t, app, id)
assert.Contains(t, b, "Queued")
assert.NotContains(t, b, `class="steps"`)
assert.NotContains(t, b, "step-health")
assert.Contains(t, b, `hx-trigger="every 2s"`)
}
func TestUploadProgress_AFailedJobShowsWhichPhaseFailedAndWhatNeverStarted(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "failed", "transcription_failed")
steps := twoEngineRun()
steps[3] = step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepFailed, 3*time.Minute, time.Minute, 0, 0)
seedProgress(t, id, steps, progressNow.Add(-2*time.Minute), progressNow.Add(-2*time.Minute))
b := statusBody(t, app, id)
assert.Contains(t, b, "Transcription failed")
assert.Contains(t, b, "step-failed")
assert.Contains(t, b, "step-pending", "what never started says where the job stopped")
assert.NotContains(t, b, "hx-trigger", "a failed job stops polling")
assert.NotContains(t, b, "step-health", "a finished job is not 'lost'")
}
func TestUploadProgress_ADoneJobKeepsItsRecordOfWhatWasDoneAndHowLongEachTook(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
steps := []domain.ProgressStep{
step("received", "Received", domain.StepDone, 20*time.Minute, 0, 0, 0),
step("audio", "Preparing the audio", domain.StepDone, 20*time.Minute, 40*time.Second, 0, 0),
step("decide", "Deciding the differences", domain.StepDone, 15*time.Minute, 9*time.Minute+30*time.Second, 282, 282),
step("save", "Saving the transcript", domain.StepSkipped, 0, 0, 0, 0),
}
seedProgress(t, id, steps, progressNow.Add(-time.Minute), progressNow.Add(-time.Minute))
b := statusBody(t, app, id)
assert.Contains(t, b, "Synthetic transcript text.")
assert.Contains(t, b, "What was done")
assert.Contains(t, b, "9m 30s")
assert.Contains(t, b, "step-skipped")
assert.NotContains(t, b, "hx-trigger", "a finished upload must not poll forever")
assert.NotContains(t, b, "step-health", "a finished job is never 'lost' however old its heartbeat")
assert.NotContains(t, b, "spinner")
}
// stepsBlock is the progress list of a page: from its opening tag to its closing one.
func stepsBlock(t *testing.T, html string) string {
t.Helper()
start := strings.Index(html, `<ol class="steps"`)
require.GreaterOrEqual(t, start, 0, "no progress list in:\n%s", html)
end := strings.Index(html[start:], "</ol>")
require.GreaterOrEqual(t, end, 0)
return html[start : start+end+len("</ol>")]
}
func TestUploadProgress_TheFullPageCarriesTheVerySameProgressListAsTheFragment(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "transcribing", "")
seedProgress(t, id, twoEngineRun(), progressNow.Add(-10*time.Second), progressNow.Add(-4*time.Second))
full := body(t, do(t, app, get("/uploads/"+id)))
fragment := statusBody(t, app, id)
assert.Equal(t, stepsBlock(t, fragment), stepsBlock(t, full),
"the page a user opens and the fragment HTMX swaps in must show the same list, or the display jumps on the first poll")
}
func TestUploadProgress_AStepNoteIsShownDirectlyUnderItsStepAndEscaped(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "transcribing", "")
steps := twoEngineRun()
steps[1].Note = "The audio is 20 min long; the file claimed 1 h 19 min. <b>cut off</b>?"
seedProgress(t, id, steps, progressNow.Add(-10*time.Second), progressNow.Add(-4*time.Second))
b := statusBody(t, app, id)
audio := strings.Index(b, "Preparing the audio")
note := strings.Index(b, "The audio is 20 min long")
next := strings.Index(b, "Transcribing with iguana/pianissimo")
require.True(t, audio >= 0 && note > audio && next > note, "the note must sit between its own step and the next one:\n%s", b)
assert.Contains(t, b, "step-note")
assert.NotContains(t, b, "<b>cut off</b>", "a note is words, never markup")
}
func TestUploadProgress_ADonePageShowsItsRecordOpenWhenAStepHasANote(t *testing.T) {
// A truncated recording must not look like a clean one: the note that replaced the
// refusal is the only warning, so it cannot sit inside a collapsed list (third review).
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
noted := seedUpload(t, userID, "noted", "done", "Synthetic transcript text.")
steps := []domain.ProgressStep{step("audio", "Preparing the audio", domain.StepDone, 20*time.Minute, 40*time.Second, 0, 0)}
steps[0].Note = "The audio is 20 min long; the file claimed 1 h 19 min."
seedProgress(t, noted, steps, progressNow.Add(-time.Minute), progressNow.Add(-time.Minute))
clean := seedUpload(t, userID, "clean", "done", "Synthetic transcript text.")
seedProgress(t, clean, []domain.ProgressStep{step("audio", "Preparing the audio", domain.StepDone, 20*time.Minute, 40*time.Second, 0, 0)}, progressNow.Add(-time.Minute), progressNow.Add(-time.Minute))
assert.Contains(t, statusBody(t, app, noted), `<details class="what-was-done" open>`)
assert.NotContains(t, statusBody(t, app, clean), `<details class="what-was-done" open>`, "a clean record stays collapsed")
}