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 { let data_path = std::env::temp_dir().join(format!( "doctate-test-{}-{}", std::process::id(), uuid::Uuid::new_v4() )); Arc::new(Config { data_path, users: vec![User { slug: "dr_test".into(), api_key: "test-key-123".into(), web_password: "unused".into(), role: "doctor".into(), whisper: Default::default(), }], api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]), ..Config::test_default() }) } /// Build a multipart body with the given fields. fn multipart_body( case_id: &str, recorded_at: &str, audio: &[u8], ) -> (String, Vec) { 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") .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").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); }