Feat: Add web interface for case listing and audio playback
Introduces a new web interface to list cases and play back audio recordings. This includes: - A new `web` module in `routes` to handle web requests. - `handle_case_list` to scan and display available cases and their recordings. - `handle_audio` to serve audio files, with validation to prevent path traversal and ensure correct file types. - `AppError::NotFound` variant to handle missing resources gracefully. - Updates to `projektplan.md` to document the change in STT container and m4a preprocessing. - New tests in `server/tests/web_test.rs` to ensure the web interface functions correctly.
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
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();
|
||||
|
||||
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"));
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user