use std::path::PathBuf; use std::time::Duration; use std::sync::Arc; use doctate_server::config::{Config, User, 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}; 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 test_config_with_whisper(whisper_url: String) -> Arc { Arc::new(Config { users: vec![User { slug: "dr_test".into(), api_key: "k".into(), web_password: "unused".into(), role: "doctor".into(), whisper: Default::default(), retention: Default::default(), window_hours: 72, preview_lines: 2, }], whisper_url, whisper_timeout_seconds: 5, // Ollama URL is irrelevant — worker never reaches it in the failure path. ollama_url: "http://127.0.0.1:1".into(), ollama_model: "test".into(), ..Config::test_default() }) } /// 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 = test_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 = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let vocab = std::sync::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 = test_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 = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let vocab = std::sync::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 = std::sync::Arc::new( doctate_server::gazetteer::Gazetteer::load_dir(vocab_dir.path()) .await .unwrap(), ); assert_eq!(vocab.len(), 1); let config = test_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 = std::sync::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:?}"); }