4f78b9a875
WAL-establish-once + busy_timeout (core 75b1830) reduced but did not eliminate a brand-new-DB multi-connection warm-up 'database error'; the E2E+restart harness stays on a single serialized connection for determinism, and the residual multi-connection concurrency issue is documented as the remaining Part-T blocker.
199 lines
7.7 KiB
Rust
199 lines
7.7 KiB
Rust
//! 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<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();
|
|
}
|