813fb896ff
Pulls three pattern groups into shared helpers so a rename or wire-format tweak edits one file instead of 10+: - BulkAction enum in doctate-common replaces the "close"/"analyze"/"reset" string literals that were duplicated between the bulk handler and 3 attack/CSRF test files. FromStr preserves the exact "Unbekannte Aktion" error shape; the handler match is now exhaustive over the enum. - join_url helper in doctate-common absorbs 7 identical trim_end_matches+format! call sites across client-core, client-desktop, server/transcribe (ollama, whisper), and server/analyze (llm). - server/tests/common/artefacts.rs re-exports ONELINER_FILENAME (doctate-common), DOCUMENT_FILE + ANALYSIS_INPUT_FILE (doctate-server::analyze), and CLOSE_MARKER (doctate-server::paths) so test files reference the canonical name instead of inlining literals. Also lifts client-desktop local duplication: - paths.rs: project_path helper collapses 4 identical ProjectDirs chains - main.rs: or_die helper replaces 4 eprintln!+exit(1) blocks - app.rs: named RecordingContext struct replaces (Uuid, String) tuple at 3 sites around the ffmpeg-flush finalization path Verification: 397 tests pass (baseline was 389; +8 for new unit tests on BulkAction and join_url), 0 failed, 4 ignored (unchanged). clippy clean.
198 lines
6.5 KiB
Rust
198 lines
6.5 KiB
Rust
//! 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.
|
|
|
|
mod common;
|
|
|
|
use std::time::Duration;
|
|
|
|
use axum::http::StatusCode;
|
|
use tower::util::ServiceExt;
|
|
|
|
use common::{
|
|
CLOSE_MARKER, TestConfig, get_with_cookie, past_rfc3339, paths, seed_case,
|
|
seed_recording_with_age, test_user_with_retention, write_closed_marker,
|
|
};
|
|
|
|
/// Trigger the sweep via its natural path: GET /web/cases.
|
|
async fn trigger_sweep(app: &axum::Router, cookie: &str) {
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(get_with_cookie(paths::CASES, cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
fn build_cfg(
|
|
label: &'static str,
|
|
auto_close_days: u32,
|
|
auto_delete_days: u32,
|
|
) -> std::sync::Arc<doctate_server::config::Config> {
|
|
TestConfig::new()
|
|
.with_label(label)
|
|
.with_user(test_user_with_retention(
|
|
"dr_a",
|
|
auto_close_days,
|
|
auto_delete_days,
|
|
))
|
|
.build()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auto_close_fires_when_last_recording_too_old() {
|
|
let cfg = build_cfg("close-old", 7, 0);
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
|
|
seed_recording_with_age(
|
|
&case_dir,
|
|
"2026-04-01T10-00-00Z",
|
|
Duration::from_secs(8 * 86400),
|
|
);
|
|
|
|
let app = doctate_server::create_router(cfg);
|
|
let cookie = common::login(&app, "dr_a", "s").await;
|
|
|
|
assert!(!case_dir.join(CLOSE_MARKER).exists());
|
|
trigger_sweep(&app, &cookie).await;
|
|
assert!(
|
|
case_dir.join(CLOSE_MARKER).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 cfg = build_cfg("close-zero", 0, 0);
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
|
|
seed_recording_with_age(
|
|
&case_dir,
|
|
"2026-04-01T10-00-00Z",
|
|
Duration::from_secs(365 * 86400),
|
|
);
|
|
|
|
let app = doctate_server::create_router(cfg);
|
|
let cookie = common::login(&app, "dr_a", "s").await;
|
|
|
|
trigger_sweep(&app, &cookie).await;
|
|
assert!(
|
|
!case_dir.join(CLOSE_MARKER).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 cfg = build_cfg("close-newest", 7, 0);
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
|
|
seed_recording_with_age(
|
|
&case_dir,
|
|
"2026-04-01T10-00-00Z",
|
|
Duration::from_secs(30 * 86400),
|
|
);
|
|
seed_recording_with_age(&case_dir, "2026-04-01T11-00-00Z", Duration::from_secs(60));
|
|
|
|
let app = doctate_server::create_router(cfg);
|
|
let cookie = common::login(&app, "dr_a", "s").await;
|
|
|
|
trigger_sweep(&app, &cookie).await;
|
|
assert!(
|
|
!case_dir.join(CLOSE_MARKER).exists(),
|
|
"recent addendum must keep the case open"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auto_purge_removes_old_closed_case() {
|
|
let cfg = build_cfg("purge-old", 0, 30);
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
|
|
seed_recording_with_age(&case_dir, "2026-04-01T10-00-00Z", 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(cfg);
|
|
let cookie = common::login(&app, "dr_a", "s").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 cfg = build_cfg("purge-zero", 0, 0);
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
|
|
seed_recording_with_age(&case_dir, "2026-04-01T10-00-00Z", Duration::from_secs(60));
|
|
write_closed_marker(&case_dir, &past_rfc3339(Duration::from_secs(365 * 86400)));
|
|
|
|
let app = doctate_server::create_router(cfg);
|
|
let cookie = common::login(&app, "dr_a", "s").await;
|
|
|
|
trigger_sweep(&app, &cookie).await;
|
|
assert!(
|
|
case_dir.exists(),
|
|
"closed case must survive when auto_delete_days = 0"
|
|
);
|
|
assert!(case_dir.join(CLOSE_MARKER).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 cfg = build_cfg("vacation", 7, 30);
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
|
|
seed_recording_with_age(
|
|
&case_dir,
|
|
"2026-04-01T10-00-00Z",
|
|
Duration::from_secs(40 * 86400),
|
|
);
|
|
|
|
let app = doctate_server::create_router(cfg);
|
|
let cookie = common::login(&app, "dr_a", "s").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(CLOSE_MARKER).exists(),
|
|
"vacation: case must be auto-closed (but retained)"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auto_purge_skips_open_cases() {
|
|
let cfg = build_cfg("purge-skip-open", 0, 30);
|
|
let case_id = "11111111-1111-1111-1111-111111111111";
|
|
let case_dir = seed_case(&cfg.data_path, "dr_a", case_id);
|
|
seed_recording_with_age(&case_dir, "2026-04-01T10-00-00Z", Duration::from_secs(60));
|
|
|
|
let app = doctate_server::create_router(cfg);
|
|
let cookie = common::login(&app, "dr_a", "s").await;
|
|
|
|
trigger_sweep(&app, &cookie).await;
|
|
assert!(
|
|
case_dir.exists(),
|
|
"open case without .closed marker must never be purged"
|
|
);
|
|
}
|