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:
OpenFUT Agent
2026-08-13 23:53:44 +00:00
parent df6994c957
commit b8beeba98d
@@ -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();
}