1051e83da3
This commit introduces a new API endpoint `/api/upload` for handling audio file uploads. It supports multipart form data, extracts `case_id`, `recorded_at`, and `audio` fields. The audio data is saved to a filesystem path determined by the user and case ID, within either the `open/` or `done/` directories. New features include: - Integration with `axum`'s `Multipart` extractor for parsing form data. - Validation of `case_id` as a UUID. - Creation of case directories if they do not exist. - Handling of late uploads to already closed cases by placing files in the `done/` directory and removing any `.remove` marker. - Logging of received recordings. - Definition of `AckResponse` and `AckStatus` models for API responses. - Addition of comprehensive unit tests for various upload scenarios, including new case creation, invalid inputs, authentication, and late uploads.
347 lines
11 KiB
Rust
347 lines
11 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-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-123".into(),
|
|
web_password: "unused".into(),
|
|
role: "doctor".into(),
|
|
}],
|
|
api_keys: HashMap::from([("test-key-123".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,
|
|
})
|
|
}
|
|
|
|
/// Build a multipart body with the given fields.
|
|
fn multipart_body(
|
|
case_id: &str,
|
|
recorded_at: &str,
|
|
audio: &[u8],
|
|
) -> (String, Vec<u8>) {
|
|
let boundary = "----testboundary";
|
|
let mut body = Vec::new();
|
|
|
|
// case_id field
|
|
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
|
body.extend_from_slice(b"Content-Disposition: form-data; name=\"case_id\"\r\n\r\n");
|
|
body.extend_from_slice(case_id.as_bytes());
|
|
body.extend_from_slice(b"\r\n");
|
|
|
|
// recorded_at field
|
|
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
|
body.extend_from_slice(b"Content-Disposition: form-data; name=\"recorded_at\"\r\n\r\n");
|
|
body.extend_from_slice(recorded_at.as_bytes());
|
|
body.extend_from_slice(b"\r\n");
|
|
|
|
// audio field
|
|
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
|
body.extend_from_slice(
|
|
b"Content-Disposition: form-data; name=\"audio\"; filename=\"test.m4a\"\r\n",
|
|
);
|
|
body.extend_from_slice(b"Content-Type: audio/mp4\r\n\r\n");
|
|
body.extend_from_slice(audio);
|
|
body.extend_from_slice(b"\r\n");
|
|
|
|
// End boundary
|
|
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
|
|
|
|
(boundary.to_owned(), body)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upload_creates_new_case() {
|
|
let config = test_config();
|
|
let data_path = config.data_path.clone();
|
|
let app = doctate_server::create_router(config);
|
|
|
|
let case_id = "550e8400-e29b-41d4-a716-446655440000";
|
|
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:30:00Z", b"fake audio data");
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/upload")
|
|
.header("X-API-Key", "test-key-123")
|
|
.header(
|
|
"Content-Type",
|
|
format!("multipart/form-data; boundary={boundary}"),
|
|
)
|
|
.body(Body::from(body))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
|
|
assert_eq!(json["case_id"], case_id);
|
|
assert_eq!(json["status"], "received");
|
|
|
|
// Verify file on disk
|
|
let file = data_path
|
|
.join("dr_test/open")
|
|
.join(case_id)
|
|
.join("2026-04-13T10-30-00Z.m4a");
|
|
assert!(file.exists(), "Audio file should exist at {file:?}");
|
|
|
|
// Cleanup
|
|
let _ = std::fs::remove_dir_all(&data_path);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upload_invalid_case_id_returns_400() {
|
|
let config = test_config();
|
|
let app = doctate_server::create_router(config);
|
|
|
|
let (boundary, body) = multipart_body("../etc/passwd", "2026-04-13T10:30:00Z", b"audio");
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/upload")
|
|
.header("X-API-Key", "test-key-123")
|
|
.header(
|
|
"Content-Type",
|
|
format!("multipart/form-data; boundary={boundary}"),
|
|
)
|
|
.body(Body::from(body))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upload_without_auth_returns_401() {
|
|
let config = test_config();
|
|
let app = doctate_server::create_router(config);
|
|
|
|
let (boundary, body) = multipart_body(
|
|
"550e8400-e29b-41d4-a716-446655440000",
|
|
"2026-04-13T10:30:00Z",
|
|
b"audio",
|
|
);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/upload")
|
|
.header(
|
|
"Content-Type",
|
|
format!("multipart/form-data; boundary={boundary}"),
|
|
)
|
|
.body(Body::from(body))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upload_empty_audio_returns_400() {
|
|
let config = test_config();
|
|
let app = doctate_server::create_router(config);
|
|
|
|
let (boundary, body) = multipart_body(
|
|
"550e8400-e29b-41d4-a716-446655440000",
|
|
"2026-04-13T10:30:00Z",
|
|
b"", // empty audio
|
|
);
|
|
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/upload")
|
|
.header("X-API-Key", "test-key-123")
|
|
.header(
|
|
"Content-Type",
|
|
format!("multipart/form-data; boundary={boundary}"),
|
|
)
|
|
.body(Body::from(body))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upload_second_recording_same_case() {
|
|
let config = test_config();
|
|
let data_path = config.data_path.clone();
|
|
let app = doctate_server::create_router(config);
|
|
|
|
let case_id = "660e8400-e29b-41d4-a716-446655440000";
|
|
|
|
// First upload
|
|
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:30:00Z", b"first recording");
|
|
let response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/upload")
|
|
.header("X-API-Key", "test-key-123")
|
|
.header(
|
|
"Content-Type",
|
|
format!("multipart/form-data; boundary={boundary}"),
|
|
)
|
|
.body(Body::from(body))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
// Second upload, same case, different timestamp
|
|
let (boundary, body) = multipart_body(case_id, "2026-04-13T10:45:00Z", b"second recording");
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/upload")
|
|
.header("X-API-Key", "test-key-123")
|
|
.header(
|
|
"Content-Type",
|
|
format!("multipart/form-data; boundary={boundary}"),
|
|
)
|
|
.body(Body::from(body))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
// Both files should exist
|
|
let case_dir = data_path.join("dr_test/open").join(case_id);
|
|
assert!(case_dir.join("2026-04-13T10-30-00Z.m4a").exists());
|
|
assert!(case_dir.join("2026-04-13T10-45-00Z.m4a").exists());
|
|
|
|
// Cleanup
|
|
let _ = std::fs::remove_dir_all(&data_path);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upload_late_recording_to_done_case() {
|
|
let config = test_config();
|
|
let data_path = config.data_path.clone();
|
|
let app = doctate_server::create_router(config);
|
|
|
|
let case_id = "770e8400-e29b-41d4-a716-446655440000";
|
|
|
|
// Pre-create a done/ case directory (simulating a closed case).
|
|
let done_dir = data_path.join("dr_test/done").join(case_id);
|
|
std::fs::create_dir_all(&done_dir).unwrap();
|
|
|
|
let (boundary, body) = multipart_body(case_id, "2026-04-13T11:00:00Z", b"late recording");
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/upload")
|
|
.header("X-API-Key", "test-key-123")
|
|
.header(
|
|
"Content-Type",
|
|
format!("multipart/form-data; boundary={boundary}"),
|
|
)
|
|
.body(Body::from(body))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
// File should be in done/, not open/
|
|
assert!(done_dir.join("2026-04-13T11-00-00Z.m4a").exists());
|
|
assert!(!data_path.join("dr_test/open").join(case_id).exists());
|
|
|
|
// Cleanup
|
|
let _ = std::fs::remove_dir_all(&data_path);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upload_removes_remove_marker_on_late_upload() {
|
|
let config = test_config();
|
|
let data_path = config.data_path.clone();
|
|
let app = doctate_server::create_router(config);
|
|
|
|
let case_id = "880e8400-e29b-41d4-a716-446655440000";
|
|
|
|
// Pre-create a done/ case with .remove marker.
|
|
let done_dir = data_path.join("dr_test/done").join(case_id);
|
|
std::fs::create_dir_all(&done_dir).unwrap();
|
|
std::fs::write(done_dir.join(".remove"), b"").unwrap();
|
|
assert!(done_dir.join(".remove").exists());
|
|
|
|
let (boundary, body) = multipart_body(case_id, "2026-04-13T12:00:00Z", b"surprise recording");
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/api/upload")
|
|
.header("X-API-Key", "test-key-123")
|
|
.header(
|
|
"Content-Type",
|
|
format!("multipart/form-data; boundary={boundary}"),
|
|
)
|
|
.body(Body::from(body))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
// .remove marker should be gone, audio should be there
|
|
assert!(!done_dir.join(".remove").exists());
|
|
assert!(done_dir.join("2026-04-13T12-00-00Z.m4a").exists());
|
|
|
|
// Cleanup
|
|
let _ = std::fs::remove_dir_all(&data_path);
|
|
}
|