# frozen_string_literal: true # Zero-dependency test for RemoteTranscriber — a fake HTTP client simulates the hosted Whisper job # lifecycle (submit -> poll -> download), so the whole flow is verified with no network/mTLS/models. # Run: ruby components/transcription-worker/test_remote_transcriber.rb require "json" require_relative "remote_transcriber" $pass = 0; $fail = 0 def ok(d); $pass += 1; puts " ok #{d}"; end def bad(d, g = nil); $fail += 1; puts " FAIL #{d}#{g.nil? ? '' : " (#{g.inspect})"}"; end def eq(a, b, d); a == b ? ok(d) : bad(d, a); end def truthy(x, d); x ? ok(d) : bad(d, x); end # Scriptable fake of RemoteTranscriber's client contract: post_multipart + get -> [code, body]. class FakeClient attr_reader :fields, :file, :gets def initialize(statuses: ["done"], transcript: "x", submit_code: 201, job_id: "job-1", download_code: 200, status_code: 200) @statuses = statuses.dup; @transcript = transcript @submit_code = submit_code; @job_id = job_id @download_code = download_code; @status_code = status_code; @gets = 0 end def post_multipart(_path, file, fields) @file = file; @fields = fields [@submit_code, @job_id ? { job_id: @job_id }.to_json : "{}"] end def get(path) @gets += 1 return [@download_code, @transcript] if path.end_with?("/download") [@status_code, { status: (@statuses.shift || @statuses.last || "done") }.to_json] end end def rt(client, **opts) Helpdesk::RemoteTranscriber.new(base_url: "https://x/app", client: client, poll_interval: 0, **opts) end puts "happy path: submit -> running -> done -> download" c = FakeClient.new(statuses: %w[running running done], transcript: "[Speaker 1]: Dobrý den.\n") r = rt(c).transcribe("/tmp/call.wav") eq(r[:state], "transcribed", "state=transcribed") eq(r[:transcript], "[Speaker 1]: Dobrý den.\n", "transcript returned from download") eq(r[:job_id], "job-1", "job_id carried through") truthy(c.gets >= 4, "polled until done then downloaded") puts "submit sends the right options" eq(c.fields["language"], "cs", "language=cs") eq(c.fields["model"], "large-v2", "model=large-v2 default") eq(c.fields["min_speakers"], "2", "min_speakers=2") eq(c.fields["max_speakers"], "3", "max_speakers=3") eq(c.file, "/tmp/call.wav", "file path passed to submit") puts "empty transcript -> no_speech" r = rt(FakeClient.new(statuses: ["done"], transcript: " \n")).transcribe("/tmp/a.wav") eq(r[:state], "no_speech", "blank download -> no_speech") puts "remote error stage -> failed + retryable" r = rt(FakeClient.new(statuses: %w[running error])).transcribe("/tmp/a.wav") eq(r[:state], "failed", "error stage -> failed") truthy(r[:retryable], "failed is retryable (transient)") eq(r[:reason], "remote error", "reason names the remote stage") puts "cancelled/interrupted -> failed retryable" eq(rt(FakeClient.new(statuses: ["cancelled"])).transcribe("/tmp/a.wav")[:state], "failed", "cancelled -> failed") puts "submit non-201 -> failed (no polling)" c = FakeClient.new(submit_code: 400) r = rt(c).transcribe("/tmp/a.wav") eq(r[:state], "failed", "submit 400 -> failed") eq(c.gets, 0, "did not poll after a failed submit") puts "missing job_id -> failed" eq(rt(FakeClient.new(job_id: nil)).transcribe("/tmp/a.wav")[:state], "failed", "no job_id -> failed") puts "poll HTTP error -> failed" eq(rt(FakeClient.new(status_code: 503, statuses: ["running"])).transcribe("/tmp/a.wav")[:state], "failed", "status 503 -> failed") puts "download HTTP error -> failed" eq(rt(FakeClient.new(statuses: ["done"], download_code: 500)).transcribe("/tmp/a.wav")[:state], "failed", "download 500 -> failed") puts "poll timeout -> failed" r = rt(FakeClient.new(statuses: %w[running running running running running])).transcribe("/tmp/a.wav", timeout: 0) eq(r[:state], "failed", "never-terminal + timeout=0 -> failed") eq(r[:reason], "poll timeout", "reason=poll timeout") puts "drops into Pipeline as the transcriber (composition)" require_relative "../backend/pipeline" stub_summariser = Object.new def stub_summariser.summarise(_t, **) = { ok: true, summary: "Shrnutí hovoru.", action_items: [], model: "stub", cost: 0.0 } rtc = Helpdesk::RemoteTranscriber.new(base_url: "https://x/app", poll_interval: 0, client: FakeClient.new(statuses: %w[running done], transcript: "[Speaker 1]: Ahoj.\n")) res = Helpdesk::Pipeline.new(transcriber: rtc, summariser: stub_summariser).process("/tmp/a.wav") eq(res[:stage], "summarised", "Pipeline(remote) -> summarised") eq(res[:transcript], "[Speaker 1]: Ahoj.\n", "Pipeline carries the remote transcript") eq(res[:summary], "Shrnutí hovoru.", "Pipeline attaches the summary") # Scriptable poll fake: status_seq is a list of [http_code, status_string_or_nil] returned per status GET. class SeqClient attr_reader :gets def initialize(status_seq:, transcript: "[Speaker 1]: Ahoj.\n", download_code: 200) @seq = status_seq.dup; @transcript = transcript; @download_code = download_code; @gets = 0 end def post_multipart(_p, _f, _fields) = [201, { job_id: "job-1" }.to_json] def get(path) @gets += 1 return [@download_code, @transcript] if path.end_with?("/download") code, status = (@seq.shift || [200, "done"]) [code, status ? { status: status }.to_json : "upstream error"] end end puts "poll resilience: a transient blip mid-poll is retried, not aborted" c = SeqClient.new(status_seq: [[502, nil], [0, nil], [200, "running"], [200, "done"]]) r = rt(c).transcribe("/tmp/call.wav") eq(r[:state], "transcribed", "transient 502 then client-error(0) during poll -> retried -> done") eq(r[:transcript], "[Speaker 1]: Ahoj.\n", "recovered job still downloads the transcript") puts "poll resilience: persistent upstream failure gives up within a bounded budget (not infinite)" c = SeqClient.new(status_seq: Array.new(30) { [503, nil] }) r = rt(c).transcribe("/tmp/call.wav") eq(r[:state], "failed", "persistent 503 -> failed (retryable) rather than looping forever") truthy(c.gets <= Helpdesk::RemoteTranscriber::MAX_POLL_TRANSIENT + 2, "gave up within MAX_POLL_TRANSIENT budget (#{c.gets} polls)") puts "poll resilience: a definite 404 (job gone) stops immediately, no long retry" c = SeqClient.new(status_seq: [[200, "running"], [404, nil]]) r = rt(c).transcribe("/tmp/call.wav") eq(r[:state], "failed", "404 during poll -> failed immediately") eq(c.gets, 2, "no retry storm after a definite 404") puts "\n#{$pass} passed, #{$fail} failed" exit($fail.zero? ? 0 : 1)