test(fifa17): real host<->Core economy integration harness

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.
This commit is contained in:
OpenFUT Agent
2026-08-13 19:50:42 +00:00
parent 96e80ab293
commit 8d752cb0e4
3 changed files with 197 additions and 1 deletions
Generated
+3 -1
View File
@@ -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]]
+5
View File
@@ -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"
@@ -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<u64> {
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();
}