a1ff410d1b
Remove the per-user oneliner watermark, which was previously used to generate ETags for the `/api/oneliners` endpoint. The watermark was intended to track the last successful oneliner write for each user. The ETag generation has been refactored to use a deterministic FNV-1a hash. This hash is computed over a sorted list of tuples containing `(case_id, last_recording_at_ns, oneliner_mtime_ns)` for all visible cases. This approach ensures that the ETag changes whenever any listen-relevant file system modification occurs, such as a new recording, oneliner regeneration, soft-delete, undo, or upload-auto-reopen. This new method is more robust and avoids the complexity of managing per-user state. The `OnelinerWatermark` type alias and its associated logic have been removed from `AppState` and the relevant modules (`transcribe::worker`, `transcribe::recovery`, `routes::oneliners`, `main`). New integration tests have been added to verify that various delete operations (single delete, undo delete, bulk delete) correctly trigger an ETag update, ensuring cache invalidation.
454 lines
15 KiB
Rust
454 lines
15 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::{generate_oneliner, OllamaError};
|
|
use doctate_server::transcribe::recovery::scan_and_enqueue;
|
|
use doctate_server::transcribe::whisper::{transcribe, WhisperError};
|
|
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).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).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:?}");
|
|
}
|