diff --git a/openfut-utas-host/tests/economy_integration.rs b/openfut-utas-host/tests/economy_integration.rs index 2693373..cd2a53a 100644 --- a/openfut-utas-host/tests/economy_integration.rs +++ b/openfut-utas-host/tests/economy_integration.rs @@ -988,6 +988,24 @@ fn pure_economy_routes() -> Vec<(&'static str, String, Vec)> { "/ut/delete/game/fifa17/trade/900000001".into(), b"".to_vec(), ), + // ── Retail v2 Store family (the S2 live-failure shapes). These MUST be + // Rust-owned exactly like their v1 forms. ── + ( + "PUT", + "/ut/v2/game/fifa17/store/transaction/0".into(), + br#"{"packId":1}"#.to_vec(), + ), + ( + "GET", + "/ut/v2/game/fifa17/store/purchasegroup".into(), + b"".to_vec(), + ), + ( + "POST", + "/ut/v2/game/fifa17/purchased".into(), + br#"{"packId":70}"#.to_vec(), + ), + ("GET", "/ut/v2/game/fifa17/purchased".into(), b"".to_vec()), ] } @@ -1019,6 +1037,41 @@ fn barrier_checks(base: &str, dir: &std::path::Path, mock: &MockPython) { "userMassInfo coins overlaid to Core (stale Python 111 not visible)" ); + // ── PART 7 REPRO: the exact S2 live-failure shape (retail v2 Store BUY, + // `PUT /ut/v2/game/fifa17/store/transaction/0`) is now Rust-owned — it + // debits Core and returns a `createPackResponse`, NOT the Python + // `{"state":"TRANSACTIONCANCEL"}` no-op the rejected candidate produced. ── + let before_buy = client.balance().unwrap(); + let calls_before_buy = mock.calls.load(std::sync::atomic::Ordering::SeqCst); + let buy = server.handle_with_ip( + "PUT", + "/ut/v2/game/fifa17/store/transaction/0", + &[], + br#"{"packId":1}"#, + None, + ); + assert_eq!(buy.status, 200, "v2 Store BUY handled by Rust (200)"); + let buyv: Value = serde_json::from_slice(&buy.body).unwrap(); + assert!( + buyv.get("createPackResponse").is_some(), + "v2 Store BUY returns a Rust createPackResponse, not the Python no-op: {buyv}" + ); + assert_ne!( + buyv.get("state").and_then(|s| s.as_str()), + Some("TRANSACTIONCANCEL"), + "v2 Store BUY must NOT be the Python TRANSACTIONCANCEL fallback" + ); + assert_eq!( + mock.calls.load(std::sync::atomic::Ordering::SeqCst), + calls_before_buy, + "v2 Store BUY never reached the Python proxy" + ); + let after_buy = client.balance().unwrap(); + assert!( + after_buy < before_buy, + "v2 Store BUY debited Core coins ({before_buy} -> {after_buy})" + ); + // ── NEVER BOTH (Core up): pure economy routes reach Rust, never Python ── let before = mock.calls.load(std::sync::atomic::Ordering::SeqCst); for (m, p, b) in pure_economy_routes() { @@ -1144,3 +1197,112 @@ async fn barrier_never_both_no_fallback_and_stale_reader() { h.abort(); std::fs::remove_dir_all(&dir).ok(); } + +// ─────────────── Retail v2 Store E2E + v1/v2 route equivalence ─────────────── +// +// Proves the S2 fix end-to-end through the REAL dispatch: the Store flow driven +// over the retail `/ut/v2/game//…` paths is Rust-owned (Python proxy count +// 0), mutates Core, and behaves IDENTICALLY to the v1 paths for the same op. + +fn v2_store_flow(base: &str, dir: &std::path::Path) { + let mock = start_mock_python(); + let (server, client, _r, _s) = build_econ_server(base, dir, &mock.url); + let calls0 = mock.calls.load(std::sync::atomic::Ordering::SeqCst); + + // GET purchasegroup via v2 → Rust catalogue (non-empty). + let pg = server.handle_with_ip( + "GET", + "/ut/v2/game/fifa17/store/purchasegroup", + &[], + b"", + None, + ); + assert_eq!(pg.status, 200, "v2 purchasegroup handled by Rust"); + let pgv: Value = serde_json::from_slice(&pg.body).unwrap(); + assert!( + pgv.get("purchase") + .and_then(|p| p.as_array()) + .is_some_and(|a| !a.is_empty()), + "v2 purchasegroup returns a Rust catalogue: {pgv}" + ); + + // Same pack (id 1) via v1 then v2 → IDENTICAL debit + item count (Part 6). + let bal0 = client.balance().unwrap(); + let v1 = server.handle_with_ip( + "PUT", + "/ut/game/fifa17/store/transaction", + &[], + br#"{"packId":1}"#, + None, + ); + assert_eq!(v1.status, 200); + let bal1 = client.balance().unwrap(); + let v1v: Value = serde_json::from_slice(&v1.body).unwrap(); + let v1_items = v1v["createPackResponse"]["itemList"] + .as_array() + .map_or(0, |a| a.len()); + let v1_debit = bal0 - bal1; + + let v2 = server.handle_with_ip( + "PUT", + "/ut/v2/game/fifa17/store/transaction/0", + &[], + br#"{"packId":1}"#, + None, + ); + assert_eq!(v2.status, 200); + let bal2 = client.balance().unwrap(); + let v2v: Value = serde_json::from_slice(&v2.body).unwrap(); + let v2_items = v2v["createPackResponse"]["itemList"] + .as_array() + .map_or(0, |a| a.len()); + let v2_debit = bal1 - bal2; + + assert!(v1_items > 0, "v1 BUY minted items"); + assert_eq!(v1_items, v2_items, "v1/v2 BUY yield identical item counts"); + assert_eq!( + v1_debit, v2_debit, + "v1/v2 BUY debit identically ({v1_debit} vs {v2_debit})" + ); + + // GET purchased via v2 → Rust reveal shape; the just-bought items are in the pile. + let reveal = server.handle_with_ip("GET", "/ut/v2/game/fifa17/purchased", &[], b"", None); + assert_eq!(reveal.status, 200, "v2 GET /purchased handled by Rust"); + let rv: Value = serde_json::from_slice(&reveal.body).unwrap(); + assert!( + rv.get("itemData").and_then(|d| d.as_array()).is_some(), + "v2 reveal is a Rust itemData array: {rv}" + ); + + // NEVER any Python proxy for the whole v2 Store flow. + assert_eq!( + mock.calls.load(std::sync::atomic::Ordering::SeqCst), + calls0, + "v2 Store flow never reached the Python proxy" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn retail_v2_store_flow_matches_v1_through_dispatch() { + let dir = std::env::temp_dir().join(format!( + "openfut-econ-v2-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let db_url = format!("sqlite://{}/econ.db", dir.display()); + let (h, base) = start_core_seeded(&db_url, true).await; + let (b, d) = (base.clone(), dir.clone()); + tokio::task::spawn_blocking(move || { + std::thread::spawn(move || v2_store_flow(&b, &d)) + .join() + .expect("v2 store flow thread") + }) + .await + .expect("v2 store phase"); + h.abort(); + std::fs::remove_dir_all(&dir).ok(); +}