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.
This commit is contained in:
Generated
+3
@@ -3219,10 +3219,13 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"openfut-adapter-fifa17",
|
||||
"openfut-core",
|
||||
"openfut-identity",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 <req.json>`); 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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> = ["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<String> = 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;
|
||||
}
|
||||
Reference in New Issue
Block a user