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:
@@ -25,6 +25,25 @@ for deterministic local runs; mDNS/macvlan remains an environment smoke path.
|
|||||||
| S15 | Three-way version skew | Three peers advertise the same catalog game ID. Peer A has `version.ini=20250101`, peer B has `version.ini=20250201`, and peer C has `version.ini=20250301`; each version has distinguishable file contents. An empty client connects to all three and downloads the game with `install=false`. | `list-games` shows one row for the game with `peer_count=3` and `eti_game_version=20250301`. The `got-game-files` descriptor set and transfer source are peer C's newest version only; no chunks come from A or B. The receiver's `version.ini` and SHA-256 manifest match C exactly. |
|
| S15 | Three-way version skew | Three peers advertise the same catalog game ID. Peer A has `version.ini=20250101`, peer B has `version.ini=20250201`, and peer C has `version.ini=20250301`; each version has distinguishable file contents. An empty client connects to all three and downloads the game with `install=false`. | `list-games` shows one row for the game with `peer_count=3` and `eti_game_version=20250301`. The `got-game-files` descriptor set and transfer source are peer C's newest version only; no chunks come from A or B. The receiver's `version.ini` and SHA-256 manifest match C exactly. |
|
||||||
| S16 | Latest-version fanout with stale peers present | Peer A has an older version of a game. Peers B and C both advertise the same newest version with matching file manifests; use a large file when proving chunk split. | The aggregated row still counts all ready peers, but eligible transfer peers are only B and C. Large-file chunks may split between B and C; peer A contributes no manifest majority vote and no file chunks. |
|
| S16 | Latest-version fanout with stale peers present | Peer A has an older version of a game. Peers B and C both advertise the same newest version with matching file manifests; use a large file when proving chunk split. | The aggregated row still counts all ready peers, but eligible transfer peers are only B and C. Large-file chunks may split between B and C; peer A contributes no manifest majority vote and no file chunks. |
|
||||||
| S17 | Latest-version conflict rejection | Peer A has an older version. Peers B and C both advertise the newest version, but their latest-version file sizes conflict. | Validation considers only the latest-version peers, so A cannot rescue the majority. The download fails with `download-failed`, and no committed target `version.ini` remains. |
|
| S17 | Latest-version conflict rejection | Peer A has an older version. Peers B and C both advertise the newest version, but their latest-version file sizes conflict. | Validation considers only the latest-version peers, so A cannot rescue the majority. The download fails with `download-failed`, and no committed target `version.ini` remains. |
|
||||||
|
| S18 | Mid-download source drop with redundancy | Client downloads a large shared game from two ready peers, then one source is killed after the download has begun. | Failed chunks are retried against the surviving source; the download finishes, no `download-failed` is emitted, and the receiver's files match the source by diff or SHA-256. |
|
||||||
|
| S19 | Mid-download sole-source drop | Client downloads a large game from one source, then that source is killed after the download has begun. | The download emits `download-failed`; no committed target `version.ini` remains; any partial payload is not advertised as ready; active operation state clears so a retry is possible. |
|
||||||
|
| S20 | Receiver write failure | Client downloads a large game into a constrained `/games` filesystem. | The download fails deterministically, no committed `version.ini` is advertised, and active operation state clears so the peer can retry later. |
|
||||||
|
| S21 | Add-game propagation | Two connected peers are running; one peer gains a new catalog game root through a completed download or an external drop. | The other peer receives a library update without reconnecting, and `list-games` shows the new remote game under the existing peer. |
|
||||||
|
| S22 | Remove-game propagation | Two connected peers are running; one peer loses a previously advertised game root. | The other peer receives a library update without dropping the peer, and `list-games` no longer shows that remote game. |
|
||||||
|
| S23 | Version bump propagation | Two connected peers are running; one peer's ready game root gets a newer `version.ini`. | The other peer receives a library update without reconnecting, and the aggregated row reflects the newer `eti_game_version`. |
|
||||||
|
| S24 | Two clients pull from one source | Two empty clients connect to the same source and download the same large game concurrently. | Both downloads finish, both receivers match the source by diff or SHA-256, and the source remains responsive. |
|
||||||
|
| S25 | One client downloads two games concurrently | One client connected to a source issues two different `download` commands without waiting for the first to finish. | Both operations may run in parallel; both eventually finish, each game reaches the requested install state, and each transferred root matches its source. |
|
||||||
|
| S26 | Same-game duplicate download rejection | A client starts downloading a game, then issues a second `download` command for the same game while the first operation is active. | The second request is rejected deterministically as an operation-in-progress condition; the first download is not corrupted and still reaches its documented final state. |
|
||||||
|
| S27 | Self-connect rejection | A peer sends `connect` to its own advertised listener address. | The command fails cleanly, no self-peer entry is created, and the peer remains responsive. |
|
||||||
|
| S28 | Address change without identity change | A known peer is rediscovered with the same peer ID and a different listener address while its library is still known. | The peer record updates in place to the new address, the existing library stays attached to that peer ID, and no duplicate peer entry appears. This is covered with a deterministic unit-level check until the CLI can rebind a live listener without restart. |
|
||||||
|
| S29 | Empty-library peer participates | A peer with no games connects into the mesh. | Other peers list it as a peer with zero games; it can receive a download, advertise the new game without restart, and become a source. |
|
||||||
|
| S30 | 5+ peer mesh aggregation | Five peers advertise partially overlapping catalog games with a mix of unique games, shared games, and differing versions; a sixth client connects to all five. | The client shows one row per game ID, correct ready-source `peer_count`, latest `eti_game_version`, no duplicates, and no self entries. |
|
||||||
|
| S31 | Bootstrapped peer becomes source in same session | An empty client downloads a game from a source, the original source shuts down, then a fresh third peer downloads the same game from the bootstrapped client. | The third peer's files match the original source by diff or SHA-256, proving downloaded files become servable without restart. |
|
||||||
|
| S32 | Reinstall after uninstall | A downloaded game is installed, uninstalled, then installed again without another download. | `local/` is recreated from preserved root files, no transfer events occur during reinstall, and the game returns to `installed=true`. |
|
||||||
|
| S33 | Install after external root mutation | A downloaded game root is externally mutated before `install` is issued. | The CLI fixture installer installs from the current root bytes. The resulting `local/fixture-payload.txt` must match the mutated archive bytes exactly. |
|
||||||
|
| S34 | Many-small-files game without `.eti` | A catalog game root contains `version.ini` plus many small regular files and no archive. | Download with `install=false` transfers every file, chunk events are coherent for small files, and source/receiver manifests match exactly. |
|
||||||
|
| S35 | Unknown game ID from remote peer | A remote peer advertises a game ID that is not in the receiver's catalog. | The receiver does not list the unknown game as downloadable, download attempts fail deterministically, and no local files are created. |
|
||||||
|
| S36 | Latest singleton beats stale majority | Five peers advertise one game; one peer has `20260501`, four peers have `20250101`. | `list-games` reports `eti_game_version=20260501`; all descriptors and chunks come from the singleton latest peer; stale peers contribute zero bytes. |
|
||||||
|
|
||||||
## Version-Skew Contract
|
## Version-Skew Contract
|
||||||
|
|
||||||
@@ -48,6 +67,23 @@ game ID:
|
|||||||
`download-chunk-finished` source addresses, and source/receiver SHA-256
|
`download-chunk-finished` source addresses, and source/receiver SHA-256
|
||||||
manifests.
|
manifests.
|
||||||
|
|
||||||
|
## Extended Failure And Mutation Contracts
|
||||||
|
|
||||||
|
Use S18-S36 to pin down operational behavior that is awkward to prove with the
|
||||||
|
GUI:
|
||||||
|
|
||||||
|
- A failed download must not commit the root `version.ini` sentinel. Partial
|
||||||
|
payload files may remain, but they must not be advertised as a ready local
|
||||||
|
game and must not leave an active operation stuck.
|
||||||
|
- Source failure during a redundant download should retry failed chunks against
|
||||||
|
another validated source for the same latest-version file.
|
||||||
|
- Live local library changes are observable by connected peers through library
|
||||||
|
deltas; reconnect is not required for add, remove, or version-bump cases.
|
||||||
|
- Same-game operations are single-flight. A duplicate download request while a
|
||||||
|
game is already active is rejected instead of starting another writer.
|
||||||
|
- Unknown remote game IDs are filtered by the receiver's current catalog and
|
||||||
|
are not downloadable.
|
||||||
|
|
||||||
For a manual run, prefer a catalog game ID already served by the fixture lab,
|
For a manual run, prefer a catalog game ID already served by the fixture lab,
|
||||||
such as `cnc4`, then create temporary `just peer-cli-run` game roots with
|
such as `cnc4`, then create temporary `just peer-cli-run` game roots with
|
||||||
different `version.ini` contents. The existing alpha/bravo/charlie fixtures
|
different `version.ini` contents. The existing alpha/bravo/charlie fixtures
|
||||||
@@ -56,6 +92,78 @@ until a dedicated fixture or temporary games root is prepared.
|
|||||||
|
|
||||||
## Run Log
|
## Run Log
|
||||||
|
|
||||||
|
### 2026-05-18 - Full Automated Docker Matrix Pass
|
||||||
|
|
||||||
|
- Runner: `python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
|
||||||
|
passed S1-S36 against the current `lanspread-peer-cli:dev` image.
|
||||||
|
- S1-S17 rerun highlights: startup, direct connect, aggregation, download,
|
||||||
|
install/uninstall, duplicate-source, ambiguous metadata, missing game,
|
||||||
|
shutdown cleanup, identity reconnect, serve gates, exact equality, large
|
||||||
|
multi-peer chunking, and latest-version selection/conflict all passed. Exact
|
||||||
|
transfer scenarios used `diff -r`/SHA-256 manifest checks; S14 chunk totals
|
||||||
|
were `58,721,049` and `67,108,864` bytes, balanced within one `32 MiB` chunk.
|
||||||
|
- S18-S36 rerun highlights: source-drop, disk-full, live mutation, concurrency,
|
||||||
|
duplicate-operation rejection, self-connect rejection, empty-peer sourcing,
|
||||||
|
5-peer aggregation, bootstrapped sourcing, reinstall, external mutation,
|
||||||
|
many-small-files, unknown catalog filtering, and stale-majority/latest
|
||||||
|
singleton cases all passed. File-copy scenarios used diff/manifests or `cmp`
|
||||||
|
for the mutated install payload.
|
||||||
|
|
||||||
|
### 2026-05-18 - Extended Scenario Docker Pass
|
||||||
|
|
||||||
|
- Runner: `python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
|
||||||
|
passed for S18-S36 after rebuilding `lanspread-peer-cli:dev` with
|
||||||
|
`just peer-cli-image`.
|
||||||
|
- S18 redundant source drop: one `alienswarm` source was killed after
|
||||||
|
`download-begin`; the client emitted `download-finished`, no
|
||||||
|
`download-failed`, and `diff -r`/SHA-256 manifest comparison matched the
|
||||||
|
surviving source. Recorded large-file chunk bytes from the surviving source:
|
||||||
|
`58,721,049`.
|
||||||
|
- S19 sole-source drop: killing the only source after `download-begin` emitted
|
||||||
|
`download-failed`; the receiver had no committed `alienswarm/version.ini`, no
|
||||||
|
ready local row, and no active operation left.
|
||||||
|
- S20 receiver write failure: a client with `/games` constrained to a `32m`
|
||||||
|
tmpfs emitted `download-failed`; `/games/alienswarm/version.ini` was absent
|
||||||
|
inside the container and active operations were empty.
|
||||||
|
- S21-S23 live mutation propagation: a connected peer observed `cod5` added,
|
||||||
|
`cod5` removed, and `cnc4` bumped from `20250101` to `20260501` without
|
||||||
|
reconnecting or dropping the peer.
|
||||||
|
- S24-S25 concurrency: two clients downloaded `alienswarm` from one source at
|
||||||
|
the same time and both diffed cleanly; one client downloaded `bfbc2` and
|
||||||
|
`cnctw` concurrently and both roots diffed cleanly.
|
||||||
|
- S26 duplicate same-game download: the second `alienswarm` download command
|
||||||
|
returned `operation already in progress for game alienswarm`; the first
|
||||||
|
download still finished and diffed cleanly.
|
||||||
|
- S27 self-connect rejection: connecting a peer to its own listener returned
|
||||||
|
`cannot connect peer to itself ...`; `list-peers` stayed empty and the peer
|
||||||
|
stayed responsive.
|
||||||
|
- S28 address-change invariant: `just test` passed and included
|
||||||
|
`peer_db::tests::address_update_preserves_peer_identity_and_library`.
|
||||||
|
- S29 empty-library peer: an observer first saw the empty peer with zero games;
|
||||||
|
after that peer downloaded `alienswarm`, the downloaded root diffed cleanly
|
||||||
|
and the observer's peer snapshot for that same peer contained `alienswarm`.
|
||||||
|
- S30 5-peer aggregation: a sixth client connected to five peers and aggregated
|
||||||
|
six game IDs with expected `peer_count` and latest versions, with no duplicate
|
||||||
|
game rows and no self-peer entry.
|
||||||
|
- S31 bootstrapped source: after the original source was killed, a third peer
|
||||||
|
downloaded `alienswarm` from the bootstrapped client and diffed cleanly
|
||||||
|
against the original fixture.
|
||||||
|
- S32 reinstall: reinstall after uninstall recreated `local/`, reported
|
||||||
|
`installed=true`, and produced no transfer chunk events during reinstall.
|
||||||
|
- S33 external root mutation: after mutating the downloaded `bfbc2.eti` inside
|
||||||
|
the client container, `install` wrote `local/fixture-payload.txt` that matched
|
||||||
|
the mutated archive exactly by `cmp`.
|
||||||
|
- S34 many-small-files transfer: a `bf1942` fixture with 20 small regular files
|
||||||
|
and no `.eti` downloaded with `install=false`; 21 file chunks were observed
|
||||||
|
including `version.ini`, and the receiver diffed cleanly against the source.
|
||||||
|
- S35 unknown game ID: a source advertised `mystery-game` via `--fixture`; the
|
||||||
|
receiver filtered it out of `list-games`, `download mystery-game` returned
|
||||||
|
`game mystery-game is not in the local catalog`, and no local files were
|
||||||
|
created.
|
||||||
|
- S36 latest singleton: with one peer on `20260501` and four peers on
|
||||||
|
`20250101`, the client reported `peer_count=5` and latest `20260501`; only
|
||||||
|
the singleton latest peer sent chunks and the final root diffed cleanly.
|
||||||
|
|
||||||
### 2026-05-18 - Full Matrix Manual Docker Pass
|
### 2026-05-18 - Full Matrix Manual Docker Pass
|
||||||
|
|
||||||
- Build/setup: `just peer-cli-image` passed. Local `just peer-cli-build`
|
- Build/setup: `just peer-cli-image` passed. Local `just peer-cli-build`
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,7 @@ struct LocalPeer {
|
|||||||
struct SharedState {
|
struct SharedState {
|
||||||
state: RwLock<CliState>,
|
state: RwLock<CliState>,
|
||||||
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
peer_game_db: Arc<RwLock<PeerGameDB>>,
|
||||||
|
catalog: Arc<RwLock<HashSet<String>>>,
|
||||||
notify: Notify,
|
notify: Notify,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +132,7 @@ async fn main() -> eyre::Result<()> {
|
|||||||
tx_events,
|
tx_events,
|
||||||
peer_game_db.clone(),
|
peer_game_db.clone(),
|
||||||
unpacker,
|
unpacker,
|
||||||
catalog,
|
catalog.clone(),
|
||||||
PeerStartOptions {
|
PeerStartOptions {
|
||||||
state_dir: Some(args.state_dir.clone()),
|
state_dir: Some(args.state_dir.clone()),
|
||||||
},
|
},
|
||||||
@@ -141,6 +142,7 @@ async fn main() -> eyre::Result<()> {
|
|||||||
let shared = Arc::new(SharedState {
|
let shared = Arc::new(SharedState {
|
||||||
state: RwLock::new(CliState::default()),
|
state: RwLock::new(CliState::default()),
|
||||||
peer_game_db,
|
peer_game_db,
|
||||||
|
catalog: catalog.clone(),
|
||||||
notify: Notify::new(),
|
notify: Notify::new(),
|
||||||
});
|
});
|
||||||
let writer = JsonlWriter::new();
|
let writer = JsonlWriter::new();
|
||||||
@@ -221,6 +223,8 @@ async fn handle_command(
|
|||||||
game_id,
|
game_id,
|
||||||
install_after_download,
|
install_after_download,
|
||||||
} => {
|
} => {
|
||||||
|
ensure_catalog_game(shared, game_id).await?;
|
||||||
|
ensure_no_active_operation(shared, game_id).await?;
|
||||||
let files = game_files_for_download(sender, shared, game_id).await?;
|
let files = game_files_for_download(sender, shared, game_id).await?;
|
||||||
sender.send(PeerCommand::DownloadGameFilesWithOptions {
|
sender.send(PeerCommand::DownloadGameFilesWithOptions {
|
||||||
id: game_id.clone(),
|
id: game_id.clone(),
|
||||||
@@ -230,12 +234,16 @@ async fn handle_command(
|
|||||||
Ok(json!({"queued": true, "game_id": game_id, "install": install_after_download}))
|
Ok(json!({"queued": true, "game_id": game_id, "install": install_after_download}))
|
||||||
}
|
}
|
||||||
CliCommand::Install { game_id } => {
|
CliCommand::Install { game_id } => {
|
||||||
|
ensure_catalog_game(shared, game_id).await?;
|
||||||
|
ensure_no_active_operation(shared, game_id).await?;
|
||||||
sender.send(PeerCommand::InstallGame {
|
sender.send(PeerCommand::InstallGame {
|
||||||
id: game_id.clone(),
|
id: game_id.clone(),
|
||||||
})?;
|
})?;
|
||||||
Ok(json!({"queued": true, "game_id": game_id}))
|
Ok(json!({"queued": true, "game_id": game_id}))
|
||||||
}
|
}
|
||||||
CliCommand::Uninstall { game_id } => {
|
CliCommand::Uninstall { game_id } => {
|
||||||
|
ensure_catalog_game(shared, game_id).await?;
|
||||||
|
ensure_no_active_operation(shared, game_id).await?;
|
||||||
sender.send(PeerCommand::UninstallGame {
|
sender.send(PeerCommand::UninstallGame {
|
||||||
id: game_id.clone(),
|
id: game_id.clone(),
|
||||||
})?;
|
})?;
|
||||||
@@ -243,6 +251,7 @@ async fn handle_command(
|
|||||||
}
|
}
|
||||||
CliCommand::WaitPeers { count, timeout } => wait_peers(shared, *count, *timeout).await,
|
CliCommand::WaitPeers { count, timeout } => wait_peers(shared, *count, *timeout).await,
|
||||||
CliCommand::Connect { addr } => {
|
CliCommand::Connect { addr } => {
|
||||||
|
ensure_not_self_connect(shared, *addr).await?;
|
||||||
sender.send(PeerCommand::ConnectPeer(*addr))?;
|
sender.send(PeerCommand::ConnectPeer(*addr))?;
|
||||||
Ok(json!({"queued": true, "addr": addr.to_string()}))
|
Ok(json!({"queued": true, "addr": addr.to_string()}))
|
||||||
}
|
}
|
||||||
@@ -275,7 +284,15 @@ async fn list_peers(shared: &SharedState) -> eyre::Result<Value> {
|
|||||||
|
|
||||||
async fn list_games(shared: &SharedState) -> eyre::Result<Value> {
|
async fn list_games(shared: &SharedState) -> eyre::Result<Value> {
|
||||||
let state = shared.state.read().await;
|
let state = shared.state.read().await;
|
||||||
let remote = shared.peer_game_db.read().await.get_all_games();
|
let catalog = shared.catalog.read().await.clone();
|
||||||
|
let remote = shared
|
||||||
|
.peer_game_db
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.get_all_games()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|game| catalog.contains(&game.id))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"local": state.local_games.clone(),
|
"local": state.local_games.clone(),
|
||||||
"remote": remote,
|
"remote": remote,
|
||||||
@@ -283,6 +300,40 @@ async fn list_games(shared: &SharedState) -> eyre::Result<Value> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn ensure_catalog_game(shared: &SharedState, game_id: &str) -> eyre::Result<()> {
|
||||||
|
if shared.catalog.read().await.contains(game_id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
eyre::bail!("game {game_id} is not in the local catalog");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_no_active_operation(shared: &SharedState, game_id: &str) -> eyre::Result<()> {
|
||||||
|
let state = shared.state.read().await;
|
||||||
|
if state
|
||||||
|
.active_operations
|
||||||
|
.iter()
|
||||||
|
.any(|operation| operation.id == game_id)
|
||||||
|
{
|
||||||
|
eyre::bail!("operation already in progress for game {game_id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_not_self_connect(shared: &SharedState, addr: SocketAddr) -> eyre::Result<()> {
|
||||||
|
let state = shared.state.read().await;
|
||||||
|
if state
|
||||||
|
.local_peer
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|peer| peer.addr == addr.to_string())
|
||||||
|
{
|
||||||
|
eyre::bail!("cannot connect peer to itself at {addr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn wait_peers(shared: &SharedState, count: usize, timeout: Duration) -> eyre::Result<Value> {
|
async fn wait_peers(shared: &SharedState, count: usize, timeout: Duration) -> eyre::Result<Value> {
|
||||||
let wait = async {
|
let wait = async {
|
||||||
loop {
|
loop {
|
||||||
@@ -356,6 +407,11 @@ async fn update_state_from_event(shared: &SharedState, event: PeerEvent) -> (&'s
|
|||||||
("local-peer-ready", json!(local_peer))
|
("local-peer-ready", json!(local_peer))
|
||||||
}
|
}
|
||||||
PeerEvent::ListGames(games) => {
|
PeerEvent::ListGames(games) => {
|
||||||
|
let catalog = shared.catalog.read().await.clone();
|
||||||
|
let games = games
|
||||||
|
.into_iter()
|
||||||
|
.filter(|game| catalog.contains(&game.id))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
shared.state.write().await.remote_games = games.clone();
|
shared.state.write().await.remote_games = games.clone();
|
||||||
("list-games", json!({ "games": games }))
|
("list-games", json!({ "games": games }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,14 @@ pub async fn handle_download_game_files_command(
|
|||||||
install_after_download: bool,
|
install_after_download: bool,
|
||||||
) {
|
) {
|
||||||
log::info!("Got PeerCommand::DownloadGameFiles");
|
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() };
|
let games_folder = { ctx.game_dir.read().await.clone() };
|
||||||
|
|
||||||
// Use majority validation to get trusted file descriptions and peer whitelist
|
// Use majority validation to get trusted file descriptions and peer whitelist
|
||||||
|
|||||||
@@ -948,6 +948,36 @@ mod tests {
|
|||||||
assert_eq!(db.peer_id_for_transport_addr(&source), None);
|
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]
|
#[test]
|
||||||
fn validation_uses_latest_version_file_metadata() {
|
fn validation_uses_latest_version_file_metadata() {
|
||||||
let old_addr = addr(12003);
|
let old_addr = addr(12003);
|
||||||
|
|||||||
Reference in New Issue
Block a user