test(peer-cli): cover full docker scenario matrix

Merge the S18-S36 scenario ideas into the official peer-cli scenario matrix
and add a Docker-backed runner that now exercises S1-S36 with concrete file
proofs. The runner creates temporary fixtures under .lanspread-peer-cli, drives
JSONL peer containers, checks transferred roots with diff and SHA-256 manifests,
and covers startup, discovery, transfer, failure, mutation, concurrency, mesh,
lifecycle, and catalog edge cases.

The scenarios exposed a few harness/runtime boundary gaps that would otherwise
make the contract ambiguous. The peer CLI now rejects self-connects, rejects
commands for game IDs outside the receiver catalog, filters unknown remote games
from its command/event surface, and reports duplicate active same-game commands
as operation-in-progress errors. The peer core also refuses non-catalog download
commands before transfer, and PeerGameDB has a unit check that address changes
preserve identity and library state.

S12 and S28 remain unit-level invariants because the CLI cannot stably race raw
serve-gate requests or rebind a live listener without restart. The runner treats
those scenarios as covered by just test and checks the expected unit test names
appear in the output.

Test Plan:
- just fmt
- python3 -m py_compile crates/lanspread-peer-cli/scripts/run_extended_scenarios.py
- RUSTC_WRAPPER= just test
- RUSTC_WRAPPER= just clippy
- RUSTC_WRAPPER= just peer-cli-build
- just peer-cli-image
- python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py
- git diff --check

Refs: PEER_CLI_SCENARIOS.md S1-S36
This commit is contained in:
2026-05-18 23:54:10 +02:00
parent 8b3aefd2db
commit a8edcd7450
5 changed files with 1599 additions and 2 deletions
+8
View File
@@ -198,6 +198,14 @@ pub async fn handle_download_game_files_command(
install_after_download: bool,
) {
log::info!("Got PeerCommand::DownloadGameFiles");
if !catalog_contains(ctx, &id).await {
log::warn!("Ignoring download command for non-catalog game {id}");
if let Err(send_err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id }) {
log::error!("Failed to send DownloadGameFilesFailed event: {send_err}");
}
return;
}
let games_folder = { ctx.game_dir.read().await.clone() };
// Use majority validation to get trusted file descriptions and peer whitelist
+30
View File
@@ -948,6 +948,36 @@ mod tests {
assert_eq!(db.peer_id_for_transport_addr(&source), None);
}
#[test]
fn address_update_preserves_peer_identity_and_library() {
let old_addr = ip_addr([10, 66, 0, 2], 40000);
let new_addr = ip_addr([10, 66, 0, 3], 41000);
let mut db = PeerGameDB::new();
let first = db.upsert_peer("peer".to_string(), old_addr);
assert!(first.is_new);
db.update_peer_games(
&"peer".to_string(),
vec![summary("game", "20250101", Availability::Ready)],
);
let second = db.upsert_peer("peer".to_string(), new_addr);
assert!(!second.is_new);
assert!(second.addr_changed);
let peers = db.peer_snapshots();
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].peer_id, "peer");
assert_eq!(peers[0].addr, new_addr);
assert_eq!(peers[0].games.len(), 1);
assert_eq!(peers[0].games[0].id, "game");
assert_eq!(db.peer_id_for_addr(&old_addr), None);
assert_eq!(
db.peer_id_for_addr(&new_addr).map(String::as_str),
Some("peer")
);
}
#[test]
fn validation_uses_latest_version_file_metadata() {
let old_addr = addr(12003);