mod common; use axum::body::Body; use axum::http::{Request, StatusCode}; use tower::util::ServiceExt; use common::{TestConfig, header_opt, test_user}; fn test_config() -> std::sync::Arc { TestConfig::new() .with_label("web") .with_user(test_user("dr_test")) .build() } #[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 = common::seed_case(&data_path, "dr_test", case_id); 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!("/audio/dr_test/{case_id}/{filename}")) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!(header_opt(&response, "content-type"), Some("audio/mp4")); let bytes = common::body_bytes(response).await; assert_eq!(&bytes[..], audio_bytes); } #[tokio::test] async fn web_audio_path_traversal_rejected() { let app = doctate_server::create_router(test_config()); let response = app .oneshot( Request::builder() .uri("/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 app = doctate_server::create_router(test_config()); let response = app .oneshot( Request::builder() .uri("/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_serves_range_as_206() { let config = test_config(); let data_path = config.data_path.clone(); let case_id = "770e8400-e29b-41d4-a716-446655440001"; let case_dir = common::seed_case(&data_path, "dr_test", case_id); let audio_bytes: Vec = (0u8..=200u8).collect(); 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!("/audio/dr_test/{case_id}/{filename}")) .header("Range", "bytes=10-19") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); assert_eq!( header_opt(&response, "content-range"), Some(format!("bytes 10-19/{}", audio_bytes.len()).as_str()) ); assert_eq!(header_opt(&response, "content-length"), Some("10")); assert_eq!(header_opt(&response, "accept-ranges"), Some("bytes")); let bytes = common::body_bytes(response).await; assert_eq!(&bytes[..], &audio_bytes[10..20]); } #[tokio::test] async fn web_audio_invalid_range_returns_416() { let config = test_config(); let data_path = config.data_path.clone(); let case_id = "770e8400-e29b-41d4-a716-446655440002"; let case_dir = common::seed_case(&data_path, "dr_test", case_id); let audio_bytes = b"short"; 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!("/audio/dr_test/{case_id}/{filename}")) .header("Range", "bytes=1000-2000") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); assert_eq!( header_opt(&response, "content-range"), Some(format!("bytes */{}", audio_bytes.len()).as_str()) ); } #[tokio::test] async fn web_audio_no_range_header_advertises_accept_ranges() { let config = test_config(); let data_path = config.data_path.clone(); let case_id = "770e8400-e29b-41d4-a716-446655440003"; let case_dir = common::seed_case(&data_path, "dr_test", case_id); let audio_bytes = b"full file content"; 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!("/audio/dr_test/{case_id}/{filename}")) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!(header_opt(&response, "accept-ranges"), Some("bytes")); assert_eq!( header_opt(&response, "content-length"), Some(audio_bytes.len().to_string().as_str()) ); } #[tokio::test] async fn web_audio_nonexistent_file_returns_404() { let app = doctate_server::create_router(test_config()); let response = app .oneshot( Request::builder() .uri("/audio/dr_test/550e8400-e29b-41d4-a716-446655440000/missing.m4a") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } /// Property: a closed case still serves its audio. Closing only writes a /// `.closed` marker — the `.m4a` files stay on disk — and that marker is a /// discovery filter for the case LIST, not an access gate on the audio file /// server. So playback from a closed case's recordings page must work, not /// 404. Regression for issue #17 (audio playback broken on closed cases). #[tokio::test] async fn web_audio_serves_closed_case_file() { let config = test_config(); let data_path = config.data_path.clone(); let case_id = "770e8400-e29b-41d4-a716-446655440099"; let case_dir = common::seed_case(&data_path, "dr_test", case_id); let audio_bytes = b"fake audio content for a closed case"; let filename = "2026-04-13T10-30-00Z.m4a"; std::fs::write(case_dir.join(filename), audio_bytes).unwrap(); common::write_closed_marker(&case_dir, "2026-04-13T12:00:00Z"); let app = doctate_server::create_router(config); let response = app .oneshot( Request::builder() .uri(format!("/audio/dr_test/{case_id}/{filename}")) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!(header_opt(&response, "content-type"), Some("audio/mp4")); let bytes = common::body_bytes(response).await; assert_eq!(&bytes[..], audio_bytes); }