af3377a6bc
Finishes the lift of shared helpers into `tests/common/`. Covered: - health, web, oneliners_api, upload (31 tests; upload exercises the new `multipart_upload_body` helper driven by doctate_common field constants) - sse_integration, sse_cleanup, transcribe, oneliner_heal_decoupled, silent_case_empty, failed_only_case_empty_oneliner, transient_failure_retries (24 tests) Also applied cargo fmt across the test tree and fixed one clippy needless_borrows_for_generic_args warning in analyze_test. All 387 tests pass; 3 ignored (as before). Side effect: health_test previously used a hardcoded `/tmp/doctate-test` data path, which parallel `cargo test` runs could collide on. The migration replaces it with the common unique-tmpdir pattern, removing a latent flake.
606 lines
18 KiB
Rust
606 lines
18 KiB
Rust
mod common;
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use doctate_server::config::WhisperUserSettings;
|
|
use doctate_server::transcribe;
|
|
use doctate_server::transcribe::ffmpeg::remux_faststart;
|
|
use doctate_server::transcribe::ollama::{OllamaError, generate_oneliner};
|
|
use doctate_server::transcribe::recovery::scan_and_enqueue;
|
|
use doctate_server::transcribe::whisper::{WhisperError, transcribe};
|
|
use serde_json::json;
|
|
use wiremock::matchers::{body_partial_json, method, path, query_param};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
use common::{TestConfig, test_user};
|
|
|
|
fn fixture(name: &str) -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("tests/fixtures")
|
|
.join(name)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ffmpeg_remux_produces_valid_output() {
|
|
let input = fixture("sample.m4a");
|
|
assert!(input.exists(), "fixture missing: {}", input.display());
|
|
|
|
let output = remux_faststart(&input).await.expect("remux failed");
|
|
|
|
let meta = std::fs::metadata(output.path()).expect("output missing");
|
|
assert!(meta.len() > 0, "output file is empty");
|
|
|
|
// Sanity check: ffprobe should still recognize it as an m4a/mp4 container.
|
|
let probe = std::process::Command::new("ffprobe")
|
|
.arg("-hide_banner")
|
|
.arg("-loglevel")
|
|
.arg("error")
|
|
.arg("-show_entries")
|
|
.arg("format=format_name")
|
|
.arg("-of")
|
|
.arg("default=nw=1:nk=1")
|
|
.arg(output.path())
|
|
.output()
|
|
.expect("ffprobe spawn failed");
|
|
assert!(probe.status.success(), "ffprobe failed");
|
|
let fmt = String::from_utf8_lossy(&probe.stdout);
|
|
assert!(
|
|
fmt.contains("mp4") || fmt.contains("m4a"),
|
|
"unexpected format: {fmt}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ffmpeg_remux_fails_on_missing_input() {
|
|
let input = PathBuf::from("/nonexistent/does-not-exist.m4a");
|
|
let result = remux_faststart(&input).await;
|
|
assert!(result.is_err(), "expected error for missing input");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn whisper_client_posts_multipart_and_returns_text() {
|
|
let server = MockServer::start().await;
|
|
let expected = "Hallo Welt, das ist ein Test.";
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/asr"))
|
|
.and(query_param("output", "txt"))
|
|
.and(query_param("language", "de"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(expected))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let text = transcribe(
|
|
&client,
|
|
&server.uri(),
|
|
&fixture("sample.m4a"),
|
|
Duration::from_secs(10),
|
|
&WhisperUserSettings::default(),
|
|
)
|
|
.await
|
|
.expect("transcribe failed");
|
|
|
|
assert_eq!(text, expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn whisper_client_returns_status_error_on_500() {
|
|
let server = MockServer::start().await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/asr"))
|
|
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let err = transcribe(
|
|
&client,
|
|
&server.uri(),
|
|
&fixture("sample.m4a"),
|
|
Duration::from_secs(10),
|
|
&WhisperUserSettings::default(),
|
|
)
|
|
.await
|
|
.expect_err("expected error");
|
|
|
|
match err {
|
|
WhisperError::Status { status, body } => {
|
|
assert_eq!(status, 500);
|
|
assert_eq!(body, "boom");
|
|
}
|
|
other => panic!("unexpected error variant: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn whisper_client_times_out() {
|
|
let server = MockServer::start().await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/asr"))
|
|
.respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(2)))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let err = transcribe(
|
|
&client,
|
|
&server.uri(),
|
|
&fixture("sample.m4a"),
|
|
Duration::from_millis(100),
|
|
&WhisperUserSettings::default(),
|
|
)
|
|
.await
|
|
.expect_err("expected timeout");
|
|
|
|
assert!(
|
|
matches!(err, WhisperError::Http(_)),
|
|
"expected Http error, got {err:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn whisper_client_forwards_hotwords_and_prompt_when_set() {
|
|
let server = MockServer::start().await;
|
|
|
|
// Accept any POST; inspect captured body below. language query param is
|
|
// still matched strictly to verify override from settings.
|
|
Mock::given(method("POST"))
|
|
.and(path("/asr"))
|
|
.and(query_param("language", "en"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let settings = WhisperUserSettings {
|
|
language: Some("en".into()),
|
|
hotwords: Some("HOCM Valsalva".into()),
|
|
initial_prompt: Some("Kardiologie".into()),
|
|
};
|
|
transcribe(
|
|
&client,
|
|
&server.uri(),
|
|
&fixture("sample.m4a"),
|
|
Duration::from_secs(10),
|
|
&settings,
|
|
)
|
|
.await
|
|
.expect("transcribe failed");
|
|
|
|
let received = server.received_requests().await.unwrap();
|
|
let body = String::from_utf8_lossy(&received[0].body);
|
|
assert!(body.contains("name=\"hotwords\""), "hotwords part missing");
|
|
assert!(
|
|
body.contains("HOCM Valsalva"),
|
|
"hotwords value missing: {body}"
|
|
);
|
|
assert!(
|
|
body.contains("name=\"initial_prompt\""),
|
|
"initial_prompt part missing"
|
|
);
|
|
assert!(body.contains("Kardiologie"), "initial_prompt value missing");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn whisper_client_omits_empty_optional_fields() {
|
|
let server = MockServer::start().await;
|
|
|
|
// Default settings = all None → only `audio_file` part, language defaults
|
|
// to `de`. Assert that neither optional field appears in the body.
|
|
Mock::given(method("POST"))
|
|
.and(path("/asr"))
|
|
.and(query_param("language", "de"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
transcribe(
|
|
&client,
|
|
&server.uri(),
|
|
&fixture("sample.m4a"),
|
|
Duration::from_secs(10),
|
|
&WhisperUserSettings::default(),
|
|
)
|
|
.await
|
|
.expect("transcribe failed");
|
|
|
|
// Inspect the captured request to confirm absence of the optional parts.
|
|
let received = server.received_requests().await.unwrap();
|
|
let body = String::from_utf8_lossy(&received[0].body);
|
|
assert!(
|
|
!body.contains("name=\"hotwords\""),
|
|
"hotwords part leaked: {body}"
|
|
);
|
|
assert!(
|
|
!body.contains("name=\"initial_prompt\""),
|
|
"initial_prompt part leaked: {body}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recovery_enqueues_only_pending_recordings() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path();
|
|
|
|
// User 1, case with two .m4a — one pending, one already transcribed.
|
|
let case_a = root.join("dr_a/aaaa");
|
|
std::fs::create_dir_all(&case_a).unwrap();
|
|
std::fs::write(case_a.join("2026-04-10T10-00-00Z.m4a"), b"x").unwrap();
|
|
std::fs::write(case_a.join("2026-04-11T10-00-00Z.m4a"), b"x").unwrap();
|
|
std::fs::write(case_a.join("2026-04-11T10-00-00Z.transcript.txt"), b"done").unwrap();
|
|
|
|
// User 2, case with one pending .m4a.
|
|
let case_b = root.join("dr_b/bbbb");
|
|
std::fs::create_dir_all(&case_b).unwrap();
|
|
std::fs::write(case_b.join("2026-04-12T10-00-00Z.m4a"), b"x").unwrap();
|
|
|
|
let (tx, mut rx) = transcribe::channel();
|
|
scan_and_enqueue(root, &tx).await;
|
|
drop(tx); // close channel so the loop below terminates
|
|
|
|
let mut received = Vec::new();
|
|
while let Some(job) = rx.recv().await {
|
|
received.push(job.audio_path);
|
|
}
|
|
|
|
assert_eq!(received.len(), 2, "got: {received:?}");
|
|
// Oldest first:
|
|
assert!(received[0].ends_with("2026-04-10T10-00-00Z.m4a"));
|
|
assert!(received[1].ends_with("2026-04-12T10-00-00Z.m4a"));
|
|
}
|
|
|
|
// -------------------- Worker: failure marker --------------------
|
|
|
|
fn config_with_whisper(whisper_url: String) -> std::sync::Arc<doctate_server::config::Config> {
|
|
// Ollama URL is irrelevant — worker never reaches it in the failure path.
|
|
// Port 1 is effectively unreachable, matching the historical intent.
|
|
TestConfig::new()
|
|
.with_label("transcribe")
|
|
.with_user(test_user("dr_test"))
|
|
.with_whisper(whisper_url, 5)
|
|
.with_ollama("http://127.0.0.1:1")
|
|
.build()
|
|
}
|
|
|
|
/// Permanent Whisper errors (4xx other than 408/429) must rename `.m4a` to
|
|
/// `.m4a.failed` so recovery treats the recording as terminal. HTTP 400 is
|
|
/// the archetypal "the payload itself is wrong" response — retrying the
|
|
/// same bytes will hit the same wall, so the file is marked and only a
|
|
/// manual admin reset un-fails it.
|
|
#[tokio::test]
|
|
async fn worker_renames_audio_to_failed_on_permanent_whisper_error() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/asr"))
|
|
.respond_with(ResponseTemplate::new(400).set_body_string("bad payload"))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// Real case dir with a real m4a fixture so ffmpeg remux succeeds.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let case_dir = tmp.path().join("dr_test/aaaa");
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
let audio = case_dir.join("2026-04-13T10-30-00Z.m4a");
|
|
std::fs::copy(fixture("sample.m4a"), &audio).unwrap();
|
|
|
|
let config = config_with_whisper(server.uri());
|
|
let (tx, rx) = transcribe::channel();
|
|
tx.send(transcribe::TranscribeJob {
|
|
audio_path: audio.clone(),
|
|
user_slug: "dr_test".into(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
drop(tx); // close channel so the worker loop exits after processing the job
|
|
|
|
let client = reqwest::Client::new();
|
|
let busy = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let vocab = Arc::new(doctate_server::gazetteer::Gazetteer::empty());
|
|
transcribe::worker::run(
|
|
rx,
|
|
config,
|
|
client,
|
|
busy,
|
|
vocab,
|
|
doctate_server::events::channel(),
|
|
)
|
|
.await;
|
|
|
|
// Audio must have been renamed so recovery skips it next time.
|
|
assert!(!audio.exists(), "original .m4a still present");
|
|
let failed = case_dir.join("2026-04-13T10-30-00Z.m4a.failed");
|
|
assert!(failed.exists(), "expected {} to exist", failed.display());
|
|
}
|
|
|
|
/// Transient Whisper errors (5xx / 408 / 429 / network) must NOT rename
|
|
/// the audio. The file stays as plain `.m4a` so the page-load heal
|
|
/// (`enqueue_pending_for_user`) picks it up automatically on the next
|
|
/// refresh — which is what happens when Minerva recovers.
|
|
#[tokio::test]
|
|
async fn worker_leaves_m4a_intact_on_transient_whisper_error() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/asr"))
|
|
.respond_with(ResponseTemplate::new(503).set_body_string("service unavailable"))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let case_dir = tmp.path().join("dr_test/bbbb");
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
let audio = case_dir.join("2026-04-13T10-31-00Z.m4a");
|
|
std::fs::copy(fixture("sample.m4a"), &audio).unwrap();
|
|
|
|
let config = config_with_whisper(server.uri());
|
|
let (tx, rx) = transcribe::channel();
|
|
tx.send(transcribe::TranscribeJob {
|
|
audio_path: audio.clone(),
|
|
user_slug: "dr_test".into(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
drop(tx);
|
|
|
|
let client = reqwest::Client::new();
|
|
let busy = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let vocab = Arc::new(doctate_server::gazetteer::Gazetteer::empty());
|
|
transcribe::worker::run(
|
|
rx,
|
|
config,
|
|
client,
|
|
busy,
|
|
vocab,
|
|
doctate_server::events::channel(),
|
|
)
|
|
.await;
|
|
|
|
// Original .m4a must still be there; .failed must NOT exist.
|
|
assert!(audio.exists(), "transient error must not consume the .m4a");
|
|
let failed = case_dir.join("2026-04-13T10-31-00Z.m4a.failed");
|
|
assert!(
|
|
!failed.exists(),
|
|
"transient error must not mark {} as failed",
|
|
failed.display()
|
|
);
|
|
// And no transcript yet either (the 503 produced no text).
|
|
let transcript = case_dir.join("2026-04-13T10-31-00Z.transcript.txt");
|
|
assert!(!transcript.exists(), "no transcript expected on 503");
|
|
}
|
|
|
|
/// The worker must route the Whisper response through the Gazetteer
|
|
/// before persisting. Mock returns the drift form "Zerebrum"; the
|
|
/// persisted `.transcript.txt` must contain the canonical "Cerebrum".
|
|
#[tokio::test]
|
|
async fn transcribe_worker_normalizes_whisper_output() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/asr"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_string("Patient mit Blutung im Zerebrum."),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let case_dir = tmp.path().join("dr_test/ffff");
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
let audio = case_dir.join("2026-04-13T10-30-00Z.m4a");
|
|
std::fs::copy(fixture("sample.m4a"), &audio).unwrap();
|
|
|
|
let vocab_dir = tempfile::tempdir().unwrap();
|
|
std::fs::write(vocab_dir.path().join("anatomy.txt"), "Cerebrum\n").unwrap();
|
|
let vocab = Arc::new(
|
|
doctate_server::gazetteer::Gazetteer::load_dir(vocab_dir.path())
|
|
.await
|
|
.unwrap(),
|
|
);
|
|
assert_eq!(vocab.len(), 1);
|
|
|
|
let config = config_with_whisper(server.uri());
|
|
let (tx, rx) = transcribe::channel();
|
|
tx.send(transcribe::TranscribeJob {
|
|
audio_path: audio.clone(),
|
|
user_slug: "dr_test".into(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
drop(tx);
|
|
|
|
let client = reqwest::Client::new();
|
|
let busy = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
transcribe::worker::run(
|
|
rx,
|
|
config,
|
|
client,
|
|
busy,
|
|
vocab,
|
|
doctate_server::events::channel(),
|
|
)
|
|
.await;
|
|
|
|
let transcript_path = case_dir.join("2026-04-13T10-30-00Z.transcript.txt");
|
|
assert!(transcript_path.exists(), "transcript missing");
|
|
let transcript = std::fs::read_to_string(&transcript_path).unwrap();
|
|
assert_eq!(transcript, "Patient mit Blutung im Cerebrum.");
|
|
}
|
|
|
|
// -------------------- Ollama client --------------------
|
|
|
|
const MODEL: &str = "gemma3:4b";
|
|
|
|
fn ok_chat_body(content: &str) -> serde_json::Value {
|
|
json!({
|
|
"model": MODEL,
|
|
"message": {"role": "assistant", "content": content},
|
|
"done": true
|
|
})
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ollama_oneliner_parses_chat_response() {
|
|
let server = MockServer::start().await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/api/chat"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(ok_chat_body("HOCM mit Septum-Hypertrophie")),
|
|
)
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let out = generate_oneliner(
|
|
&client,
|
|
&server.uri(),
|
|
MODEL,
|
|
0,
|
|
"irrelevant",
|
|
Duration::from_secs(5),
|
|
)
|
|
.await
|
|
.expect("call failed");
|
|
|
|
assert_eq!(out, "HOCM mit Septum-Hypertrophie");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ollama_oneliner_normalizes_response() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/api/chat"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(ok_chat_body(
|
|
"\"Kniegelenk re., V.a. Meniskus.\"\nSonstiges",
|
|
)))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let out = generate_oneliner(
|
|
&client,
|
|
&server.uri(),
|
|
MODEL,
|
|
0,
|
|
"x",
|
|
Duration::from_secs(5),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(out, "Kniegelenk re., V.a. Meniskus");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ollama_oneliner_returns_status_on_500() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/api/chat"))
|
|
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let err = generate_oneliner(
|
|
&client,
|
|
&server.uri(),
|
|
MODEL,
|
|
0,
|
|
"x",
|
|
Duration::from_secs(5),
|
|
)
|
|
.await
|
|
.expect_err("expected error");
|
|
match err {
|
|
OllamaError::Status { status, body } => {
|
|
assert_eq!(status, 500);
|
|
assert_eq!(body, "boom");
|
|
}
|
|
other => panic!("unexpected: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ollama_oneliner_sends_keep_alive_and_model() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/api/chat"))
|
|
.and(body_partial_json(json!({
|
|
"model": MODEL,
|
|
"stream": false,
|
|
"keep_alive": 0
|
|
})))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(ok_chat_body("OK")))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let _ = generate_oneliner(
|
|
&client,
|
|
&server.uri(),
|
|
MODEL,
|
|
0,
|
|
"irrelevant",
|
|
Duration::from_secs(5),
|
|
)
|
|
.await
|
|
.expect("call failed");
|
|
// Mock::expect(1) + server drop verifies the matcher hit exactly once.
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ollama_oneliner_empty_response_when_model_obeys_silence_rule() {
|
|
// Model followed the silence rule for a keyword-less transcript and
|
|
// returned empty content. That's a valid outcome, not a parse error.
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/api/chat"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(ok_chat_body(" \n ")))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let err = generate_oneliner(
|
|
&client,
|
|
&server.uri(),
|
|
MODEL,
|
|
0,
|
|
"äh, Moment",
|
|
Duration::from_secs(5),
|
|
)
|
|
.await
|
|
.expect_err("expected EmptyResponse");
|
|
assert!(matches!(err, OllamaError::EmptyResponse), "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ollama_oneliner_parse_error_on_missing_content() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/api/chat"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"done": true})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let err = generate_oneliner(
|
|
&client,
|
|
&server.uri(),
|
|
MODEL,
|
|
0,
|
|
"x",
|
|
Duration::from_secs(5),
|
|
)
|
|
.await
|
|
.expect_err("expected parse error");
|
|
assert!(matches!(err, OllamaError::Parse(_)), "got {err:?}");
|
|
}
|