Files
doctate/server/tests/transcribe_test.rs
T
Brummel 1e3cc9574c Refactor user config validation and add transcript display
Introduces a dedicated function `validate_and_index_users` to
encapsulate the logic for validating user configurations, checking for
duplicate slugs and API keys. This function returns a `Result` to handle
errors gracefully, and its usage in `load_users` is updated to panic
with a more informative error message.

Additionally, this commit modifies the web routing to fetch and display
audio transcripts. When scanning recordings, it now attempts to read a
corresponding `.transcript.txt` file. If found, the transcript content
is included in the `RecordingView` and rendered in the `cases.html`
template. If the transcript file is not found, a "Transcription pending"
message is displayed.

The `transcribe` function in `whisper.rs` is updated to explicitly set
the language to German (`language=de`), which can improve transcription
accuracy by skipping language detection. The `encode=false` query
parameter has been removed, as the service is designed to handle encoded
audio.

Finally, tests have been added for the new `validate_and_index_users`
function to ensure duplicate slugs and API keys are correctly rejected.
Integration tests in `web_test.rs` have been updated to verify the
rendering of transcripts and the pending status.
2026-04-13 17:08:21 +02:00

158 lines
5.1 KiB
Rust

use std::path::PathBuf;
use std::time::Duration;
use doctate_server::transcribe;
use doctate_server::transcribe::ffmpeg::remux_faststart;
use doctate_server::transcribe::recovery::scan_and_enqueue;
use doctate_server::transcribe::whisper::{transcribe, WhisperError};
use wiremock::matchers::{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))
.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))
.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),
)
.await
.expect_err("expected timeout");
assert!(matches!(err, WhisperError::Http(_)), "expected Http error, got {err:?}");
}
#[tokio::test]
async fn recovery_enqueues_only_pending_recordings() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// User 1, open case with two .m4a — one pending, one already transcribed.
let case_a = root.join("dr_a/open/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, open case with one pending .m4a.
let case_b = root.join("dr_b/open/bbbb");
std::fs::create_dir_all(&case_b).unwrap();
std::fs::write(case_b.join("2026-04-12T10-00-00Z.m4a"), b"x").unwrap();
// done/ cases must be ignored (not re-transcribed).
let case_c = root.join("dr_a/done/cccc");
std::fs::create_dir_all(&case_c).unwrap();
std::fs::write(case_c.join("2026-04-09T10-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"));
}