From 3ba24a0fafa88b0e48179e808e5c00acf9c3064b Mon Sep 17 00:00:00 2001 From: OpenFUT Agent Date: Thu, 13 Aug 2026 22:31:01 +0000 Subject: [PATCH] test(import): verify durable FIFA17 economy migration openfut-import-fifa17/tests/durable_import.rs drives the REAL import pipeline (analyze -> Report dry-run; emit_content; plan_apply -> GenericImportRequest + deterministic identity mappings + watermark; the staging/preflight/seed/ post-validate gates over a real JsonIdentityStore; openfut_core::services:: import::apply_profile_import in one Core SQLite tx) against a disposable temp-file Core DB, from a small sanitized in-test fixture (750000 coins, 3 resolvable base players, unopenedPackIds [70,70,101], one squad). Five ordered steps on one durable target, all green: A dry-run: report exposes persona/coins/inventory/unopened/fingerprint; ZERO DB mutation (all Core tables COUNT=0, identity store empty). B apply: coins=750000 exact, owned=3, packs=3 (opened=0), squad_players=2, deterministic owned ids, import_fingerprint recorded, identities reverse- resolve both ways, watermark=100000600. C restart: close+reopen the SAME sqlite file -> identical state. D re-apply same source -> AlreadyImported (fingerprint), no doubling. E conflict (coins 750000->750001 flips the fingerprint for the same game) -> apply fails closed ('different source'); DB unchanged. Fingerprint = FNV-1a-64 hex of the source snapshot, carried into ProfileImportRequest.source_fingerprint = Core profiles.import_fingerprint, the per-game rerun-identity key. dev-deps added to openfut-import-fifa17 (openfut-core path, tokio, sqlx). No production/live data. --- Cargo.lock | 3 + openfut-import-fifa17/Cargo.toml | 3 + openfut-import-fifa17/tests/durable_import.rs | 387 ++++++++++++++++++ 3 files changed, 393 insertions(+) create mode 100644 openfut-import-fifa17/tests/durable_import.rs diff --git a/Cargo.lock b/Cargo.lock index 396d1cd..9c6e35c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3219,10 +3219,13 @@ version = "0.1.0" dependencies = [ "anyhow", "openfut-adapter-fifa17", + "openfut-core", "openfut-identity", "serde", "serde_json", + "sqlx", "tempfile", + "tokio", "uuid", ] diff --git a/openfut-import-fifa17/Cargo.toml b/openfut-import-fifa17/Cargo.toml index 22f5316..8ffce19 100644 --- a/openfut-import-fifa17/Cargo.toml +++ b/openfut-import-fifa17/Cargo.toml @@ -22,3 +22,6 @@ openfut-identity = { path = "../openfut-identity" } [dev-dependencies] tempfile = "3" +openfut-core = { path = "../openfut-core" } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls"] } diff --git a/openfut-import-fifa17/tests/durable_import.rs b/openfut-import-fifa17/tests/durable_import.rs new file mode 100644 index 0000000..3287343 --- /dev/null +++ b/openfut-import-fifa17/tests/durable_import.rs @@ -0,0 +1,387 @@ +//! Durable end-to-end proof of the FIFA17 real-profile import against a +//! DISPOSABLE, temp-FILE Core SQLite DB (not `:memory:`, so a restart is real). +//! +//! This drives the REAL components exactly as the production dispatch does: +//! * `openfut_import_fifa17::analyze` → the read-only [`Report`] (dry-run); +//! * `openfut_import_fifa17::emit_content` → the production content pack; +//! * `openfut_import_fifa17::apply::plan_apply` → the generic Core request + +//! the deterministic identity mappings + the allocation watermark; +//! * the real orchestration GATES (`gate_staging`, `local_core_preflight`, +//! `identity_dry_preflight`, `seed_identity`, `post_validate_identity`) and a +//! real `openfut_identity::JsonIdentityStore`; +//! * `openfut_core::services::import::apply_profile_import` inside a single +//! Core SQLite transaction on a temp-file pool. +//! +//! The ONLY place this test diverges from `apply::apply` is the Core boundary: +//! `apply::apply` writes the request JSON and spawns the `openfut-core` binary +//! (`import `); here we serialize the SAME `GenericImportRequest` to +//! JSON and deserialize it into Core's `ProfileImportRequest` (the two types +//! share their field names by contract), then call `apply_profile_import` +//! in-process against the temp-file pool. That collapses the process boundary +//! without reimplementing any importer or Core logic, and lets the test drop & +//! reopen the DB file to prove durability. +//! +//! Fingerprint mechanism under test: `openfut_import_fifa17::fingerprint` (a +//! dependency-free FNV-1a-64 hex digest of the source snapshot bytes) is carried +//! verbatim into `ProfileImportRequest::source_fingerprint`, which Core persists +//! as `profiles.import_fingerprint` and uses as the per-game rerun-identity key: +//! identical fingerprint on an already-imported game → idempotent no-op; a +//! DIFFERENT fingerprint for the same game → hard failure (never a silent merge). + +use std::collections::BTreeSet; + +use openfut_adapter_fifa17::fut::catalog::Fifa17WireItemIdPolicy; +use openfut_core::db::{init_pool, run_migrations, Pool}; +use openfut_core::services::card_db::CardDb; +use openfut_core::services::import::{apply_profile_import, ImportOutcome, ProfileImportRequest}; +use openfut_identity::{ExternalIdentityStore, JsonIdentityStore}; + +use openfut_import_fifa17::apply::{ + content_card_ids, gate_staging, identity_dry_preflight, local_core_preflight, owned_item_id, + plan_apply, post_validate_identity, seed_identity, ApplyPlan, +}; +use openfut_import_fifa17::model::Profile; +use openfut_import_fifa17::{analyze, emit_content, fingerprint, Entities, Report, Roster}; + +const PERSONA: i64 = 33068179; + +// ---- sanitized in-test fixture (NOT production/live data) ---- + +fn roster() -> Roster { + Roster::from_json_str( + r#"[ + {"id":20801,"first":"Cristiano","last":"Ronaldo","common":""}, + {"id":176580,"first":"Luis","last":"Suárez","common":""}, + {"id":158023,"first":"Lionel","last":"Messi","common":""} + ]"#, + ) + .unwrap() +} + +fn entities() -> Entities { + use std::collections::BTreeMap; + Entities::from_maps( + BTreeMap::from([(53, "LaLiga".to_string())]), + BTreeMap::from([(38, "Portugal".to_string())]), + BTreeMap::from([(243, "Real Madrid".to_string())]), + ) +} + +/// A resolvable base player card (nation 38 / team 243 / league 53 all resolve). +fn player(id: i64, resource: i64, asset: i64, rating: i64) -> String { + format!( + r#"{{"id":{id},"resourceId":{resource},"assetId":{asset},"itemType":"player", + "rareflag":1,"rating":{rating},"preferredPosition":"ST","nation":38, + "teamid":243,"leagueId":53,"attributeList":[ + {{"index":0,"value":90}},{{"index":1,"value":91}},{{"index":2,"value":82}}, + {{"index":3,"value":88}},{{"index":4,"value":30}},{{"index":5,"value":78}}]}}"# + ) +} + +const SQUAD_F433: &str = r#"[{"formation":"f433","squadName":"OpenFUT","captain":100000001, + "squadType":"REGULAR_SQUAD","custom":"[0,0,0]", + "players":[{"index":0,"itemData":{"id":100000001},"kitNumber":7}, + {"index":1,"itemData":{"id":100000002},"kitNumber":9}], + "kicktakers":[{"index":0,"id":100000001}],"manager":[{"id":100000427}]}]"#; + +/// The three owned player instances (wire ids 100000001..100000003). +fn items() -> Vec { + vec![ + player(100000001, 20801, 20801, 94), // fifa17_20801 (Ronaldo) + player(100000002, 176580, 176580, 86), // fifa17_176580 (Suárez) + player(100000003, 158023, 158023, 93), // fifa17_158023 (Messi) + ] +} + +/// Build the source profile JSON with the given coins (varying coins is the +/// "meaningful change" that flips the fingerprint for STEP E). +fn profile_json(coins: i64) -> String { + format!( + r#"{{"personaId":{PERSONA},"personaName":"CAGE","clubName":"OpenFUT","clubAbbr":"OFC", + "coins":{coins},"nextItemId":100000600, + "items":[{}],"squads":{SQUAD_F433},"unopenedPackIds":[70,70,101]}}"#, + items().join(",") + ) +} + +/// Analyze one snapshot into (report, raw Value, fingerprint), the read-only +/// dry-run the production dispatch performs before any write. +fn dry_run(coins: i64) -> (Report, serde_json::Value, String) { + let json = profile_json(coins); + let prof = Profile::from_json_str(&json).unwrap(); + let report = analyze(&prof, &roster(), &entities(), &BTreeSet::new()); + let raw: serde_json::Value = serde_json::from_str(&json).unwrap(); + let fp = fingerprint(json.as_bytes()); + (report, raw, fp) +} + +/// Serialize the importer's generic request and deserialize it into Core's +/// request — the exact JSON contract `apply::apply` hands the Core binary. +fn to_core_request(plan: &ApplyPlan) -> ProfileImportRequest { + let bytes = serde_json::to_vec(&plan.request).expect("serialize generic request"); + serde_json::from_slice(&bytes).expect("deserialize into Core ProfileImportRequest") +} + +async fn count(pool: &Pool, table: &str) -> i64 { + sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}")) + .fetch_one(pool) + .await + .unwrap() +} + +async fn coins(pool: &Pool) -> i64 { + sqlx::query_scalar("SELECT coins FROM clubs") + .fetch_one(pool) + .await + .unwrap() +} + +async fn owned_card_ids(pool: &Pool) -> BTreeSet { + sqlx::query_scalar::<_, String>("SELECT card_id FROM owned_cards") + .fetch_all(pool) + .await + .unwrap() + .into_iter() + .collect() +} + +async fn owned_item_ids(pool: &Pool) -> BTreeSet { + sqlx::query_scalar::<_, String>("SELECT id FROM owned_cards") + .fetch_all(pool) + .await + .unwrap() + .into_iter() + .collect() +} + +const CORE_TABLES: &[&str] = &[ + "profiles", + "clubs", + "owned_cards", + "packs", + "squads", + "squad_players", + "game_entity_ext", +]; + +#[tokio::test] +async fn durable_import_dryrun_apply_restart_idempotent_conflict() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("core.sqlite"); + let db_url = format!("sqlite://{}", db_path.display()); + let identity_store_path = dir.path().join("identity.json"); + + // Empty base catalog dir → CardDb has ONLY the emitted production pack. + let data_dir = dir.path().join("data"); + std::fs::create_dir_all(&data_dir).unwrap(); + + // The expected import shape, derived from the fixture. + let expected_coins = 750_000_i64; + let expected_owned = 3_usize; + let expected_packs = 3_usize; // unopenedPackIds [70,70,101] + let expected_squad_slots = 2_usize; + let expected_card_ids: BTreeSet = ["fifa17_20801", "fifa17_176580", "fifa17_158023"] + .iter() + .map(|s| s.to_string()) + .collect(); + let expected_wires = [100000001_i64, 100000002, 100000003]; + let expected_owned_ids: BTreeSet = expected_wires + .iter() + .map(|&w| owned_item_id(PERSONA, w)) + .collect(); + + let (g, k) = ( + Fifa17WireItemIdPolicy::GAME, + Fifa17WireItemIdPolicy::OWNED_ITEM_KIND, + ); + + // ============================ STEP A — DRY-RUN ============================ + let (report, raw, fp1) = dry_run(expected_coins); + + // The report exposes the migration target + inventory + entitlements + the + // provenance fingerprint, WITHOUT touching any store. + assert_eq!(report.game, "fifa17"); + assert_eq!(report.persona_id, PERSONA); + assert_eq!(report.persona_name, "CAGE"); + assert_eq!(report.coins, expected_coins); + assert_eq!(report.counts.player_cards, expected_owned); + assert!(report.counts.balances(), "item accounting must balance"); + assert_eq!(report.unopened_pack_ids, [70, 70, 101]); + assert!(!report.has_blockers(), "clean fixture has no blockers"); + assert_eq!(fp1.len(), 16, "FNV-1a-64 fingerprint is 16 hex chars"); + assert!(fp1.chars().all(|c| c.is_ascii_hexdigit())); + + // Plan the apply (still no writes) and emit the production content pack. + let plan = plan_apply(&report, &raw, &fp1).expect("plan apply"); + assert_eq!(plan.source_fingerprint, fp1); + assert_eq!(plan.request.owned.len(), expected_owned); + assert_eq!(plan.mappings.len(), expected_owned); + assert_eq!(plan.watermark, 100000600); + assert_eq!(plan.request.entitlements.len(), expected_packs); + + let emit = emit_content(&report, dir.path(), &fp1).expect("emit content"); + let pack_ids = content_card_ids(&emit.content_pack).expect("read emitted pack"); + assert_eq!(pack_ids, expected_card_ids, "emitted pack card ids"); + + // The real orchestration gates run against the plan (read-only). + assert!( + !gate_staging(&plan, false).expect("staging gate"), + "zero deferred instances → production-complete, no staging flag" + ); + local_core_preflight(&plan, &pack_ids).expect("local core preflight"); + + // Open the disposable temp-FILE Core pool + migrations, and PROVE the + // dry-run above mutated nothing: every Core table is empty. + let pool = init_pool(&db_url, 1).await.expect("init core pool"); + run_migrations(&pool).await.expect("migrations"); + let store = JsonIdentityStore::open(&identity_store_path).expect("open identity store"); + identity_dry_preflight(&store, &plan).expect("identity dry preflight"); + for t in CORE_TABLES { + assert_eq!(count(&pool, t).await, 0, "dry-run left `{t}` unmutated"); + } + // The dry identity preflight also seeded nothing. + assert_eq!( + store + .external_for(g, k, &owned_item_id(PERSONA, 100000001)) + .unwrap(), + None + ); + + // ============================ STEP B — APPLY ============================= + // Idempotently seed the identity mappings + watermark, then run the ONE + // generic Core import transaction (via the real JSON contract). + seed_identity(&store, &plan).expect("seed identity"); + let req = to_core_request(&plan); + let card_db = { + let mut db = CardDb::load(data_dir.to_str().unwrap()).expect("load empty base catalog"); + db.load_pack(&emit.content_pack) + .expect("load production pack"); + db + }; + let outcome = apply_profile_import(&pool, &card_db, &req) + .await + .expect("core import"); + assert_eq!( + outcome, + ImportOutcome::Imported { + owned: expected_owned, + squad_slots: expected_squad_slots + } + ); + post_validate_identity(&store, &plan).expect("post-validate identity"); + + // Exact durable Core state. + assert_eq!(count(&pool, "profiles").await, 1); + assert_eq!(count(&pool, "clubs").await, 1); + assert_eq!(coins(&pool).await, expected_coins, "exact coins"); + assert_eq!(count(&pool, "owned_cards").await, expected_owned as i64); + assert_eq!(count(&pool, "packs").await, expected_packs as i64); + let unopened: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM packs WHERE opened = 0") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(unopened, expected_packs as i64, "unopened entitlements"); + assert_eq!( + count(&pool, "squad_players").await, + expected_squad_slots as i64 + ); + + // Core content identities + the deterministic opaque OwnedItemIds. + assert_eq!(owned_card_ids(&pool).await, expected_card_ids); + assert_eq!( + owned_item_ids(&pool).await, + expected_owned_ids, + "Core owned_cards.id == deterministic owned_item_id(persona, wire)" + ); + // The stored provenance/rerun key IS the source fingerprint. + let stored_fp: String = sqlx::query_scalar("SELECT import_fingerprint FROM profiles") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stored_fp, fp1); + + // Identity store: every preserved wire id resolves both directions, and the + // watermark is the source allocation floor. + for &w in &expected_wires { + let core_id = owned_item_id(PERSONA, w); + assert_eq!(store.external_for(g, k, &core_id).unwrap(), Some(w)); + assert_eq!(store.core_for(g, k, w).unwrap(), Some(core_id)); + } + assert_eq!(store.watermark_for(g, k), Some(100000600)); + + // ============================ STEP C — RESTART =========================== + // Drop the pool, reopen the SAME db file, and re-read identical state. + pool.close().await; + drop(pool); + let pool = init_pool(&db_url, 1).await.expect("reopen core pool"); + run_migrations(&pool).await.expect("migrations idempotent"); + assert_eq!(count(&pool, "profiles").await, 1); + assert_eq!(coins(&pool).await, expected_coins, "coins survive restart"); + assert_eq!(count(&pool, "owned_cards").await, expected_owned as i64); + assert_eq!(count(&pool, "packs").await, expected_packs as i64); + assert_eq!(owned_card_ids(&pool).await, expected_card_ids); + assert_eq!(owned_item_ids(&pool).await, expected_owned_ids); + + // Identity store also durable across a reopen. + let store = JsonIdentityStore::open(&identity_store_path).expect("reopen identity store"); + assert_eq!( + store + .external_for(g, k, &owned_item_id(PERSONA, 100000001)) + .unwrap(), + Some(100000001) + ); + + // ======================= STEP D — IDEMPOTENT RE-APPLY ==================== + // Same source (same fingerprint) → recognized as already imported, no dupes. + identity_dry_preflight(&store, &plan).expect("re-run identity dry preflight"); + seed_identity(&store, &plan).expect("re-run identity seed is idempotent"); + let req_again = to_core_request(&plan); + let outcome = apply_profile_import(&pool, &card_db, &req_again) + .await + .expect("re-apply"); + assert_eq!(outcome, ImportOutcome::AlreadyImported); + assert_eq!(count(&pool, "profiles").await, 1, "no duplicate profile"); + assert_eq!(coins(&pool).await, expected_coins, "coins NOT doubled"); + assert_eq!( + count(&pool, "owned_cards").await, + expected_owned as i64, + "no duplicate owned cards" + ); + assert_eq!( + count(&pool, "packs").await, + expected_packs as i64, + "no duplicate entitlements" + ); + + // ============================ STEP E — CONFLICT ========================== + // A MEANINGFUL change (different coins → different snapshot fingerprint) for + // the SAME target (game fifa17) must FAIL CLOSED — never a silent merge. + let (report2, raw2, fp2) = dry_run(expected_coins + 1); + assert_ne!(fp2, fp1, "meaningful change flips the fingerprint"); + let plan2 = plan_apply(&report2, &raw2, &fp2).expect("plan conflicting apply"); + let req_conflict = to_core_request(&plan2); + let err = apply_profile_import(&pool, &card_db, &req_conflict) + .await + .expect_err("different fingerprint on imported game must fail closed"); + let msg = format!("{err:#}"); + assert!( + msg.contains("different source"), + "expected explicit conflict, got: {msg}" + ); + + // The failed conflicting import changed nothing. + assert_eq!(count(&pool, "profiles").await, 1); + assert_eq!( + coins(&pool).await, + expected_coins, + "conflict left coins intact" + ); + assert_eq!(count(&pool, "owned_cards").await, expected_owned as i64); + let stored_fp: String = sqlx::query_scalar("SELECT import_fingerprint FROM profiles") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stored_fp, fp1, "original fingerprint unchanged"); + + pool.close().await; +}