test(fifa17): cover retail v2 Store route shapes
Through real handle_with_ip dispatch against live Core: - pure_economy_routes gains the retail v2 Store family (transaction/0, purchasegroup, purchased GET/POST) so NEVER-BOTH + no-fallback assert them Rust-owned (Python proxy count 0) and fail-closed 503 on dead Core. - Part-7 repro: PUT /ut/v2/game/fifa17/store/transaction/0 returns a Rust createPackResponse + debits Core (NOT the Python TRANSACTIONCANCEL no-op). - retail_v2_store_flow_matches_v1_through_dispatch: v1 and v2 BUY of the same pack debit + mint identically; v2 purchasegroup + reveal Rust-owned.
This commit is contained in:
@@ -988,6 +988,24 @@ fn pure_economy_routes() -> Vec<(&'static str, String, Vec<u8>)> {
|
|||||||
"/ut/delete/game/fifa17/trade/900000001".into(),
|
"/ut/delete/game/fifa17/trade/900000001".into(),
|
||||||
b"".to_vec(),
|
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)"
|
"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 ──
|
// ── NEVER BOTH (Core up): pure economy routes reach Rust, never Python ──
|
||||||
let before = mock.calls.load(std::sync::atomic::Ordering::SeqCst);
|
let before = mock.calls.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
for (m, p, b) in pure_economy_routes() {
|
for (m, p, b) in pure_economy_routes() {
|
||||||
@@ -1144,3 +1197,112 @@ async fn barrier_never_both_no_fallback_and_stale_reader() {
|
|||||||
h.abort();
|
h.abort();
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
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/<sku>/…` 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();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user