diff --git a/crates/lanspread-peer/src/install/transaction.rs b/crates/lanspread-peer/src/install/transaction.rs index c855bac..a6b8a27 100644 --- a/crates/lanspread-peer/src/install/transaction.rs +++ b/crates/lanspread-peer/src/install/transaction.rs @@ -464,7 +464,11 @@ mod tests { use std::{ collections::HashSet, path::{Path, PathBuf}, - sync::{Arc, Mutex}, + sync::{ + Arc, + Mutex, + atomic::{AtomicU64, Ordering}, + }, }; use super::*; @@ -503,12 +507,16 @@ mod tests { struct TempDir(PathBuf); + static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); + impl TempDir { fn new() -> Self { let mut path = std::env::temp_dir(); + let unique_id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); path.push(format!( - "lanspread-install-{}-{}", + "lanspread-install-{}-{}-{}", std::process::id(), + unique_id, std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -637,37 +645,174 @@ mod tests { assert!(root.join("version.ini").is_file()); } - #[tokio::test] - async fn recovery_restores_backup_for_interrupted_update() { - let temp = TempDir::new(); - let root = temp.game_root(); + #[derive(Clone)] + struct RecoveryCase { + name: &'static str, + intent_state: InstallIntentState, + has_local: bool, + has_installing: bool, + has_backup: bool, + expected_local_payload: Option<&'static [u8]>, + } + + const LOCAL_PAYLOAD: &[u8] = b"local"; + const INSTALLING_PAYLOAD: &[u8] = b"installing"; + const BACKUP_PAYLOAD: &[u8] = b"backup"; + + fn seed_recovery_case(root: &Path, case: &RecoveryCase) { write_file(&root.join("version.ini"), b"20250101"); - write_file(&root.join(".local.backup").join("old.txt"), b"old"); - write_file(&root.join(".local.installing").join("new.txt"), b"new"); - write_file(&root.join(".local.backup").join(OWNED_MARKER), b""); - write_file(&root.join(".local.installing").join(OWNED_MARKER), b""); - write_intent( - &root, - &InstallIntent::new( - "game", - InstallIntentState::Updating, - Some("20250101".into()), + if case.has_local { + write_file(&root.join(LOCAL_DIR).join("payload.txt"), LOCAL_PAYLOAD); + } + if case.has_installing { + write_file( + &root.join(INSTALLING_DIR).join("payload.txt"), + INSTALLING_PAYLOAD, + ); + } + if case.has_backup { + write_file(&root.join(BACKUP_DIR).join("payload.txt"), BACKUP_PAYLOAD); + } + } + + fn assert_recovered_case(root: &Path, case: &RecoveryCase) { + let local_payload = root.join(LOCAL_DIR).join("payload.txt"); + match case.expected_local_payload { + Some(expected) => assert_eq!( + std::fs::read(&local_payload) + .unwrap_or_else(|err| panic!("{} local payload: {err}", case.name)), + expected, + "{} local payload", + case.name ), - ) - .await - .expect("intent should be written"); - - recover_game_root(&root, "game") - .await - .expect("recovery should succeed"); - - assert!(root.join("local").join("old.txt").is_file()); - assert!(!root.join(".local.installing").exists()); - assert!(!root.join(".local.backup").exists()); - assert_eq!( - read_intent(&root, "game").await.state, - InstallIntentState::None + None => assert!( + !root.join(LOCAL_DIR).exists(), + "{} local dir should be absent", + case.name + ), + } + assert!( + !root.join(INSTALLING_DIR).exists(), + "{} installing dir should be absent", + case.name ); + assert!( + !root.join(BACKUP_DIR).exists(), + "{} backup dir should be absent", + case.name + ); + } + + #[tokio::test] + async fn recovery_covers_install_matrix_rows() { + let cases = [ + RecoveryCase { + name: "installing_commit_landed", + intent_state: InstallIntentState::Installing, + has_local: true, + has_installing: false, + has_backup: false, + expected_local_payload: Some(LOCAL_PAYLOAD), + }, + RecoveryCase { + name: "installing_staging_only", + intent_state: InstallIntentState::Installing, + has_local: false, + has_installing: true, + has_backup: false, + expected_local_payload: None, + }, + RecoveryCase { + name: "installing_before_staging", + intent_state: InstallIntentState::Installing, + has_local: false, + has_installing: false, + has_backup: false, + expected_local_payload: None, + }, + RecoveryCase { + name: "updating_restore_backup", + intent_state: InstallIntentState::Updating, + has_local: false, + has_installing: true, + has_backup: true, + expected_local_payload: Some(BACKUP_PAYLOAD), + }, + RecoveryCase { + name: "updating_commit_landed_with_stale_dirs", + intent_state: InstallIntentState::Updating, + has_local: true, + has_installing: true, + has_backup: true, + expected_local_payload: Some(LOCAL_PAYLOAD), + }, + RecoveryCase { + name: "updating_commit_landed_before_backup_cleanup", + intent_state: InstallIntentState::Updating, + has_local: true, + has_installing: false, + has_backup: true, + expected_local_payload: Some(LOCAL_PAYLOAD), + }, + RecoveryCase { + name: "updating_commit_and_cleanup_landed", + intent_state: InstallIntentState::Updating, + has_local: true, + has_installing: false, + has_backup: false, + expected_local_payload: Some(LOCAL_PAYLOAD), + }, + RecoveryCase { + name: "uninstalling_delete_backup", + intent_state: InstallIntentState::Uninstalling, + has_local: false, + has_installing: false, + has_backup: true, + expected_local_payload: None, + }, + RecoveryCase { + name: "uninstalling_already_clean", + intent_state: InstallIntentState::Uninstalling, + has_local: false, + has_installing: false, + has_backup: false, + expected_local_payload: None, + }, + RecoveryCase { + name: "uninstalling_before_fs_work", + intent_state: InstallIntentState::Uninstalling, + has_local: true, + has_installing: false, + has_backup: false, + expected_local_payload: None, + }, + ]; + + for case in cases { + let temp = TempDir::new(); + let root = temp.game_root(); + seed_recovery_case(&root, &case); + write_intent( + &root, + &InstallIntent::new("game", case.intent_state.clone(), Some("20250101".into())), + ) + .await + .unwrap_or_else(|err| panic!("{} intent should be written: {err}", case.name)); + + recover_game_root(&root, "game") + .await + .unwrap_or_else(|err| panic!("{} recovery should succeed: {err}", case.name)); + + assert_recovered_case(&root, &case); + let intent = read_intent(&root, "game").await; + assert_eq!(intent.state, InstallIntentState::None, "{}", case.name); + assert_eq!( + intent.eti_version.as_deref(), + Some("20250101"), + "{}", + case.name + ); + } } #[tokio::test]