mod common; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; use axum::http::StatusCode; use doctate_common::oneliners::OnelinerState; use filetime::FileTime; use tower::util::ServiceExt; use common::{ CLOSE_MARKER, TestConfig, body_json, get_with_api_key, get_with_api_key_if_none_match, header_str, seed_oneliner_ready, seed_oneliner_state, seed_recording_with_age, test_user, test_user_with_window_hours, }; const TEST_SLUG: &str = "dr_oneliners"; const TEST_KEY: &str = "key-dr_oneliners"; fn test_config() -> (std::sync::Arc, PathBuf) { let cfg = TestConfig::new() .with_label("oneliners") .with_user(test_user(TEST_SLUG)) .build(); let dp = cfg.data_path.clone(); (cfg, dp) } fn test_config_with_window_hours( hours: u32, ) -> (std::sync::Arc, PathBuf) { let cfg = TestConfig::new() .with_label("oneliners-win") .with_user(test_user_with_window_hours(TEST_SLUG, hours)) .build(); let dp = cfg.data_path.clone(); (cfg, dp) } /// Seed a case with an aged m4a and optionally a ready oneliner. /// Combines the common primitives — a single call is more ergonomic /// here than three repeated triples across every test. fn seed( 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); std::fs::create_dir_all(&case_dir).unwrap(); seed_recording_with_age(&case_dir, "2026-04-18T10-00-00Z", m4a_age); if let Some(text) = oneliner_text { seed_oneliner_ready(&case_dir, text); } case_dir } fn mark_deleted(case_dir: &Path) { std::fs::write(case_dir.join(CLOSE_MARKER), "{}").unwrap(); } #[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( axum::http::Request::builder() .method("GET") .uri("/api/oneliners") .body(axum::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_with_api_key("/api/oneliners", TEST_KEY)) .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("-72\""), "etag was {etag:?}"); let body = body_json(response).await; assert_eq!(body["window_hours"], 72); assert!(body["oneliners"].as_array().unwrap().is_empty()); } #[tokio::test] async fn recent_case_with_oneliner_shows_up() { let (config, dp) = test_config(); let case_id = "550e8400-e29b-41d4-a716-000000000001"; seed( &dp, case_id, Duration::from_secs(60), Some("Kniegelenk re., V.a. Meniskus"), ); let app = doctate_server::create_router(config); let response = app .oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) .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"]["kind"], "ready"); assert_eq!(arr[0]["oneliner"]["text"], "Kniegelenk re., V.a. Meniskus"); assert!(arr[0]["created_at"].is_string()); assert!(arr[0]["updated_at"].is_string()); } #[tokio::test] async fn case_without_oneliner_shows_null() { let (config, dp) = test_config(); seed( &dp, "550e8400-e29b-41d4-a716-000000000002", Duration::from_secs(60), None, ); let app = doctate_server::create_router(config); let body = body_json( app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) .await .unwrap(), ) .await; let arr = body["oneliners"].as_array().unwrap(); assert_eq!(arr.len(), 1); assert!(arr[0]["oneliner"].is_null()); } #[tokio::test] async fn case_older_than_default_window_excluded() { let (config, dp) = test_config(); seed( &dp, "550e8400-e29b-41d4-a716-000000000003", Duration::from_secs(80 * 3600), // 80 h old — outside the 72 h default Some("old case"), ); let app = doctate_server::create_router(config); let body = body_json( app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) .await .unwrap(), ) .await; assert!(body["oneliners"].as_array().unwrap().is_empty()); } /// Demonstrates that the visibility decision lives in users.toml: /// the same 40 h-old case is hidden under window_hours = 16 and /// visible under window_hours = 48. #[tokio::test] async fn case_visibility_follows_user_window_hours() { let case_id = "550e8400-e29b-41d4-a716-000000000004"; let age = Duration::from_secs(40 * 3600); let (config_small, dp_small) = test_config_with_window_hours(16); seed(&dp_small, case_id, age, Some("40h case")); let app_small = doctate_server::create_router(config_small); let body = body_json( app_small .oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) .await .unwrap(), ) .await; assert_eq!(body["window_hours"], 16); assert!(body["oneliners"].as_array().unwrap().is_empty()); let (config_wide, dp_wide) = test_config_with_window_hours(48); seed(&dp_wide, case_id, age, Some("40h case")); let app_wide = doctate_server::create_router(config_wide); let body = body_json( app_wide .oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) .await .unwrap(), ) .await; assert_eq!(body["window_hours"], 48); assert_eq!(body["oneliners"].as_array().unwrap().len(), 1); } /// Regression guard: a lingering `?hours=N` from an old client is /// silently ignored — the user-config value drives the response. #[tokio::test] async fn hours_query_param_is_ignored() { let (config, _dp) = test_config(); let app = doctate_server::create_router(config); let body = body_json( app.oneshot(get_with_api_key("/api/oneliners?hours=999", TEST_KEY)) .await .unwrap(), ) .await; assert_eq!(body["window_hours"], 72); } #[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_with_api_key("/api/oneliners", TEST_KEY)) .await .unwrap(); assert_eq!(first.status(), StatusCode::OK); let etag = header_str(&first, "etag"); assert!(!etag.is_empty()); let second = app .oneshot(get_with_api_key_if_none_match( "/api/oneliners", TEST_KEY, &etag, )) .await .unwrap(); assert_eq!(second.status(), StatusCode::NOT_MODIFIED); assert_eq!(header_str(&second, "etag"), etag); } #[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_api_key_if_none_match( "/api/oneliners", TEST_KEY, "W/\"99999999-72\"", )) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); } /// Two users with different `window_hours` must produce different /// ETag suffixes, even on empty data — so an admin's edit to /// users.toml invalidates client caches after the next restart. #[tokio::test] async fn etag_differs_between_users_with_different_window_hours() { let (config_a, _dp_a) = test_config_with_window_hours(16); let (config_b, _dp_b) = test_config_with_window_hours(48); let app_a = doctate_server::create_router(config_a); let app_b = doctate_server::create_router(config_b); let r_a = app_a .oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) .await .unwrap(); let r_b = app_b .oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) .await .unwrap(); let etag_a = header_str(&r_a, "etag"); let etag_b = header_str(&r_b, "etag"); assert_ne!(etag_a, etag_b); assert!(etag_a.ends_with("-16\""), "etag_a was {etag_a:?}"); assert!(etag_b.ends_with("-48\""), "etag_b was {etag_b:?}"); } #[tokio::test] async fn deleted_case_is_excluded() { let (config, dp) = test_config(); let case_dir = seed( &dp, "550e8400-e29b-41d4-a716-000000000005", Duration::from_secs(60), Some("deleted case"), ); mark_deleted(&case_dir); let app = doctate_server::create_router(config); let body = body_json( app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) .await .unwrap(), ) .await; assert!(body["oneliners"].as_array().unwrap().is_empty()); } #[tokio::test] async fn multiple_cases_sorted_newest_first() { let (config, dp) = test_config(); seed( &dp, "550e8400-e29b-41d4-a716-000000000010", Duration::from_secs(2 * 3600), Some("older"), ); seed( &dp, "550e8400-e29b-41d4-a716-000000000011", Duration::from_secs(30), Some("newer"), ); let app = doctate_server::create_router(config); let body = body_json( app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) .await .unwrap(), ) .await; let arr = body["oneliners"].as_array().unwrap(); assert_eq!(arr.len(), 2); assert_eq!(arr[0]["oneliner"]["text"], "newer"); assert_eq!(arr[1]["oneliner"]["text"], "older"); } /// 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); std::fs::create_dir_all(&dir_a).unwrap(); let old_m4a = dir_a.join("2026-04-17T12-00-00Z.m4a"); std::fs::write(&old_m4a, b"x").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"); std::fs::write(&fresh_m4a, b"x").unwrap(); filetime::set_file_mtime( &fresh_m4a, FileTime::from_system_time(SystemTime::now() - Duration::from_secs(60)), ) .unwrap(); seed_oneliner_ready(&dir_a, "A (fresh addendum)"); // Case B: single recording 10h ago — created_at newer than A's, but // last_recording_at older than A's fresh addendum. seed( &dp, "550e8400-e29b-41d4-a716-000000000021", Duration::from_secs(10 * 3600), Some("B (single, middle-aged)"), ); let app = doctate_server::create_router(config); let body = body_json( app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) .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()); } #[tokio::test] async fn empty_state_serializes_as_kind_empty() { let (config, dp) = test_config(); let case_dir = seed( &dp, "550e8400-e29b-41d4-a716-000000000030", Duration::from_secs(60), None, ); seed_oneliner_state( &case_dir, &OnelinerState::Empty { generated_at: "2026-04-18T10:00:00Z".into(), }, ); let app = doctate_server::create_router(config); let body = body_json( app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) .await .unwrap(), ) .await; let arr = body["oneliners"].as_array().unwrap(); assert_eq!(arr.len(), 1); assert_eq!(arr[0]["oneliner"]["kind"], "empty"); assert!(arr[0]["oneliner"].get("text").is_none()); } #[tokio::test] async fn error_state_serializes_as_kind_error() { let (config, dp) = test_config(); let case_dir = seed( &dp, "550e8400-e29b-41d4-a716-000000000031", Duration::from_secs(60), None, ); seed_oneliner_state( &case_dir, &OnelinerState::Error { generated_at: "2026-04-18T10:00:00Z".into(), }, ); let app = doctate_server::create_router(config); let body = body_json( app.oneshot(get_with_api_key("/api/oneliners", TEST_KEY)) .await .unwrap(), ) .await; let arr = body["oneliners"].as_array().unwrap(); assert_eq!(arr.len(), 1); assert_eq!(arr[0]["oneliner"]["kind"], "error"); }