Files
tapir/internal/web/uploads_progress_handler_test.go
T

244 lines
10 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, `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")
}
func TestUploadProgress_TheFullPageRendersTheSameDisplayAsTheFragment(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))
rec := do(t, app, get("/uploads/"+id))
require.Equal(t, 200, rec.Code)
b := body(t, rec)
assert.Contains(t, b, "Transcribing with iguana/kb-whisper", "the page a user opens, not only the polled fragment, shows the phases")
assert.Contains(t, b, "3m 12s")
}