diff --git a/crates/ipx/src/retention.rs b/crates/ipx/src/retention.rs index 82af2ac..73e97d2 100644 --- a/crates/ipx/src/retention.rs +++ b/crates/ipx/src/retention.rs @@ -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) { + 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![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![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 = (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![1]); + assert_eq!(dry.over_quota.iter().map(|c| c.id).collect::>(), 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![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::>(), [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![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();