Files
doctate/server/tests/web_test.rs
T
Brummel 17aa5d7200 Implement audio range requests
This commit enables serving audio files via HTTP Range requests. This is
crucial for allowing HTML5 audio players to seek to specific positions
within an audio file without re-downloading the entire file.

The changes include:
- Modifying `handle_audio` in `server/src/routes/web.rs` to parse
  `Range` headers.
- Implementing `serve_range` to handle partial content responses.
- Adding a `parse_range` helper function.
- Updating tests to verify range request functionality.
- Adding `ACCEPT_RANGES: bytes` header to indicate support for range
  requests.
- Storing recording duration in a sidecar file for faster UI rendering.
- Enhancing the HTML template to support a custom audio player with
  seeking.
2026-04-19 22:26:04 +02:00

274 lines
7.3 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 {
data_path,
users: vec![User {
slug: "dr_test".into(),
api_key: "test-key".into(),
web_password: "unused".into(),
role: "doctor".into(),
whisper: Default::default(),
}],
api_keys: HashMap::from([("test-key".into(), "dr_test".into())]),
..Config::test_default()
})
}
#[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").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_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 = data_path.join("dr_test").join(case_id);
std::fs::create_dir_all(&case_dir).unwrap();
let audio_bytes: Vec<u8> = (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!("/web/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!(
response
.headers()
.get("content-range")
.unwrap()
.to_str()
.unwrap(),
format!("bytes 10-19/{}", audio_bytes.len())
);
assert_eq!(
response
.headers()
.get("content-length")
.unwrap()
.to_str()
.unwrap(),
"10"
);
assert_eq!(
response
.headers()
.get("accept-ranges")
.unwrap()
.to_str()
.unwrap(),
"bytes"
);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&bytes[..], &audio_bytes[10..20]);
let _ = std::fs::remove_dir_all(&data_path);
}
#[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 = data_path.join("dr_test").join(case_id);
std::fs::create_dir_all(&case_dir).unwrap();
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!("/web/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!(
response
.headers()
.get("content-range")
.unwrap()
.to_str()
.unwrap(),
format!("bytes */{}", audio_bytes.len())
);
let _ = std::fs::remove_dir_all(&data_path);
}
#[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 = data_path.join("dr_test").join(case_id);
std::fs::create_dir_all(&case_dir).unwrap();
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!("/web/audio/dr_test/{case_id}/{filename}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("accept-ranges")
.unwrap()
.to_str()
.unwrap(),
"bytes"
);
assert_eq!(
response
.headers()
.get("content-length")
.unwrap()
.to_str()
.unwrap(),
audio_bytes.len().to_string()
);
let _ = std::fs::remove_dir_all(&data_path);
}
#[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);
}