package transcribe_test import ( "bytes" "context" "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "git.d-ma.be/mathias/tapir/internal/adapters/transcribe" "git.d-ma.be/mathias/tapir/internal/domain" "git.d-ma.be/mathias/tapir/internal/errkind" ) // The httptest server stands in for the LiteLLM gateway: it is the process // boundary, so a fake here is legitimate. The JSON it returns mirrors what the // real gateway returned for iguana/kb-whisper and berget/kb-whisper on 2026-10-01 // (docs/research/audio-length-ceiling-2026-10-01.md). All text is synthetic. const verboseJSON = `{ "text": " God morgon allihopa.\n Vi går igenom läget.\n", "language": "swedish", "duration": 4.25, "segments": [ {"id": 0, "start": 0.0, "end": 1.0, "text": " God morgon allihopa."}, {"id": 1, "start": 1.0, "end": 9.0, "text": " Vi går igenom läget."} ] }` func wavFile(t *testing.T, content string) string { t.Helper() p := filepath.Join(t.TempDir(), "a.wav") require.NoError(t, os.WriteFile(p, []byte(content), 0o600)) return p } type seen struct { method, path, auth, expect, contentType string contentLength int64 transferEncoding []string fields map[string]string file string fileField string bodyLen int } // serve starts a gateway fake that records the request it received and replies // with status and body. func serve(t *testing.T, status int, reply string, got *seen) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got != nil { got.method, got.path = r.Method, r.URL.Path got.auth = r.Header.Get("Authorization") got.expect = r.Header.Get("Expect") got.contentType = r.Header.Get("Content-Type") got.contentLength = r.ContentLength got.transferEncoding = r.TransferEncoding raw, _ := io.ReadAll(r.Body) got.bodyLen = len(raw) r.Body = io.NopCloser(bytes.NewReader(raw)) require.NoError(t, r.ParseMultipartForm(32<<20)) got.fields = map[string]string{} for k, v := range r.MultipartForm.Value { got.fields[k] = v[0] } for field, fhs := range r.MultipartForm.File { got.fileField = field f, _ := fhs[0].Open() b, _ := io.ReadAll(f) got.file = string(b) } } w.WriteHeader(status) _, _ = io.WriteString(w, reply) })) t.Cleanup(srv.Close) return srv } func TestGateway_SendsTheRequestTheGatewayExpects(t *testing.T) { var got seen srv := serve(t, 200, verboseJSON, &got) g := transcribe.NewGateway(srv.URL+"/v1", "secret-key", "iguana/kb-whisper", srv.Client()) _, err := g.Transcribe(context.Background(), wavFile(t, "RIFF-synthetic-pcm")) require.NoError(t, err) assert.Equal(t, http.MethodPost, got.method) assert.Equal(t, "/v1/audio/transcriptions", got.path) assert.Equal(t, "Bearer secret-key", got.auth) assert.Equal(t, "file", got.fileField, "the multipart field is `file`; `audio_file` is rejected as 'Invalid request'") assert.Equal(t, "RIFF-synthetic-pcm", got.file) assert.Equal(t, "iguana/kb-whisper", got.fields["model"]) assert.Equal(t, "verbose_json", got.fields["response_format"]) assert.Equal(t, "sv", got.fields["language"]) assert.Empty(t, got.expect, "no Expect: 100-continue: it trips the proxy for any body over 1 KB") } func TestGateway_SendsAKnownContentLengthNotChunked(t *testing.T) { var got seen srv := serve(t, 200, verboseJSON, &got) g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client()) _, err := g.Transcribe(context.Background(), wavFile(t, "0123456789")) require.NoError(t, err) assert.Empty(t, got.transferEncoding, "a 90-minute WAV is ~170 MB: stream it with a computed length, not chunked and not buffered") assert.Equal(t, int64(got.bodyLen), got.contentLength) } func TestGateway_NormalisesTheVerboseJSONResponse(t *testing.T) { srv := serve(t, 200, verboseJSON, nil) g := transcribe.NewGateway(srv.URL+"/v1", "k", "iguana/kb-whisper", srv.Client()) got, err := g.Transcribe(context.Background(), wavFile(t, "x")) require.NoError(t, err) assert.Equal(t, "God morgon allihopa. Vi går igenom läget.", got.Text) assert.Equal(t, "iguana/kb-whisper", got.Endpoint, "the serving endpoint is part of the result") assert.InDelta(t, 4.25, got.AudioSeconds, 1e-9) assert.Equal(t, []domain.Segment{ {Start: 0, End: 1, Text: "God morgon allihopa."}, // The final segment ended at 9.0 s for 4.25 s of audio: whisper's last // window can run past the end. Clamped to the audio duration. {Start: 1, End: 4.25, Text: "Vi går igenom läget."}, }, got.Segments) } func TestGateway_ErrorsDoNotDependOnTheBody(t *testing.T) { // {"error":"failed to process audio"} has had at least five distinct causes, so // no code may branch on it. Two different bodies must be treated identically. for _, reply := range []string{`{"error":"failed to process audio"}`, `{"error":"something else entirely"}`} { srv := serve(t, 500, reply, nil) g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client()) _, err := g.Transcribe(context.Background(), wavFile(t, "x")) var se *transcribe.StatusError require.ErrorAs(t, err, &se) assert.Equal(t, 500, se.Code) } } func TestGateway_AnEmptyTranscriptIsAFailureNotASuccess(t *testing.T) { srv := serve(t, 200, `{"text":" ","language":"swedish","duration":3,"segments":[]}`, nil) g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client()) _, err := g.Transcribe(context.Background(), wavFile(t, "x")) assert.ErrorIs(t, err, transcribe.ErrEmptyTranscript) } func TestGateway_MalformedJSONIsAnError(t *testing.T) { srv := serve(t, 200, `not json`, nil) g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client()) _, err := g.Transcribe(context.Background(), wavFile(t, "x")) assert.Error(t, err) } func TestGateway_MissingAudioFileIsAnError(t *testing.T) { g := transcribe.NewGateway("http://127.0.0.1:1/v1", "k", "m", http.DefaultClient) _, err := g.Transcribe(context.Background(), filepath.Join(t.TempDir(), "nope.wav")) assert.Error(t, err) } // whisper.cpp wraps its output at ~60 characters, and a wrap can land INSIDE a word: // the continuation segment has no leading space, the `text` field joins segments // with newlines, and a segment can be a lone ".". Measured on real gateway output // 2026-10-01: 7% of a one-hour recording's segments were such fragments. Joining by // newline hands the chat model broken words ("nyt\nt"), so Text is rebuilt from the // segments: a segment that begins with a space starts a new word, one that does not // continues the previous one. func TestGateway_RejoinsWordsWhisperCppSplitAcrossSegments(t *testing.T) { reply := `{ "text": " Vi ligger just nu 12 procent över den ursprungliga kalkylen\n.\n Det kräver ett nyt\nt godkännande från beställaren.\n Framförallt på arbetspl\natsen.\n", "language": "swedish", "duration": 20, "segments": [ {"start": 0, "end": 4, "text": " Vi ligger just nu 12 procent över den ursprungliga kalkylen"}, {"start": 4, "end": 5, "text": "."}, {"start": 5, "end": 8, "text": " Det kräver ett nyt"}, {"start": 8, "end": 11, "text": "t godkännande från beställaren."}, {"start": 11, "end": 14, "text": " Framförallt på arbetspl"}, {"start": 14, "end": 16, "text": "atsen."} ]}` srv := serve(t, 200, reply, nil) g := transcribe.NewGateway(srv.URL+"/v1", "k", "iguana/kb-whisper", srv.Client()) got, err := g.Transcribe(context.Background(), wavFile(t, "x")) require.NoError(t, err) assert.Equal(t, "Vi ligger just nu 12 procent över den ursprungliga kalkylen. "+ "Det kräver ett nytt godkännande från beställaren. Framförallt på arbetsplatsen.", got.Text) assert.Len(t, got.Segments, 6, "the timed segments are kept as the backend gave them (trimmed)") assert.Equal(t, ".", got.Segments[1].Text) } func TestGateway_SentenceLevelSegmentsWithNoLeadingSpaceAreJoinedWithASpace(t *testing.T) { // berget/kb-whisper returns whole sentences with no leading space; rebuilding by // concatenation would glue them ("allihopa!Vi"). reply := `{"text": "God morgon allihopa!\nVi går igenom läget.", "language": "sv", "duration": 5, "segments": [ {"start": 0.05, "end": 1.2, "text": "God morgon allihopa!"}, {"start": 1.6, "end": 4.2, "text": "Vi går igenom läget."} ]}` srv := serve(t, 200, reply, nil) g := transcribe.NewGateway(srv.URL+"/v1", "k", "berget/kb-whisper", srv.Client()) got, err := g.Transcribe(context.Background(), wavFile(t, "x")) require.NoError(t, err) assert.Equal(t, "God morgon allihopa! Vi går igenom läget.", got.Text) } func TestGateway_WithNoSegmentsTheTextFieldIsUsedWithWhitespaceCollapsed(t *testing.T) { srv := serve(t, 200, `{"text": " Hej allihopa.\n Vi börjar. ", "duration": 3, "segments": []}`, nil) g := transcribe.NewGateway(srv.URL+"/v1", "k", "m", srv.Client()) got, err := g.Transcribe(context.Background(), wavFile(t, "x")) require.NoError(t, err) assert.Equal(t, "Hej allihopa. Vi börjar.", got.Text) } func TestStatusError_ExposesItsCodeAsAKindAndNotItsSnippet(t *testing.T) { err := fmt.Errorf("transcribe: %w", &transcribe.StatusError{Code: 502, Snippet: "echoed: Anders ansvarar"}) assert.Equal(t, "http_502", errkind.Of(err)) } func TestGateway_AnErrorBodyIsBoundedToTwoHundredBytesInTheStatusError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(strings.Repeat("x", 100_000))) })) defer srv.Close() wav := filepath.Join(t.TempDir(), "a.wav") require.NoError(t, os.WriteFile(wav, []byte("RIFF....WAVEfmt "), 0o600)) _, err := transcribe.NewGateway(srv.URL, "", "iguana/kb-whisper", srv.Client()).Transcribe(context.Background(), wav) var se *transcribe.StatusError require.ErrorAs(t, err, &se) assert.LessOrEqual(t, len(se.Snippet), 200, "a gateway's whole body must not become part of an error") } // The language travels on the context (tapir#61); the request field is the only place it // takes effect, so these observe the request the gateway fake received. func TestGateway_SendsLanguageSvWhenTheContextCarriesNoneAndForSwedish(t *testing.T) { for name, ctx := range map[string]context.Context{ "no language in the context": context.Background(), "swedish": domain.WithLanguage(context.Background(), domain.LangSwedish), } { t.Run(name, func(t *testing.T) { var got seen srv := serve(t, 200, verboseJSON, &got) g := transcribe.NewGateway(srv.URL+"/v1", "k", "iguana/kb-whisper", srv.Client()) _, err := g.Transcribe(ctx, wavFile(t, "RIFF-synthetic-pcm")) require.NoError(t, err) assert.Equal(t, "sv", got.fields["language"]) }) } } func TestGateway_OmitsTheLanguageFieldForAnAutoContextSoTheEngineDetectsIt(t *testing.T) { var got seen srv := serve(t, 200, verboseJSON, &got) g := transcribe.NewGateway(srv.URL+"/v1", "k", "iguana/kb-whisper", srv.Client()) _, err := g.Transcribe(domain.WithLanguage(context.Background(), domain.LangAuto), wavFile(t, "RIFF-synthetic-pcm")) require.NoError(t, err) _, present := got.fields["language"] assert.False(t, present, "an auto upload must not be forced to Swedish") assert.Equal(t, "iguana/kb-whisper", got.fields["model"], "the rest of the request is unchanged") } func TestGateway_ALanguageOutsideTheClosedSetIsRefusedBeforeAnythingIsSent(t *testing.T) { var got seen srv := serve(t, 200, verboseJSON, &got) g := transcribe.NewGateway(srv.URL+"/v1", "k", "iguana/kb-whisper", srv.Client()) _, err := g.Transcribe(domain.WithLanguage(context.Background(), domain.Language("en")), wavFile(t, "RIFF-synthetic-pcm")) require.Error(t, err) assert.Empty(t, got.method, "no request may reach the engine with a language the closed set does not hold") }