test(fifa17): replay retail purchased/items BUY + route matrix via dispatch

- pure_economy_routes (NEVER-BOTH + no-fallback) gains POST/GET purchased/items,
  lowercase tradepile, tradePile/counts -> all asserted Rust-owned, proxy 0.
- retail_purchased_items_buy_debits_core_through_dispatch: the exact round-2
  live failure -> POST /purchased/items now debits Core, reveal shows minted
  items, repeat reveal idempotent, Python proxy count 0.
This commit is contained in:
OpenFUT Agent
2026-08-14 00:50:03 +00:00
parent a6416a3f1d
commit cf05ab2a9e
@@ -1006,6 +1006,24 @@ fn pure_economy_routes() -> Vec<(&'static str, String, Vec<u8>)> {
br#"{"packId":70}"#.to_vec(),
),
("GET", "/ut/v2/game/fifa17/purchased".into(), b"".to_vec()),
// ── Round-2 retail shapes: the confirmed BUY uses POST /purchased/items,
// reveal GET /purchased/items; hub tile polls lowercase tradepile + /counts. ──
(
"POST",
"/ut/game/fifa17/purchased/items".into(),
br#"{"packId":1}"#.to_vec(),
),
(
"GET",
"/ut/game/fifa17/purchased/items".into(),
b"".to_vec(),
),
("GET", "/ut/game/fifa17/tradepile".into(), b"".to_vec()),
(
"GET",
"/ut/game/fifa17/tradePile/counts".into(),
b"".to_vec(),
),
]
}
@@ -1306,3 +1324,94 @@ async fn retail_v2_store_flow_matches_v1_through_dispatch() {
h.abort();
std::fs::remove_dir_all(&dir).ok();
}
// ─────────────── Retail /purchased/items BUY sequence (round-2 S2 regression) ─
//
// The rejected candidate 47ced22 sent the confirmed retail Store BUY
// (POST /ut/game/fifa17/purchased/items) to Python and left Core coins unchanged.
// This replays the exact live sequence through real dispatch and asserts the BUY
// debits Core, the reveal shows the minted items, and Python proxy count is 0.
fn retail_purchased_items_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);
// Store screen catalogue (v1 /all) — Rust.
let pg = server.handle_with_ip(
"GET",
"/ut/game/fifa17/store/purchasegroup/all",
&[],
b"",
None,
);
assert_eq!(pg.status, 200, "purchasegroup/all Rust-owned");
// THE CONFIRMED RETAIL BUY: POST /ut/game/fifa17/purchased/items must debit Core.
let bal0 = client.balance().unwrap();
let buy = server.handle_with_ip(
"POST",
"/ut/game/fifa17/purchased/items",
&[],
br#"{"packId":1}"#,
None,
);
assert_eq!(buy.status, 200, "purchased/items BUY handled by Rust (200)");
assert!(
!buy.body.windows(10).any(|w| w == b"__python__"),
"purchased/items BUY is Rust-owned (no Python marker)"
);
let bal1 = client.balance().unwrap();
assert!(
bal1 < bal0,
"purchased/items BUY debited Core ({bal0} -> {bal1}) — the round-2 S2 was NO debit"
);
// Reveal poll: GET /ut/game/fifa17/purchased/items — Rust, shows the minted items.
let reveal = server.handle_with_ip("GET", "/ut/game/fifa17/purchased/items", &[], b"", None);
assert_eq!(reveal.status, 200, "purchased/items reveal Rust-owned");
let rv: Value = serde_json::from_slice(&reveal.body).unwrap();
let revealed = rv["itemData"].as_array().map_or(0, |a| a.len());
assert!(revealed > 0, "reveal shows the freshly-minted items: {rv}");
// Repeat reveal is idempotent (no re-grant, no extra debit).
let bal2 = client.balance().unwrap();
let _ = server.handle_with_ip("GET", "/ut/game/fifa17/purchased/items", &[], b"", None);
assert_eq!(
client.balance().unwrap(),
bal2,
"repeat reveal does not mutate coins"
);
// NEVER any Python proxy across the whole /purchased/items sequence.
assert_eq!(
mock.calls.load(std::sync::atomic::Ordering::SeqCst),
calls0,
"retail /purchased/items sequence never reached the Python proxy"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn retail_purchased_items_buy_debits_core_through_dispatch() {
let dir = std::env::temp_dir().join(format!(
"openfut-econ-pi-{}-{}",
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 || retail_purchased_items_flow(&b, &d))
.join()
.expect("purchased/items flow thread")
})
.await
.expect("purchased/items phase");
h.abort();
std::fs::remove_dir_all(&dir).ok();
}