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(),
|
||||
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/<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