From 8d752cb0e4f6f4a88b8fd31e84a60221bac076dc Mon Sep 17 00:00:00 2001 From: OpenFUT Agent Date: Thu, 13 Aug 2026 19:50:42 +0000 Subject: [PATCH] test(fifa17): real host<->Core economy integration harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spawn Core (axum) on an ephemeral loopback port backed by a disposable temp-file SQLite, seed a fifa17 profile via the real Core HTTP API, then drive the HOST's REAL transport (HttpCoreClient: CoreEconomy) + handlers against it — no fakes: credits reads Core balance; match-reward writer credits via Core grant_reward; purchasegroup full-gen renders the owned pack from a Core entitlement (no sentinel); userMassInfo overlay derives coins from the same Core state (credits==massinfo==Core invariant). Restart phase reboots Core from the same on-disk DB and proves coins + entitlements persist. Temp dir + 127.0.0.1:0 only; no prod DB/ports/containers/.105. dev-deps: openfut-core, tokio, axum. --- Cargo.lock | 4 +- openfut-utas-host/Cargo.toml | 5 + .../tests/economy_integration.rs | 189 ++++++++++++++++++ 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 openfut-utas-host/tests/economy_integration.rs diff --git a/Cargo.lock b/Cargo.lock index 2173051..52db839 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3188,7 +3188,6 @@ dependencies = [ name = "openfut-hook" version = "0.1.0" dependencies = [ - "openfut-common", "windows-sys 0.59.0", ] @@ -3280,12 +3279,15 @@ dependencies = [ name = "openfut-utas-host" version = "0.1.0" dependencies = [ + "axum", "openfut-adapter-fifa17", + "openfut-core", "openfut-http", "openfut-identity", "parking_lot", "reqwest", "serde_json", + "tokio", ] [[package]] diff --git a/openfut-utas-host/Cargo.toml b/openfut-utas-host/Cargo.toml index 11216ff..6e0a43d 100644 --- a/openfut-utas-host/Cargo.toml +++ b/openfut-utas-host/Cargo.toml @@ -17,3 +17,8 @@ reqwest = { version = "0.11", default-features = false, features = ["blocking", [dev-dependencies] parking_lot = "0.12" +# Real host<->Core integration harness: spawn Core (axum) against a disposable +# temp-file SQLite and drive the real CoreEconomy transport (no fakes). +openfut-core = { path = "../openfut-core" } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] } +axum = "0.7" diff --git a/openfut-utas-host/tests/economy_integration.rs b/openfut-utas-host/tests/economy_integration.rs new file mode 100644 index 0000000..61fc945 --- /dev/null +++ b/openfut-utas-host/tests/economy_integration.rs @@ -0,0 +1,189 @@ +//! Real host↔Core economy integration harness. +//! +//! Spawns OpenFUT Core (axum) on an ephemeral loopback port backed by a +//! disposable temp-file SQLite, seeds a `fifa17`-scoped profile via the real +//! Core HTTP API, then drives the HOST's REAL economy transport +//! (`HttpCoreClient` implementing `CoreEconomy`) + handlers against it — no +//! fakes, no in-memory doubles. It proves the credits / purchasegroup / +//! userMassInfo / match-reward cluster end-to-end and that state survives a +//! Core restart from the same on-disk database. +//! +//! Safety: uses only a temp directory + `127.0.0.1:0` ephemeral ports. Never +//! touches the production Core DB, production ports/containers, or `.105`. + +use openfut_adapter_fifa17::fut::store_session::StoreMode; +use openfut_utas_host::{ + handle_credits, handle_match_end, handle_purchasegroup, overlay_massinfo_economy, CoreEconomy, + HttpCoreClient, +}; +use serde_json::{json, Value}; + +/// Boot a Core instance against `db_url`, serving on an ephemeral port. Returns +/// the serve task handle and its base URL. +async fn start_core(db_url: &str) -> (tokio::task::JoinHandle<()>, String) { + let pool = openfut_core::db::init_pool(db_url, 5) + .await + .expect("core pool"); + openfut_core::db::run_migrations(&pool) + .await + .expect("core migrations"); + // `data` lives in the sibling core crate; tests run with the host crate as cwd. + let app = openfut_core::build_app(pool, "../openfut-core/data") + .await + .expect("core build_app"); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral"); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + (handle, format!("http://{addr}")) +} + +fn wait_ready(base: &str) { + let http = reqwest::blocking::Client::new(); + for _ in 0..100 { + if let Ok(r) = http.get(format!("{base}/health")).send() { + if r.status().is_success() { + return; + } + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + panic!("core did not become ready at {base}"); +} + +fn post(http: &reqwest::blocking::Client, base: &str, path: &str, body: Value) -> Value { + let resp = http + .post(format!("{base}{path}")) + .header("X-OpenFUT-Game", "fifa17") + .json(&body) + .send() + .unwrap_or_else(|e| panic!("POST {path}: {e}")); + let status = resp.status(); + let v: Value = resp.json().unwrap_or(Value::Null); + assert!(status.is_success(), "POST {path} -> {status}: {v}"); + v +} + +fn pack_ids(pg: &Value) -> Vec { + pg["purchase"] + .as_array() + .unwrap() + .iter() + .map(|p| p["id"].as_u64().unwrap()) + .collect() +} + +/// Seed via the real Core HTTP API, then exercise the host handlers + transport. +/// Returns nothing; panics on any mismatch. +fn seed_and_exercise(base: &str) { + wait_ready(base); + let http = reqwest::blocking::Client::new(); + + // Seed a fifa17 profile + club (auth grants 5000 coins + a starter pack). + post(&http, base, "/auth/local", json!({ "username": "CAGE" })); + + // The host's REAL transport to Core (no fake). + let client = HttpCoreClient::new(base, "fifa17"); + + // credits reads the authoritative Core balance. + let credits: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap(); + assert_eq!(credits["currencies"][0]["funds"], 5000, "seeded balance"); + + // Match-reward WRITER: win credits +400 via Core grant_reward, end to end. + let m = handle_match_end(&client, br#"{"endReason":"WIN"}"#); + assert_eq!(m.status, 200); + let mb: Value = serde_json::from_slice(&m.body).unwrap(); + assert_eq!(mb["allCoins"], 5400, "match reward credited in Core"); + assert_eq!(client.balance().unwrap(), 5400); + + // Buy a numeric entitlement "70" through the Core economy API (debit 600). + post( + &http, + base, + "/economy/purchase-entitlement", + json!({ "cost": 600, "definition_id": "70" }), + ); + assert_eq!(client.balance().unwrap(), 4800, "debit applied atomically"); + + // credits reflects the debit through the same Core state. + let credits2: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap(); + assert_eq!(credits2["currencies"][0]["funds"], 4800); + + // purchasegroup full-gen shows the owned pack 70 and NO sentinel. + let pg: Value = + serde_json::from_slice(&handle_purchasegroup(&client, StoreMode::Sentinel).body).unwrap(); + let ids = pack_ids(&pg); + assert!( + ids.contains(&70), + "owned pack 70 rendered from Core entitlement" + ); + assert!(!ids.contains(&65534), "no sentinel while a pack is owned"); + + // userMassInfo overlay derives coins from the SAME Core state as credits. + let mut mass = json!({ + "userInfo": { "currencies": [ {"name":"coins","funds":0,"finalFunds":0} ] } + }); + overlay_massinfo_economy( + &mut mass, + client.balance().unwrap(), + client.entitlements().unwrap().len(), + ); + assert_eq!(mass["userInfo"]["currencies"][0]["funds"], 4800); + // Invariant: credits coins == userMassInfo coins == Core balance. + assert_eq!( + credits2["currencies"][0]["funds"], + mass["userInfo"]["currencies"][0]["funds"] + ); +} + +/// After a Core restart from the same DB file, all economy state persists. +fn verify_after_restart(base: &str) { + wait_ready(base); + let client = HttpCoreClient::new(base, "fifa17"); + assert_eq!( + client.balance().unwrap(), + 4800, + "coins persisted across restart" + ); + let credits: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap(); + assert_eq!(credits["currencies"][0]["funds"], 4800); + let pg: Value = + serde_json::from_slice(&handle_purchasegroup(&client, StoreMode::Sentinel).body).unwrap(); + assert!( + pack_ids(&pg).contains(&70), + "entitlement persisted across restart" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn economy_end_to_end_and_restart_persistence() { + let dir = std::env::temp_dir().join(format!( + "openfut-econ-it-{}-{}", + 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()); + + // --- Core instance #1: seed + exercise the full cluster --- + let (h1, base1) = start_core(&db_url).await; + let b1 = base1.clone(); + let r = tokio::task::spawn_blocking(move || seed_and_exercise(&b1)).await; + h1.abort(); + r.expect("exercise phase"); + + // --- Core instance #2: same on-disk DB, prove persistence --- + let (h2, base2) = start_core(&db_url).await; + let b2 = base2.clone(); + let r2 = tokio::task::spawn_blocking(move || verify_after_restart(&b2)).await; + h2.abort(); + r2.expect("restart phase"); + + std::fs::remove_dir_all(&dir).ok(); +}