Files
doctate/server/tests/web_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

225 lines
6.2 KiB
Rust

use std::collections::HashMap;
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::util::ServiceExt;
use doctate_server::config::{Config, User};
fn test_config() -> Arc<Config> {
let data_path = std::env::temp_dir().join(format!(
"doctate-web-test-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
Arc::new(Config {
server_port: 3000,
data_path,
log_level: "info".into(),
log_path: "/tmp/doctate-test/logs".into(),
log_max_days: 90,
users: vec![User {
slug: "dr_test".into(),
api_key: "test-key".into(),
web_password: "unused".into(),
role: "doctor".into(),
}],
api_keys: HashMap::from([("test-key".into(), "dr_test".into())]),
retention_audio_days: 30,
retention_transcript_days: 30,
retention_document_days: 0,
whisper_url: "http://localhost:10300".into(),
whisper_timeout_seconds: 120,
ollama_url: "http://localhost:11434".into(),
ollama_model: "gemma3:4b".into(),
ollama_keep_alive: 0,
llm_url: String::new(),
llm_api_key: String::new(),
llm_model: String::new(),
llm_temperature: 0.0,
session_timeout_hours: 8,
})
}
async fn body_to_string(response: axum::response::Response) -> String {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn web_index_empty() {
let config = test_config();
let app = doctate_server::create_router(config);
let response = app
.oneshot(
Request::builder()
.uri("/web/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_string(response).await;
assert!(body.contains("No cases found"));
}
#[tokio::test]
async fn web_index_shows_cases() {
let config = test_config();
let data_path = config.data_path.clone();
// Pre-create cases on disk
let case1 = "550e8400-e29b-41d4-a716-446655440000";
let case2 = "660e8400-e29b-41d4-a716-446655440000";
let open_case = data_path.join("dr_test/open").join(case1);
std::fs::create_dir_all(&open_case).unwrap();
std::fs::write(
open_case.join("2026-04-13T10-30-00Z.m4a"),
b"fake audio 1",
)
.unwrap();
let done_case = data_path.join("dr_test/done").join(case2);
std::fs::create_dir_all(&done_case).unwrap();
std::fs::write(
done_case.join("2026-04-12T09-00-00Z.m4a"),
b"fake audio 2",
)
.unwrap();
std::fs::write(
done_case.join("2026-04-12T09-00-00Z.transcript.txt"),
"Herzkatheter ohne Befund.",
)
.unwrap();
let app = doctate_server::create_router(config);
let response = app
.oneshot(
Request::builder()
.uri("/web/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_string(response).await;
assert!(body.contains(case1));
assert!(body.contains(case2));
assert!(body.contains("dr_test"));
assert!(body.contains("<audio"));
assert!(body.contains("2026-04-13T10-30-00Z.m4a"));
// case1 has no transcript → pending placeholder
assert!(body.contains("Transcription pending"));
// case2 has a transcript → rendered
assert!(body.contains("Herzkatheter ohne Befund."));
let _ = std::fs::remove_dir_all(&data_path);
}
#[tokio::test]
async fn web_audio_serves_file() {
let config = test_config();
let data_path = config.data_path.clone();
let case_id = "770e8400-e29b-41d4-a716-446655440000";
let case_dir = data_path.join("dr_test/open").join(case_id);
std::fs::create_dir_all(&case_dir).unwrap();
let audio_bytes = b"fake audio content for test";
let filename = "2026-04-13T10-30-00Z.m4a";
std::fs::write(case_dir.join(filename), audio_bytes).unwrap();
let app = doctate_server::create_router(config);
let response = app
.oneshot(
Request::builder()
.uri(format!("/web/audio/dr_test/{case_id}/{filename}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"audio/mp4"
);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&bytes[..], audio_bytes);
let _ = std::fs::remove_dir_all(&data_path);
}
#[tokio::test]
async fn web_audio_path_traversal_rejected() {
let config = test_config();
let app = doctate_server::create_router(config);
let response = app
.oneshot(
Request::builder()
.uri("/web/audio/..%2Fetc/550e8400-e29b-41d4-a716-446655440000/foo.m4a")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn web_audio_invalid_case_id_rejected() {
let config = test_config();
let app = doctate_server::create_router(config);
let response = app
.oneshot(
Request::builder()
.uri("/web/audio/dr_test/not-a-uuid/foo.m4a")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn web_audio_nonexistent_file_returns_404() {
let config = test_config();
let app = doctate_server::create_router(config);
let response = app
.oneshot(
Request::builder()
.uri("/web/audio/dr_test/550e8400-e29b-41d4-a716-446655440000/missing.m4a")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}