Tests for the reaper deleting files (#396)

retention::run's age and quota passes and remove ran in no test: only
the pure choosers did. Three tests now run them against a scratch
downloads folder and a database: an old file deleted, marked reaped,
its bytes counted and its entry pruned, a dry run deleting and pruning
nothing; over quota the oldest going first with the age pass's take
not counted again; a file already gone reaped without an error, and
one procfs will not unlink left claiming its file with nothing freed.
Held items, which have no path, are never candidates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 03:55:52 +00:00
parent bda2ef07be
commit a92e81aba2

View File

@@ -267,6 +267,142 @@ mod tests {
assert_eq!(run(&cfg, &db, false).await.unwrap().reconciled, 1, "the folder is there and the file is not");
}
/// A scratch downloads folder of its own, so tests running at once never share files.
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ipx-reap-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn file(dir: &Path, name: &str, len: usize) -> String {
let p = dir.join(name);
std::fs::write(&p, vec![0u8; len]).unwrap();
p.display().to_string()
}
async fn state(db: &Db, id: i64) -> (String, Option<String>) {
let s = db
.strings_for_test(&format!("SELECT state || '|' || coalesce(path, '') FROM enclosures WHERE id = {id}"))
.await;
let (state, path) = s[0].split_once('|').unwrap();
(state.into(), Some(path.to_string()).filter(|p| !p.is_empty()))
}
#[tokio::test]
async fn the_age_pass_deletes_old_files_and_a_dry_run_only_counts_them() {
let dir = scratch("age");
let (old, new) = (file(&dir, "old.mp3", 100), file(&dir, "new.mp3", 200));
let (day, now) = (86_400, db::now());
let db = Db::memory().await.unwrap();
db.exec_for_test(&format!(
"INSERT INTO entries (feed_id, guid, first_seen) VALUES
('f', 'old', {old_seen}), ('f', 'new', {now}), ('f', 'stale', {old_seen}), ('f', 'held', {old_seen});
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
(1, 'f', 'old', 'u1', '{old}', 100, 'done', {old_at}),
(2, 'f', 'new', 'u2', '{new}', 200, 'done', {new_at}),
(3, 'f', 'held', 'u3', NULL, 0, 'held', {old_at});",
old_seen = now - 40 * day,
old_at = now - 10 * day,
new_at = now - day,
))
.await
.unwrap();
let mut cfg = Config::default();
cfg.general.download_dir = dir.clone();
cfg.general.max_age_days = 7;
let dry = run(&cfg, &db, true).await.unwrap();
assert_eq!(dry.aged_out.iter().map(|c| c.id).collect::<Vec<_>>(), vec![1], "a held item has no file to take");
assert_eq!((dry.bytes_freed, dry.entries_pruned), (100, 0), "counted, and nothing pruned");
assert!(Path::new(&old).exists(), "a dry run deletes nothing");
assert_eq!(state(&db, 1).await.0, "done");
let r = run(&cfg, &db, false).await.unwrap();
assert_eq!(r.aged_out.iter().map(|c| c.id).collect::<Vec<_>>(), vec![1]);
assert_eq!(r.bytes_freed, 100);
assert!(!Path::new(&old).exists() && Path::new(&new).exists(), "only the old file goes");
assert_eq!(state(&db, 1).await, ("reaped".into(), None), "the row stays, so it is not fetched again");
assert_eq!(state(&db, 2).await, ("done".into(), Some(new)));
assert_eq!(state(&db, 3).await.0, "held");
// The reaped one's entry and the one that never had a file; not held, whose row has no
// path, nor new.
assert_eq!(r.entries_pruned, 3, "old, stale and held are past the cutoff and have no file");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn over_quota_the_oldest_go_and_the_age_pass_is_not_counted_twice() {
let dir = scratch("quota");
let paths: Vec<String> = (1..=4).map(|i| file(&dir, &format!("{i}.mp3"), 10 * i)).collect();
let (day, now) = (86_400, db::now());
let db = Db::memory().await.unwrap();
// Recorded sizes, not the files' own: a dry run counts these. 1 is past the age limit.
db.exec_for_test(&format!(
"INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
(1, 'f', 'a', 'u1', '{}', 2000000, 'done', {}),
(2, 'f', 'b', 'u2', '{}', 1000000, 'done', {}),
(3, 'f', 'c', 'u3', '{}', 1000000, 'done', {}),
(4, 'f', 'd', 'u4', '{}', 1000000, 'done', {});",
paths[0],
now - 40 * day,
paths[1],
now - 5 * day,
paths[2],
now - 3 * day,
paths[3],
now - day,
))
.await
.unwrap();
let mut cfg = Config::default();
cfg.general.download_dir = dir.clone();
cfg.general.max_age_days = 30;
// 53.7 MB less the 50 MB pad leaves about 1.26 MB. Without 1, the other three are 3 MB:
// two must go. Counting 1 again would make it 5 MB and take all three.
cfg.general.max_total_gb = 0.05;
let dry = run(&cfg, &db, true).await.unwrap();
assert_eq!(dry.aged_out.iter().map(|c| c.id).collect::<Vec<_>>(), vec![1]);
assert_eq!(dry.over_quota.iter().map(|c| c.id).collect::<Vec<_>>(), vec![2, 3], "oldest first");
assert_eq!(dry.bytes_freed, 4_000_000);
assert!(paths.iter().all(|p| Path::new(p).exists()), "a dry run deletes nothing");
let r = run(&cfg, &db, false).await.unwrap();
assert_eq!(r.over_quota.iter().map(|c| c.id).collect::<Vec<_>>(), vec![2, 3]);
assert_eq!(r.bytes_freed, 10 + 20 + 30, "what was on disk, not what was recorded");
assert_eq!(paths.iter().map(|p| Path::new(p).exists()).collect::<Vec<_>>(), [false, false, false, true]);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn a_file_already_gone_is_reaped_and_one_that_will_not_delete_is_left() {
let dir = scratch("stuck");
let now = db::now();
let db = Db::memory().await.unwrap();
// procfs refuses an unlink even from root, and the file is there, so it is a candidate.
db.exec_for_test(&format!(
"INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
(1, 'f', 'gone', 'u1', '{}', 100, 'done', 1),
(2, 'f', 'stuck', 'u2', '/proc/version', 100, 'done', 1);",
dir.join("gone.mp3").display(),
))
.await
.unwrap();
let mut cfg = Config::default();
cfg.general.download_dir = dir.clone();
cfg.general.max_age_days = 1;
assert!(now > 86_400);
let r = run(&cfg, &db, false).await.unwrap();
assert_eq!(r.reconciled, 1, "the missing file is noticed");
assert_eq!(state(&db, 1).await, ("reaped".into(), None));
assert_eq!(r.aged_out.iter().map(|c| c.id).collect::<Vec<_>>(), vec![2]);
assert_eq!(r.bytes_freed, 0, "nothing was freed");
assert_eq!(state(&db, 2).await, ("done".into(), Some("/proc/version".into())), "still claims its file");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn prune_keeps_entries_that_still_have_a_file() {
let db = Db::memory().await.unwrap();