package whisperx import ( "context" "encoding/json" "errors" "io" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "time" ) func TestHTTPClientTranscribeSuccess(t *testing.T) { var gotLanguage string var gotFileField string var gotFileSize int var gotFileData string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Fatalf("method = %s, want POST", r.Method) } if err := r.ParseMultipartForm(4 << 20); err != nil { t.Fatalf("ParseMultipartForm() error = %v", err) } gotLanguage = r.FormValue("language") file, fh, err := r.FormFile("file") if err != nil { t.Fatalf("FormFile(file) error = %v", err) } defer file.Close() gotFileField = fh.Filename data, err := io.ReadAll(file) if err != nil { t.Fatalf("ReadAll(file) error = %v", err) } gotFileSize = len(data) gotFileData = string(data) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"schema":"speaker_transcript.v1","segments":[]}`)) })) defer srv.Close() client, err := NewHTTPClientFromConfigValues(srv.URL, "en", "2s", "10ms", 0) if err != nil { t.Fatalf("NewHTTPClientFromConfigValues() error = %v", err) } audioPath := writeWhisperXTestFile(t, "audio.flac", "audio-data") outPath := filepath.Join(t.TempDir(), "raw.json") res, err := client.Transcribe(context.Background(), TranscribeRequest{ SpeakerID: "alice", AudioPath: audioPath, OutputRawTranscriptPath: outPath, }) if err != nil { t.Fatalf("Transcribe() error = %v", err) } if res.Attempts != 1 { t.Fatalf("Attempts = %d, want 1", res.Attempts) } if res.HTTPStatus != http.StatusOK { t.Fatalf("HTTPStatus = %d, want %d", res.HTTPStatus, http.StatusOK) } if gotLanguage != "en" { t.Fatalf("language = %q, want en", gotLanguage) } if gotFileField != "audio.flac" { t.Fatalf("file field filename = %q, want audio.flac", gotFileField) } if gotFileSize == 0 { t.Fatal("file size = 0, want >0") } if gotFileData != "audio-data" { t.Fatalf("file data = %q, want exact payload", gotFileData) } verifyJSONFile(t, outPath) } func TestHTTPClientRetriesOnTransientAndSucceeds(t *testing.T) { var calls atomic.Int32 var payloads []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n := calls.Add(1) file, _, err := r.FormFile("file") if err != nil { t.Fatalf("FormFile(file) error = %v", err) } data, err := io.ReadAll(file) _ = file.Close() if err != nil { t.Fatalf("ReadAll(file) error = %v", err) } payloads = append(payloads, string(data)) if n == 1 { http.Error(w, "temporary", http.StatusInternalServerError) return } _, _ = w.Write([]byte(`{"ok":true}`)) })) defer srv.Close() client, err := NewHTTPClientFromConfigValues(srv.URL, "en", "2s", "1ms", 2) if err != nil { t.Fatalf("NewHTTPClientFromConfigValues() error = %v", err) } audioPath := writeWhisperXTestFile(t, "audio.flac", "audio-data") outPath := filepath.Join(t.TempDir(), "raw.json") res, err := client.Transcribe(context.Background(), TranscribeRequest{ AudioPath: audioPath, OutputRawTranscriptPath: outPath, }) if err != nil { t.Fatalf("Transcribe() error = %v", err) } if res.Attempts != 2 { t.Fatalf("Attempts = %d, want 2", res.Attempts) } if calls.Load() != 2 { t.Fatalf("calls = %d, want 2", calls.Load()) } if len(payloads) != 2 || payloads[0] != "audio-data" || payloads[1] != "audio-data" { t.Fatalf("retry payloads = %#v, want two exact audio payloads", payloads) } verifyJSONFile(t, outPath) } func TestHTTPClientDoesNotRetryOnNonRetryableStatus(t *testing.T) { var calls atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) http.Error(w, "bad request", http.StatusBadRequest) })) defer srv.Close() client, err := NewHTTPClientFromConfigValues(srv.URL, "en", "2s", "1ms", 3) if err != nil { t.Fatalf("NewHTTPClientFromConfigValues() error = %v", err) } audioPath := writeWhisperXTestFile(t, "audio.flac", "audio-data") outPath := filepath.Join(t.TempDir(), "raw.json") _, err = client.Transcribe(context.Background(), TranscribeRequest{ AudioPath: audioPath, OutputRawTranscriptPath: outPath, }) if err == nil { t.Fatal("Transcribe() error = nil, want non-nil") } if calls.Load() != 1 { t.Fatalf("calls = %d, want 1", calls.Load()) } if _, statErr := os.Stat(outPath); !errors.Is(statErr, os.ErrNotExist) { t.Fatalf("output should not exist, stat err = %v", statErr) } } func TestHTTPClientInvalidJSONFailsAndDoesNotInstallOutput(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`not-json`)) })) defer srv.Close() client, err := NewHTTPClientFromConfigValues(srv.URL, "en", "2s", "1ms", 0) if err != nil { t.Fatalf("NewHTTPClientFromConfigValues() error = %v", err) } audioPath := writeWhisperXTestFile(t, "audio.flac", "audio-data") outPath := filepath.Join(t.TempDir(), "raw.json") _, err = client.Transcribe(context.Background(), TranscribeRequest{ AudioPath: audioPath, OutputRawTranscriptPath: outPath, }) if err == nil { t.Fatal("Transcribe() error = nil, want non-nil") } if !strings.Contains(strings.ToLower(err.Error()), "json") { t.Fatalf("error = %q, want json context", err.Error()) } if _, statErr := os.Stat(outPath); !errors.Is(statErr, os.ErrNotExist) { t.Fatalf("output should not exist, stat err = %v", statErr) } } func TestHTTPClientTimeoutRetryDeterministic(t *testing.T) { var calls atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) time.Sleep(150 * time.Millisecond) _, _ = w.Write([]byte(`{"ok":true}`)) })) defer srv.Close() client, err := NewHTTPClientFromConfigValues(srv.URL, "en", "50ms", "1ms", 1) if err != nil { t.Fatalf("NewHTTPClientFromConfigValues() error = %v", err) } audioPath := writeWhisperXTestFile(t, "audio.flac", "audio-data") outPath := filepath.Join(t.TempDir(), "raw.json") res, err := client.Transcribe(context.Background(), TranscribeRequest{ AudioPath: audioPath, OutputRawTranscriptPath: outPath, }) if err == nil { t.Fatal("Transcribe() error = nil, want timeout error") } if calls.Load() != 2 { t.Fatalf("calls = %d, want 2 attempts", calls.Load()) } if res.Attempts != 2 { t.Fatalf("Attempts = %d, want 2", res.Attempts) } } func TestHTTPClientCancelDoesNotRetry(t *testing.T) { var calls atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) time.Sleep(100 * time.Millisecond) _, _ = w.Write([]byte(`{"ok":true}`)) })) defer srv.Close() client, err := NewHTTPClientFromConfigValues(srv.URL, "en", "2s", "1ms", 3) if err != nil { t.Fatalf("NewHTTPClientFromConfigValues() error = %v", err) } audioPath := writeWhisperXTestFile(t, "audio.flac", "audio-data") outPath := filepath.Join(t.TempDir(), "raw.json") ctx, cancel := context.WithCancel(context.Background()) cancel() _, err = client.Transcribe(ctx, TranscribeRequest{ AudioPath: audioPath, OutputRawTranscriptPath: outPath, }) if err == nil { t.Fatal("Transcribe() error = nil, want cancellation error") } if calls.Load() > 1 { t.Fatalf("calls = %d, want <= 1 when already canceled", calls.Load()) } } func TestHTTPClientConstructorValidation(t *testing.T) { _, err := NewHTTPClientFromConfigValues("", "en", "30m", "2s", 1) if err == nil { t.Fatal("expected missing URL error") } _, err = NewHTTPClientFromConfigValues("https://example.com", "en", "bad", "2s", 1) if err == nil { t.Fatal("expected bad timeout error") } _, err = NewHTTPClientFromConfigValues("https://example.com", "en", "30m", "bad", 1) if err == nil { t.Fatal("expected bad retry_delay error") } for _, endpoint := range []string{"ftp://example.com/transcribe", "file:///tmp/transcribe", "//example.com/transcribe", "https:/missing-host"} { if _, err := NewHTTPClientFromConfigValues(endpoint, "en", "30m", "2s", 1); err == nil { t.Errorf("NewHTTPClientFromConfigValues(%q) error = nil, want endpoint validation error", endpoint) } } for _, endpoint := range []string{"http://example.com/transcribe", "https://example.com/transcribe"} { if _, err := NewHTTPClientFromConfigValues(endpoint, "en", "30m", "2s", 1); err != nil { t.Errorf("NewHTTPClientFromConfigValues(%q) error = %v", endpoint, err) } } } func TestHTTPClientStreamsUploadBeforeSourceCompletes(t *testing.T) { release := make(chan struct{}) source := newGatedReadCloser([]byte("audio-data"), release) firstByteReceived := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { part := firstMultipartFilePart(t, r) buf := make([]byte, 1) if _, err := part.Read(buf); err != nil { t.Errorf("Read(file) error = %v", err) return } close(firstByteReceived) if _, err := io.Copy(io.Discard, part); err != nil { t.Errorf("discard remaining file data: %v", err) return } _, _ = w.Write([]byte(`{"ok":true}`)) })) defer srv.Close() client := newTestHTTPClient(t, srv.URL) client.openAudio = func(string) (io.ReadCloser, error) { return source, nil } done := make(chan error, 1) go func() { _, err := client.Transcribe(context.Background(), TranscribeRequest{AudioPath: "audio.flac", OutputRawTranscriptPath: filepath.Join(t.TempDir(), "raw.json")}) done <- err }() select { case <-firstByteReceived: close(release) case <-time.After(time.Second): t.Fatal("server did not receive streamed audio before source completed") } if err := <-done; err != nil { t.Fatalf("Transcribe() error = %v", err) } } func TestHTTPClientSourceReadFailureReachesCaller(t *testing.T) { sourceErr := errors.New("source read failed") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.Copy(io.Discard, r.Body) })) defer srv.Close() client := newTestHTTPClient(t, srv.URL) client.openAudio = func(string) (io.ReadCloser, error) { return &failingReadCloser{first: []byte("partial"), err: sourceErr}, nil } _, err := client.Transcribe(context.Background(), TranscribeRequest{AudioPath: "audio.flac", OutputRawTranscriptPath: filepath.Join(t.TempDir(), "raw.json")}) if !errors.Is(err, sourceErr) { t.Fatalf("Transcribe() error = %v, want source read failure", err) } } func TestHTTPClientEarlyServerResponseReleasesBlockedProducer(t *testing.T) { release := make(chan struct{}) source := newGatedReadCloser([]byte("audio-data"), release) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad request", http.StatusBadRequest) })) defer srv.Close() client := newTestHTTPClient(t, srv.URL) client.openAudio = func(string) (io.ReadCloser, error) { return source, nil } done := make(chan error, 1) go func() { _, err := client.Transcribe(context.Background(), TranscribeRequest{AudioPath: "audio.flac", OutputRawTranscriptPath: filepath.Join(t.TempDir(), "raw.json")}) done <- err }() select { case err := <-done: if err == nil { t.Fatal("Transcribe() error = nil, want HTTP status error") } case <-time.After(time.Second): t.Fatal("Transcribe() did not finish after server closed the request early") } select { case <-source.closed: case <-time.After(time.Second): t.Fatal("blocked audio source was not closed") } } func TestHTTPClientCancellationReleasesBlockedProducer(t *testing.T) { release := make(chan struct{}) source := newGatedReadCloser([]byte("audio-data"), release) firstByteReceived := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { part := firstMultipartFilePart(t, r) buf := make([]byte, 1) if _, err := part.Read(buf); err != nil { t.Errorf("Read(file) error = %v", err) return } close(firstByteReceived) <-r.Context().Done() })) defer srv.Close() client := newTestHTTPClient(t, srv.URL) client.openAudio = func(string) (io.ReadCloser, error) { return source, nil } ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { _, err := client.Transcribe(ctx, TranscribeRequest{AudioPath: "audio.flac", OutputRawTranscriptPath: filepath.Join(t.TempDir(), "raw.json")}) done <- err }() select { case <-firstByteReceived: cancel() case <-time.After(time.Second): cancel() t.Fatal("server did not receive initial streamed audio") } select { case err := <-done: if !errors.Is(err, context.Canceled) { t.Fatalf("Transcribe() error = %v, want context cancellation", err) } case <-time.After(time.Second): t.Fatal("Transcribe() did not finish after cancellation") } select { case <-source.closed: case <-time.After(time.Second): t.Fatal("blocked audio source was not closed on cancellation") } } func TestHTTPClientBoundsWhisperXResponse(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.Copy(io.Discard, r.Body) _, _ = w.Write([]byte(`{"ok":true}`)) })) defer srv.Close() client, err := NewHTTPClient(HTTPClientConfig{TranscribeURL: srv.URL, Language: "en", Timeout: time.Second, MaxResponseBytes: 4}) if err != nil { t.Fatalf("NewHTTPClient() error = %v", err) } audioPath := writeWhisperXTestFile(t, "audio.flac", "audio-data") _, err = client.Transcribe(context.Background(), TranscribeRequest{AudioPath: audioPath, OutputRawTranscriptPath: filepath.Join(t.TempDir(), "raw.json")}) if err == nil || !strings.Contains(err.Error(), "whisperx response exceeds configured limit") { t.Fatalf("Transcribe() error = %v, want bounded WhisperX response error", err) } } func newTestHTTPClient(t *testing.T, endpoint string) *HTTPClient { t.Helper() client, err := NewHTTPClientFromConfigValues(endpoint, "en", "2s", "1ms", 0) if err != nil { t.Fatalf("NewHTTPClientFromConfigValues() error = %v", err) } return client } func firstMultipartFilePart(t *testing.T, r *http.Request) *multipart.Part { t.Helper() reader, err := r.MultipartReader() if err != nil { t.Fatalf("MultipartReader() error = %v", err) } part, err := reader.NextPart() if err != nil { t.Fatalf("NextPart() error = %v", err) } if part.FormName() != "file" { t.Fatalf("first form field = %q, want file", part.FormName()) } return part } type gatedReadCloser struct { first []byte release <-chan struct{} closed chan struct{} sent bool once atomic.Bool } func newGatedReadCloser(first []byte, release <-chan struct{}) *gatedReadCloser { return &gatedReadCloser{first: first, release: release, closed: make(chan struct{})} } func (r *gatedReadCloser) Read(p []byte) (int, error) { if !r.sent { r.sent = true return copy(p, r.first), nil } select { case <-r.release: return 0, io.EOF case <-r.closed: return 0, errors.New("audio source closed") } } func (r *gatedReadCloser) Close() error { if r.once.CompareAndSwap(false, true) { close(r.closed) } return nil } type failingReadCloser struct { first []byte err error sent bool } func (r *failingReadCloser) Read(p []byte) (int, error) { if !r.sent { r.sent = true return copy(p, r.first), nil } return 0, r.err } func (r *failingReadCloser) Close() error { return nil } func writeWhisperXTestFile(t *testing.T, name, contents string) string { t.Helper() path := filepath.Join(t.TempDir(), name) if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { t.Fatalf("WriteFile(%q) error = %v", path, err) } return path } func verifyJSONFile(t *testing.T, path string) { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile(%q) error = %v", path, err) } var v any if err := json.Unmarshal(data, &v); err != nil { t.Fatalf("json unmarshal output %q: %v", path, err) } }