mod common; use axum::body::Body; use axum::http::{Request, StatusCode}; use tower::util::ServiceExt; use doctate_common::UPLOAD_PATH; use doctate_server::paths::{CLOSE_MARKER, CloseMarker, write_close_marker}; use common::{ TestConfig, body_json, get_with_api_key, get_with_api_key_if_none_match, header_str, multipart_upload_body, test_user, }; const TEST_KEY: &str = "key-dr_test"; fn test_config() -> std::sync::Arc { TestConfig::new() .with_label("upload") .with_user(test_user("dr_test")) .build() } /// Wrap an `(content_type, body)` pair into the actual HTTP upload request. /// Keeps the multipart boundary and the audio-upload path in sync for /// every test in this file. fn upload_request(content_type: &str, body: Vec, api_key: Option<&str>) -> Request { let mut b = Request::builder() .method("POST") .uri(UPLOAD_PATH) .header("Content-Type", content_type); if let Some(k) = api_key { b = b.header("X-API-Key", k); } b.body(Body::from(body)).unwrap() } #[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 (content_type, body) = multipart_upload_body(case_id, "2026-04-13T10:30:00Z", b"fake audio data"); let response = app .oneshot(upload_request(&content_type, body, Some(TEST_KEY))) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let json = body_json(response).await; 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:?}"); } #[tokio::test] async fn upload_invalid_case_id_returns_400() { let app = doctate_server::create_router(test_config()); let (content_type, body) = multipart_upload_body("../etc/passwd", "2026-04-13T10:30:00Z", b"audio"); let response = app .oneshot(upload_request(&content_type, body, Some(TEST_KEY))) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn upload_without_auth_returns_401() { let app = doctate_server::create_router(test_config()); let (content_type, body) = multipart_upload_body( "550e8400-e29b-41d4-a716-446655440000", "2026-04-13T10:30:00Z", b"audio", ); let response = app .oneshot(upload_request(&content_type, body, None)) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn upload_empty_audio_returns_400() { let app = doctate_server::create_router(test_config()); let (content_type, body) = multipart_upload_body( "550e8400-e29b-41d4-a716-446655440000", "2026-04-13T10:30:00Z", b"", // empty audio ); let response = app .oneshot(upload_request(&content_type, body, Some(TEST_KEY))) .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 (ct, body) = multipart_upload_body(case_id, "2026-04-13T10:30:00Z", b"first recording"); let response = app .clone() .oneshot(upload_request(&ct, body, Some(TEST_KEY))) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); // Second upload, same case, different timestamp let (ct, body) = multipart_upload_body(case_id, "2026-04-13T10:45:00Z", b"second recording"); let response = app .oneshot(upload_request(&ct, body, Some(TEST_KEY))) .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()); } /// 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 (ct, body) = multipart_upload_body(case_id, "2026-04-13T10:30:00Z", b"first recording"); let response = app .clone() .oneshot(upload_request(&ct, body, Some(TEST_KEY))) .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 /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(get_with_api_key("/api/oneliners", TEST_KEY)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let t = header_str(&resp, "etag"); assert!(!t.is_empty(), "server must set ETag"); t }; // Second upload — must auto-reopen. let (ct, body) = multipart_upload_body(case_id, "2026-04-13T12:00:00Z", b"reopen recording"); let response = app .clone() .oneshot(upload_request(&ct, body, Some(TEST_KEY))) .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(get_with_api_key_if_none_match( "/api/oneliners", TEST_KEY, &etag_before, )) .await .unwrap(); assert_eq!( resp.status(), StatusCode::OK, "auto-reopen must invalidate the pre-reopen ETag" ); let etag_after = header_str(&resp, "etag"); assert!(!etag_after.is_empty(), "server must set ETag"); assert_ne!(etag_after, etag_before); } /// 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 (ct, body) = multipart_upload_body(case_id, "2026-04-13T10:30:00Z", b"original recording"); let response = app .clone() .oneshot(upload_request(&ct, body, Some(TEST_KEY))) .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 (ct, body) = multipart_upload_body(case_id, "2026-04-13T12:00:00Z", b"resurrected recording"); let response = app .oneshot(upload_request(&ct, body, Some(TEST_KEY))) .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()); }