use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, SystemTime}; use axum::body::Body; use axum::http::{Request, StatusCode}; use filetime::FileTime; use tower::util::ServiceExt; use doctate_server::config::{Config, User}; const TEST_KEY: &str = "test-key-oneliners"; const TEST_SLUG: &str = "dr_oneliners"; fn test_config() -> (Arc, PathBuf) { let data_path = std::env::temp_dir().join(format!( "doctate-oneliners-{}-{}", std::process::id(), uuid::Uuid::new_v4() )); let config = Arc::new(Config { data_path: data_path.clone(), users: vec![User { slug: TEST_SLUG.into(), api_key: TEST_KEY.into(), web_password: "unused".into(), role: "doctor".into(), whisper: Default::default(), }], api_keys: HashMap::from([(TEST_KEY.into(), TEST_SLUG.into())]), ..Config::test_default() }); (config, data_path) } async fn seed_case( user_root: &Path, case_id: &str, m4a_age: Duration, oneliner_text: Option<&str>, ) -> PathBuf { let case_dir = user_root.join(TEST_SLUG).join(case_id); tokio::fs::create_dir_all(&case_dir).await.unwrap(); // Write a dummy .m4a and rewind its mtime to simulate "case born N ago". let m4a = case_dir.join("2026-04-18T10-00-00Z.m4a"); tokio::fs::write(&m4a, b"x").await.unwrap(); let target_mtime = SystemTime::now() - m4a_age; filetime::set_file_mtime(&m4a, FileTime::from_system_time(target_mtime)).unwrap(); if let Some(text) = oneliner_text { tokio::fs::write(case_dir.join("oneliner.txt"), text) .await .unwrap(); } case_dir } async fn mark_deleted(case_dir: &Path) { tokio::fs::write(case_dir.join(".deleted"), "{}") .await .unwrap(); } fn get(uri: &str) -> Request { Request::builder() .method("GET") .uri(uri) .header("X-API-Key", TEST_KEY) .body(Body::empty()) .unwrap() } fn get_with_if_none_match(uri: &str, etag: &str) -> Request { Request::builder() .method("GET") .uri(uri) .header("X-API-Key", TEST_KEY) .header("If-None-Match", etag) .body(Body::empty()) .unwrap() } async fn body_json(response: axum::http::Response) -> serde_json::Value { let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); serde_json::from_slice(&bytes).unwrap() } fn header_str(response: &axum::http::Response, name: &str) -> String { response .headers() .get(name) .and_then(|v| v.to_str().ok()) .unwrap_or("") .to_owned() } #[tokio::test] async fn returns_401_without_api_key() { let (config, _dp) = test_config(); let app = doctate_server::create_router(config); let response = app .oneshot( Request::builder() .method("GET") .uri("/api/oneliners") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn empty_user_dir_returns_200_empty_list() { let (config, dp) = test_config(); let app = doctate_server::create_router(config); let response = app.oneshot(get("/api/oneliners")).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let etag = header_str(&response, "etag"); assert!(etag.starts_with("W/\""), "etag was {etag:?}"); assert!(etag.ends_with("-16\""), "etag was {etag:?}"); let body = body_json(response).await; assert_eq!(body["window_hours"], 16); assert!(body["oneliners"].as_array().unwrap().is_empty()); let _ = std::fs::remove_dir_all(&dp); } #[tokio::test] async fn recent_case_with_oneliner_shows_up() { let (config, dp) = test_config(); let case_id = "550e8400-e29b-41d4-a716-000000000001"; seed_case( &dp, case_id, Duration::from_secs(60), Some("Kniegelenk re., V.a. Meniskus"), ) .await; let app = doctate_server::create_router(config); let response = app.oneshot(get("/api/oneliners")).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = body_json(response).await; let arr = body["oneliners"].as_array().unwrap(); assert_eq!(arr.len(), 1); assert_eq!(arr[0]["case_id"], case_id); assert_eq!(arr[0]["oneliner"], "Kniegelenk re., V.a. Meniskus"); assert!(arr[0]["created_at"].is_string()); assert!(arr[0]["updated_at"].is_string()); let _ = std::fs::remove_dir_all(&dp); } #[tokio::test] async fn case_without_oneliner_shows_null() { let (config, dp) = test_config(); seed_case( &dp, "550e8400-e29b-41d4-a716-000000000002", Duration::from_secs(60), None, ) .await; let app = doctate_server::create_router(config); let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await; let arr = body["oneliners"].as_array().unwrap(); assert_eq!(arr.len(), 1); assert!(arr[0]["oneliner"].is_null()); let _ = std::fs::remove_dir_all(&dp); } #[tokio::test] async fn case_older_than_default_window_excluded() { let (config, dp) = test_config(); seed_case( &dp, "550e8400-e29b-41d4-a716-000000000003", Duration::from_secs(20 * 3600), // 20 h old Some("old case"), ) .await; let app = doctate_server::create_router(config); let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await; assert!(body["oneliners"].as_array().unwrap().is_empty()); let _ = std::fs::remove_dir_all(&dp); } #[tokio::test] async fn case_outside_default_but_inside_custom_window_included() { let (config, dp) = test_config(); seed_case( &dp, "550e8400-e29b-41d4-a716-000000000004", Duration::from_secs(20 * 3600), Some("yesterday case"), ) .await; let app = doctate_server::create_router(config); let body = body_json(app.oneshot(get("/api/oneliners?hours=48")).await.unwrap()).await; assert_eq!(body["window_hours"], 48); assert_eq!(body["oneliners"].as_array().unwrap().len(), 1); let _ = std::fs::remove_dir_all(&dp); } #[tokio::test] async fn hours_clamped_to_max() { let (config, dp) = test_config(); let app = doctate_server::create_router(config); let body = body_json( app.oneshot(get("/api/oneliners?hours=999999")) .await .unwrap(), ) .await; assert_eq!(body["window_hours"], 168); let _ = std::fs::remove_dir_all(&dp); } #[tokio::test] async fn hours_zero_clamped_to_one() { let (config, dp) = test_config(); let app = doctate_server::create_router(config); let body = body_json(app.oneshot(get("/api/oneliners?hours=0")).await.unwrap()).await; assert_eq!(body["window_hours"], 1); let _ = std::fs::remove_dir_all(&dp); } #[tokio::test] async fn matching_if_none_match_returns_304() { let (config, dp) = test_config(); let app = doctate_server::create_router(config); let first = app.clone().oneshot(get("/api/oneliners")).await.unwrap(); assert_eq!(first.status(), StatusCode::OK); let etag = header_str(&first, "etag"); assert!(!etag.is_empty()); let second = app .oneshot(get_with_if_none_match("/api/oneliners", &etag)) .await .unwrap(); assert_eq!(second.status(), StatusCode::NOT_MODIFIED); assert_eq!(header_str(&second, "etag"), etag); let _ = std::fs::remove_dir_all(&dp); } #[tokio::test] async fn stale_if_none_match_returns_200() { let (config, dp) = test_config(); let app = doctate_server::create_router(config); let response = app .oneshot(get_with_if_none_match( "/api/oneliners", "W/\"99999999-16\"", )) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let _ = std::fs::remove_dir_all(&dp); } #[tokio::test] async fn etag_differs_between_different_hours() { let (config, dp) = test_config(); let app = doctate_server::create_router(config); let r1 = app .clone() .oneshot(get("/api/oneliners?hours=1")) .await .unwrap(); let r16 = app.oneshot(get("/api/oneliners?hours=16")).await.unwrap(); let etag1 = header_str(&r1, "etag"); let etag16 = header_str(&r16, "etag"); assert_ne!(etag1, etag16); let _ = std::fs::remove_dir_all(&dp); } #[tokio::test] async fn etag_for_one_hour_does_not_match_sixteen_hour_request() { let (config, dp) = test_config(); let app = doctate_server::create_router(config); let r1 = app .clone() .oneshot(get("/api/oneliners?hours=1")) .await .unwrap(); let etag1 = header_str(&r1, "etag"); // Send the hours=1 ETag against an hours=16 request — must NOT 304. let r16 = app .oneshot(get_with_if_none_match("/api/oneliners?hours=16", &etag1)) .await .unwrap(); assert_eq!(r16.status(), StatusCode::OK); let _ = std::fs::remove_dir_all(&dp); } #[tokio::test] async fn deleted_case_is_excluded() { let (config, dp) = test_config(); let case_dir = seed_case( &dp, "550e8400-e29b-41d4-a716-000000000005", Duration::from_secs(60), Some("deleted case"), ) .await; mark_deleted(&case_dir).await; let app = doctate_server::create_router(config); let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await; assert!(body["oneliners"].as_array().unwrap().is_empty()); let _ = std::fs::remove_dir_all(&dp); } #[tokio::test] async fn multiple_cases_sorted_newest_first() { let (config, dp) = test_config(); seed_case( &dp, "550e8400-e29b-41d4-a716-000000000010", Duration::from_secs(2 * 3600), Some("older"), ) .await; seed_case( &dp, "550e8400-e29b-41d4-a716-000000000011", Duration::from_secs(30), Some("newer"), ) .await; let app = doctate_server::create_router(config); let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await; let arr = body["oneliners"].as_array().unwrap(); assert_eq!(arr.len(), 2); assert_eq!(arr[0]["oneliner"], "newer"); assert_eq!(arr[1]["oneliner"], "older"); let _ = std::fs::remove_dir_all(&dp); } /// Regression guard: if a case was first created long ago but got a /// follow-up recording today, it must sort ahead of a case whose single /// (older) recording happens to be newer than the first one. Sorting /// must use `last_recording_at`, not `created_at`. #[tokio::test] async fn sorts_by_last_recording_not_created() { let (config, dp) = test_config(); // Case A: first recording 20h ago (old created_at), second 1 min ago // (new last_recording_at). let case_a = "550e8400-e29b-41d4-a716-000000000020"; let dir_a = dp.join(TEST_SLUG).join(case_a); tokio::fs::create_dir_all(&dir_a).await.unwrap(); let old_m4a = dir_a.join("2026-04-17T12-00-00Z.m4a"); tokio::fs::write(&old_m4a, b"x").await.unwrap(); filetime::set_file_mtime( &old_m4a, FileTime::from_system_time(SystemTime::now() - Duration::from_secs(20 * 3600)), ) .unwrap(); let fresh_m4a = dir_a.join("2026-04-18T07-59-00Z.m4a"); tokio::fs::write(&fresh_m4a, b"x").await.unwrap(); filetime::set_file_mtime( &fresh_m4a, FileTime::from_system_time(SystemTime::now() - Duration::from_secs(60)), ) .unwrap(); tokio::fs::write(dir_a.join("oneliner.txt"), "A (fresh addendum)") .await .unwrap(); // Case B: single recording 10h ago — created_at newer than A's, but // last_recording_at older than A's fresh addendum. seed_case( &dp, "550e8400-e29b-41d4-a716-000000000021", Duration::from_secs(10 * 3600), Some("B (single, middle-aged)"), ) .await; let app = doctate_server::create_router(config); let body = body_json(app.oneshot(get("/api/oneliners?hours=48")).await.unwrap()).await; let arr = body["oneliners"].as_array().unwrap(); assert_eq!(arr.len(), 2); assert_eq!(arr[0]["case_id"], case_a, "A must sort first"); assert!(arr[0]["last_recording_at"].is_string()); let _ = std::fs::remove_dir_all(&dp); }