//! Lazy retention sweep: GET /web/cases must auto-close stale cases //! and auto-purge old closed cases, driven by per-user retention //! settings from users.toml. Tests age the *comparison objects* //! (m4a mtime, closed_at string) instead of faking "now" — gives //! realistic behavioural coverage without a clock abstraction. 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, header}; use filetime::FileTime; use tower::util::ServiceExt; use doctate_server::config::{Config, RetentionUserSettings, User}; fn unique_tmp(label: &str) -> PathBuf { std::env::temp_dir().join(format!( "doctate-retention-{label}-{}-{}", std::process::id(), uuid::Uuid::new_v4() )) } fn user_with_retention(slug: &str, auto_close_days: u32, auto_delete_days: u32) -> User { User { slug: slug.into(), api_key: format!("key-{slug}"), web_password: bcrypt::hash("s", 4).unwrap(), role: "doctor".into(), whisper: Default::default(), retention: RetentionUserSettings { auto_close_days, auto_delete_days, }, window_hours: 72, preview_lines: 2, } } fn config_with(data_path: PathBuf, auto_close_days: u32, auto_delete_days: u32) -> Arc { let users = vec![user_with_retention( "dr_a", auto_close_days, auto_delete_days, )]; let api_keys: HashMap = users .iter() .map(|u| (u.api_key.clone(), u.slug.clone())) .collect(); Arc::new(Config { data_path, users, api_keys, ..Config::test_default() }) } fn seed_case(data_path: &Path, slug: &str, case_id: &str) -> PathBuf { let dir = data_path.join(slug).join(case_id); std::fs::create_dir_all(&dir).unwrap(); dir } /// Create an m4a with a specific mtime (used to simulate "recorded N /// days ago"). The content is meaningless; only the mtime matters. fn seed_recording_with_age(case_dir: &Path, ts_hms: &str, age: Duration) { let filename = format!("2026-04-01T{ts_hms}Z.m4a"); let path = case_dir.join(&filename); std::fs::write(&path, b"audio").unwrap(); let target = SystemTime::now() - age; filetime::set_file_mtime(&path, FileTime::from_system_time(target)).unwrap(); } /// Write a `.closed` marker with a specific `closed_at` string (age /// is simulated by handing in a historical RFC3339 stamp). fn write_closed_marker(case_dir: &Path, closed_at: &str) { let json = format!(r#"{{"closed_at":"{closed_at}"}}"#); std::fs::write(case_dir.join(".closed"), json).unwrap(); } fn past_rfc3339(age: Duration) -> String { use time::format_description::well_known::Rfc3339; let t = time::OffsetDateTime::now_utc() - time::Duration::seconds(age.as_secs() as i64); t.format(&Rfc3339).unwrap() } async fn login(app: axum::Router, slug: &str) -> String { let body = format!("slug={slug}&password=s"); let resp = app .oneshot( Request::builder() .method("POST") .uri("/web/login") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(Body::from(body)) .unwrap(), ) .await .unwrap(); for v in resp.headers().get_all(header::SET_COOKIE).iter() { let s = v.to_str().unwrap(); if let Some(pair) = s.split(';').next() && pair.starts_with("session=") { return pair.to_string(); } } panic!("no session cookie after login"); } /// Trigger the sweep via its natural path: GET /web/cases. async fn trigger_sweep(app: &axum::Router, cookie: &str) { let resp = app .clone() .oneshot( Request::builder() .uri("/web/cases") .header(header::COOKIE, cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn auto_close_fires_when_last_recording_too_old() { let config = config_with(unique_tmp("close-old"), 7, 0); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording_with_age(&case_dir, "10-00-00", Duration::from_secs(8 * 86400)); let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; assert!(!case_dir.join(".closed").exists()); trigger_sweep(&app, &cookie).await; assert!( case_dir.join(".closed").exists(), "stale case must be auto-closed" ); } #[tokio::test] async fn auto_close_disabled_when_days_zero() { // auto_close_days = 0 → sweep never closes for age; even a very // old recording must stay open. let config = config_with(unique_tmp("close-zero"), 0, 0); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording_with_age(&case_dir, "10-00-00", Duration::from_secs(365 * 86400)); let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; trigger_sweep(&app, &cookie).await; assert!( !case_dir.join(".closed").exists(), "case must stay open when auto_close_days = 0" ); } #[tokio::test] async fn auto_close_respects_newest_recording() { // Case has one very old .m4a and one fresh .m4a — must stay open. let config = config_with(unique_tmp("close-newest"), 7, 0); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording_with_age(&case_dir, "10-00-00", Duration::from_secs(30 * 86400)); seed_recording_with_age(&case_dir, "11-00-00", Duration::from_secs(60)); let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; trigger_sweep(&app, &cookie).await; assert!( !case_dir.join(".closed").exists(), "recent addendum must keep the case open" ); } #[tokio::test] async fn auto_purge_removes_old_closed_case() { let config = config_with(unique_tmp("purge-old"), 0, 30); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording_with_age(&case_dir, "10-00-00", Duration::from_secs(60)); // closed_at = 31 days ago → must be purged. write_closed_marker(&case_dir, &past_rfc3339(Duration::from_secs(31 * 86400))); let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; trigger_sweep(&app, &cookie).await; assert!( !case_dir.exists(), "old closed case must be purged (directory gone)" ); } #[tokio::test] async fn auto_purge_disabled_when_days_zero() { // Admin-test guarantee: auto_delete_days = 0 means closed cases // are retained indefinitely. let config = config_with(unique_tmp("purge-zero"), 0, 0); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording_with_age(&case_dir, "10-00-00", Duration::from_secs(60)); write_closed_marker(&case_dir, &past_rfc3339(Duration::from_secs(365 * 86400))); let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; trigger_sweep(&app, &cookie).await; assert!( case_dir.exists(), "closed case must survive when auto_delete_days = 0" ); assert!(case_dir.join(".closed").exists()); } #[tokio::test] async fn fresh_auto_close_is_not_immediately_purged() { // Vacation scenario: user returns after 40 days offline. // auto_close_days = 7, auto_delete_days = 30. // The sweep auto-closes (step 1, closed_at ≈ now), but the purge // check (step 2) sees closed_at ≈ now and skips — the user gets // the full 30-day grace period, nothing disappears silently. let config = config_with(unique_tmp("vacation"), 7, 30); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording_with_age(&case_dir, "10-00-00", Duration::from_secs(40 * 86400)); let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; trigger_sweep(&app, &cookie).await; assert!( case_dir.exists(), "vacation: case directory must NOT be purged in the same sweep that auto-closed it" ); assert!( case_dir.join(".closed").exists(), "vacation: case must be auto-closed (but retained)" ); } #[tokio::test] async fn auto_purge_skips_open_cases() { let config = config_with(unique_tmp("purge-skip-open"), 0, 30); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&config.data_path, "dr_a", case_id); seed_recording_with_age(&case_dir, "10-00-00", Duration::from_secs(60)); let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; trigger_sweep(&app, &cookie).await; assert!( case_dir.exists(), "open case without .closed marker must never be purged" ); }