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:
Generated
+3
-1
@@ -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]]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user