use std::sync::Arc;
use holger_server_lib::audit::{AuditAction, MemoryAuditLog};
use holger_server_lib::retention::{
ArtifactRecord, GcOptions, GcOutcome, RetentionDecision, RetentionPlan, RetentionPolicy,
RetentionReason,
};
use holger_server_lib::{read_ron_config, wire_holger, Holger};
use traits::ArtifactId;
fn live_repo() -> (Holger, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let d = dir.path().display();
let ron = format!(
r#"(
exposed_endpoints: [ ( ron_name: "dev", ron_url: "127.0.0.1:0", ron_http_url: None ) ],
storage_endpoints: [ ( ron_name: "inert", ron_storage_type: "znippy", ron_path: "{d}/inert/" ) ],
repositories: [
( ron_name: "rust-dev", ron_repo_type: "rust", ron_data_dir: Some("{d}/rust-dev"), ron_base_url: Some("http://x"), ron_upstreams: [], ron_in: None, ron_out: Some(( ron_storage_endpoint: "inert", ron_exposed_endpoint: "dev" )) ),
],
)
"#
);
use std::io::Write;
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(ron.as_bytes()).unwrap();
let mut holger = read_ron_config(f.path()).expect("RON must parse");
holger.instantiate_backends().expect("backend builds");
wire_holger(&mut holger).expect("wires");
(holger, dir)
}
fn id(name: &str, version: &str) -> ArtifactId {
ArtifactId {
namespace: Some("crates".into()),
name: name.into(),
version: version.into(),
}
}
fn seed_serde(holger: &Holger) {
for v in ["1.0.0", "1.1.0", "1.2.0", "1.10.0", "2.0.0"] {
holger
.put("rust-dev", &id("serde", v), format!("serde-{v}").as_bytes())
.unwrap();
}
}
#[test]
fn dry_run_reclaims_nothing() {
let (holger, _guard) = live_repo();
seed_serde(&holger);
let policy = RetentionPolicy::new().keep_last(2); let audit = MemoryAuditLog::new();
let report = holger
.run_repo_retention("rust-dev", &policy, 1_700_000_000, &GcOptions::dry_run(), &audit, None)
.expect("dry-run executes");
assert!(report.dry_run);
assert_eq!(report.deleted_count, 0, "dry-run deletes nothing");
assert_eq!(report.reclaimed_bytes, 0);
assert_eq!(report.would_delete_count, 3, "but reports it WOULD reclaim 3");
assert!(report.actions.iter().all(|a| matches!(a.outcome, GcOutcome::WouldDelete)
|| !a.reasons.contains(&RetentionReason::SupersededByNewer)));
for v in ["1.0.0", "1.1.0", "1.2.0", "1.10.0", "2.0.0"] {
assert!(
holger.fetch("rust-dev", &id("serde", v)).unwrap().is_some(),
"dry-run must leave {v} in the store"
);
}
assert!(audit.events().is_empty(), "dry-run historizes no delete");
}
#[test]
fn execute_drops_exactly_the_delete_set_and_keeps_the_rest() {
let (holger, _guard) = live_repo();
seed_serde(&holger);
let policy = RetentionPolicy::new().keep_last(2);
let audit = MemoryAuditLog::new();
let plan = holger
.plan_repo_retention("rust-dev", &policy, 1_700_000_000)
.unwrap();
let report = holger
.execute_repo_retention("rust-dev", &plan, &GcOptions::execute(), &audit, None)
.expect("execute");
assert!(!report.dry_run);
assert_eq!(report.deleted_count, 3);
assert_eq!(report.failed_count, 0);
assert_eq!(report.skipped_count, 0);
assert!(report.reclaimed_bytes > 0);
for v in ["1.0.0", "1.1.0", "1.2.0"] {
assert!(
holger.fetch("rust-dev", &id("serde", v)).unwrap().is_none(),
"{v} must be reclaimed"
);
}
for v in ["1.10.0", "2.0.0"] {
assert!(
holger.fetch("rust-dev", &id("serde", v)).unwrap().is_some(),
"{v} (kept) must survive"
);
}
let evs = audit.events();
assert_eq!(evs.len(), 3, "one GC audit record per reclaim");
assert!(evs.iter().all(|e| e.action == AuditAction::Delete));
assert!(evs.iter().all(|e| e.repo == "rust-dev"));
assert!(evs.iter().all(|e| e.detail.contains("retention-gc")));
}
#[test]
fn execute_never_drops_a_protected_latest() {
let (holger, _guard) = live_repo();
holger.put("rust-dev", &id("serde", "1.0.0"), b"only-one").unwrap();
let policy = RetentionPolicy::new().keep_last(1); let audit = MemoryAuditLog::new();
let report = holger
.run_repo_retention("rust-dev", &policy, 1_700_000_000, &GcOptions::execute(), &audit, None)
.unwrap();
assert_eq!(report.deleted_count, 0, "the lone latest is protected");
assert!(
holger.fetch("rust-dev", &id("serde", "1.0.0")).unwrap().is_some(),
"protected latest survives execute"
);
}
#[test]
fn executor_refuses_a_delete_flag_on_a_protected_record() {
let (holger, _guard) = live_repo();
holger.put("rust-dev", &id("serde", "1.0.0"), b"precious").unwrap();
let rec = ArtifactRecord {
id: id("serde", "1.0.0"),
size_bytes: 8,
created_at: None,
protected_by_property: false,
};
let plan = RetentionPlan {
decisions: vec![RetentionDecision {
record: rec,
delete: true,
reasons: vec![RetentionReason::ProtectedLatest],
}],
kept_count: 0,
delete_count: 1,
kept_bytes: 0,
reclaimed_bytes: 8,
name_count: 1,
evaluated_at: 1_700_000_000,
};
let audit = MemoryAuditLog::new();
let report = holger
.execute_repo_retention("rust-dev", &plan, &GcOptions::execute(), &audit, None)
.unwrap();
assert_eq!(report.deleted_count, 0, "protected record must not be dropped");
assert_eq!(report.skipped_count, 1);
assert!(matches!(report.actions[0].outcome, GcOutcome::Skipped(_)));
assert!(
holger.fetch("rust-dev", &id("serde", "1.0.0")).unwrap().is_some(),
"the protected artifact must survive a corrupt plan"
);
assert!(audit.events().is_empty());
}
#[test]
fn execute_records_a_jera_job_when_a_ledger_is_attached() {
let (holger, _guard) = live_repo();
seed_serde(&holger);
let led_dir = tempfile::tempdir().unwrap();
let jobs = Arc::new(jera::jobs::JobStore::open(led_dir.path()).expect("open jera ledger"));
let policy = RetentionPolicy::new().keep_last(2);
let audit = MemoryAuditLog::new();
holger
.run_repo_retention(
"rust-dev",
&policy,
1_700_000_000,
&GcOptions::execute(),
&audit,
Some(&jobs),
)
.expect("execute with ledger");
let recorded = jobs
.list(&jera::jobs::JobSelector::Kind("retention-gc".into()))
.expect("list jobs");
assert_eq!(recorded.len(), 1, "one retention-gc job recorded");
assert!(recorded[0].is_terminal(), "the GC job settled");
}