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}; use doctate_server::paths::{CLOSE_MARKER, CloseMarker, write_close_marker}; 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(), retention: Default::default(), window_hours: 72, }], 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); } /// Scenario A: a new upload to a *closed* case must silently remove /// the `.closed` marker and store the new audio. Reopens the case so /// it reappears in /api/oneliners without any manual "undo". #[tokio::test] async fn upload_reopens_closed_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"; // First upload — creates the case directory. 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); // Close the case by writing the marker directly — simulates the // user hitting the close button in the web UI without having to // drive the /web/cases/{id}/delete handler here. Direct write also // means the watermark is NOT bumped here; that way the next GET // /api/oneliners pins down the pre-reopen ETag cleanly. let case_dir = data_path.join("dr_test").join(case_id); let marker = CloseMarker { closed_at: "2026-04-13T11:00:00Z".into(), }; write_close_marker(&case_dir, &marker).await.unwrap(); assert!(case_dir.join(CLOSE_MARKER).exists()); // Capture the ETag before the reopen. let etag_before = { let resp = app .clone() .oneshot( Request::builder() .method("GET") .uri("/api/oneliners") .header("X-API-Key", "test-key-123") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); resp.headers() .get("etag") .and_then(|v| v.to_str().ok()) .map(str::to_owned) .expect("server must set ETag") }; // Second upload — must auto-reopen. let (boundary, body) = multipart_body(case_id, "2026-04-13T12:00:00Z", b"reopen 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); // Marker is gone → case is visible again. assert!( !case_dir.join(CLOSE_MARKER).exists(), ".closed marker must be removed after re-upload" ); // Both audios are on disk — the old one was not touched. assert!(case_dir.join("2026-04-13T10-30-00Z.m4a").exists()); assert!(case_dir.join("2026-04-13T12-00-00Z.m4a").exists()); // Auto-reopen must bump the watermark: sending the pre-reopen // If-None-Match must return 200 (not 304) so clients who were // offline during the reopen notice it on their next poll. let resp = app .oneshot( Request::builder() .method("GET") .uri("/api/oneliners") .header("X-API-Key", "test-key-123") .header("If-None-Match", &etag_before) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!( resp.status(), StatusCode::OK, "auto-reopen must invalidate the pre-reopen ETag" ); let etag_after = resp .headers() .get("etag") .and_then(|v| v.to_str().ok()) .map(str::to_owned) .expect("server must set ETag"); assert_ne!(etag_after, etag_before); let _ = std::fs::remove_dir_all(&data_path); } /// Scenario B: a new upload to a case whose server-side directory has /// been *hard-deleted* (e.g. by a future retention sweep or an /// out-of-band cleanup) must transparently re-create the directory and /// accept the audio. This guards the forward-looking scenario where the /// client was offline long enough that the server has already purged /// the case. #[tokio::test] async fn upload_resurrects_hard_deleted_case() { 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"; // First upload. let (boundary, body) = multipart_body(case_id, "2026-04-13T10:30:00Z", b"original 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); // Wipe the case directory entirely — no marker, no audio, nothing. let case_dir = data_path.join("dr_test").join(case_id); std::fs::remove_dir_all(&case_dir).unwrap(); assert!(!case_dir.exists()); // Second upload with the same client-generated UUID. let (boundary, body) = multipart_body(case_id, "2026-04-13T12:00:00Z", b"resurrected 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); // Directory is back, new audio is inside, old audio stays gone. assert!(case_dir.exists()); assert!(case_dir.join("2026-04-13T12-00-00Z.m4a").exists()); assert!(!case_dir.join("2026-04-13T10-30-00Z.m4a").exists()); let _ = std::fs::remove_dir_all(&data_path); }