Files
doctate/server/tests/transcribe_test.rs
T
Brummel 1d702e2d85 Add event bus for live UI updates
Introduces a system for broadcasting real-time events to the web UI.
This enables features like automatic page reloads when case data changes
in the background, improving the user experience by keeping the UI
synchronized with the backend.

Key components:
- `events` module: Contains the `CaseEventKind` enum, `CaseEvent`
  struct, and `EventSender` type for managing the broadcast channel.
- SSE endpoint (`/web/events`): Streams events to connected browsers.
- Client-side JavaScript: Listens for events and triggers debounced page
  reloads.
- Integration points: Workers and route handlers now emit events when
  relevant state changes occur.
2026-04-19 23:33:53 +02:00

525 lines
16 KiB
Rust

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<Config> {
Arc::new(Config {
users: vec![User {
slug: "dr_test".into(),
api_key: "k".into(),
web_password: "unused".into(),
role: "doctor".into(),
whisper: Default::default(),
}],
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()
})
}
#[tokio::test]
async fn worker_renames_audio_to_failed_on_whisper_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/asr"))
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
.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());
}
/// 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_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:?}");
}