//! 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) { // Serialize Core access with a single pooled connection so this E2E+restart // harness is deterministic. NOTE: even with the WAL-establish-once + // busy_timeout fixes (core 75b1830), a multi-connection pool on a brand-new // DB still intermittently surfaces a write "database error" under warm-up — // a deeper sqlx/SQLite pool concurrency issue that is a documented remaining // blocker for the Part-T concurrency proof, not exercised by this harness. let pool = openfut_core::db::init_pool(db_url, 1) .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(); // Generous ceiling (~30s): returns on first success, so it only ever waits // this long if Core genuinely never comes up. Under heavy parallel test-binary // load Core's content load (CardDb::load) can take several seconds to be ready. for _ in 0..1500 { 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(); }