feat(import-fifa17): --apply — recoverable two-store real-profile import
FIFA17-specific orchestration that installs the real profile across BOTH durable stores (openfut-identity + Core SQLite) recoverably and idempotently, handing Core only a GENERIC request (all FIFA17 semantics stay in this adapter layer). apply module: - owned_item_id(persona, wire) = deterministic UUIDv5 from a private namespace; identical in BOTH stores (identity core_id AND Core owned_cards.id), so the running host's wire->owned reverse lookup resolves exactly what Core stored. - plan_apply(report, raw_profile, fp): pure translation to a GenericImportRequest (card_id = fifa17_<resourceId>) + the preserved (owned_item_id <-> source wire) mappings + watermark. Canonical squad + Fifa17SquadExtensionV1 are built by the SAME adapter code (parse_squad_put + build_squad_write) the retail-validated live squad path uses. Refuses if the report has blockers. - Two-store protocol (apply): staging gate -> local Core-preflight mirror -> identity dry-preflight -> idempotent seed (insert_existing_mapping per instance + set_watermark) -> ONE generic Core import transaction (spawned binary) -> cross-store post-validation -> completion record. A crash after identity seeding re-converges on re-run (idempotent mappings + Core already_imported): no cleanup, no reminting. - gate_staging: deferred players are ABSENT from an import; allowed only for a staged run behind --allow-deferred-players-for-staging (never a silent default; prints an INCOMPLETE banner). Production requires zero deferred instances. CLI: --apply (with --emit-content, --core-bin, --core-db, --core-data, --identity-store, --allow-deferred-players-for-staging). Depends on openfut-adapter-fifa17 + openfut-identity + uuid(v5). Proven end-to-end on the real 33068179/CAGE profile (staged): first apply imports 1949 supported instances (293 base + versioned), 11/11 f433 squad + opaque extension, coins 28,112,944, fingerprint 8dc5582d2414af28; re-run is an idempotent no-op (already_imported, DB unchanged); staging-not-default refuses before any write; every OwnedItemId is an opaque UUID; identity wire-id set == source supported set exactly (0 minted, 0 dropped); 13 deferred instances leak 0. 10 new apply tests (determinism, request/mapping/squad translation, blocker refusal, staging gate both ways, local preflight, identity seed/dry/postvalidate/ idempotency, conflict detection, graceful spawn failure). clippy -D warnings clean; crate suite 23 tests green.
This commit is contained in:
@@ -318,3 +318,243 @@ fn emit_refuses_when_blocked() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(emit_content(&rep, dir.path(), "x").is_err());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ apply
|
||||
|
||||
use crate::apply::{
|
||||
apply as apply_import, content_card_ids, gate_staging, identity_dry_preflight,
|
||||
local_core_preflight, owned_item_id, plan_apply, post_validate_identity, seed_identity,
|
||||
};
|
||||
use openfut_identity::{ExternalIdentityStore, JsonIdentityStore};
|
||||
|
||||
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}]}]"#;
|
||||
|
||||
/// Build a report + raw profile Value from the same JSON (typed profile for
|
||||
/// analysis, raw Value for the squad wire).
|
||||
fn report_and_raw(
|
||||
items: &[String],
|
||||
squads: &str,
|
||||
next_item_id: i64,
|
||||
) -> (Report, serde_json::Value) {
|
||||
let json = format!(
|
||||
r#"{{"personaId":33068179,"personaName":"CAGE","clubName":"OpenFUT","clubAbbr":"OFC",
|
||||
"coins":1000,"nextItemId":{next_item_id},"items":[{}],"squads":{}}}"#,
|
||||
items.join(","),
|
||||
squads
|
||||
);
|
||||
let prof = Profile::from_json_str(&json).unwrap();
|
||||
let report = analyze(&prof, &roster(), &entities(), &none());
|
||||
let raw: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
(report, raw)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_item_id_is_deterministic_and_distinct() {
|
||||
let a = owned_item_id(33068179, 100000001);
|
||||
assert_eq!(a, owned_item_id(33068179, 100000001), "stable across calls");
|
||||
assert_ne!(
|
||||
a,
|
||||
owned_item_id(33068179, 100000002),
|
||||
"distinct per wire id"
|
||||
);
|
||||
assert_ne!(
|
||||
a,
|
||||
owned_item_id(90909090, 100000001),
|
||||
"distinct per account"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_apply_builds_generic_request_mappings_and_squad() {
|
||||
let items = vec![
|
||||
player(100000001, 20801, 20801, 94),
|
||||
player(100000002, VER5_176580, 176580, 92),
|
||||
];
|
||||
let (report, raw) = report_and_raw(&items, SQUAD_F433, 100000500);
|
||||
let plan = plan_apply(&report, &raw, "fp-test").unwrap();
|
||||
|
||||
assert_eq!(plan.request.owned.len(), 2);
|
||||
assert_eq!(plan.mappings.len(), 2);
|
||||
assert_eq!(plan.watermark, 100000500);
|
||||
assert_eq!(plan.supported_instances, 2);
|
||||
assert_eq!(plan.deferred_instances, 0);
|
||||
assert_eq!(plan.source_fingerprint, "fp-test");
|
||||
// card ids are fifa17_<resourceId>, base and versioned distinct.
|
||||
let mut cards: Vec<&str> = plan
|
||||
.request
|
||||
.owned
|
||||
.iter()
|
||||
.map(|o| o.card_id.as_str())
|
||||
.collect();
|
||||
cards.sort_unstable();
|
||||
assert_eq!(
|
||||
cards,
|
||||
vec!["fifa17_20801", &format!("fifa17_{VER5_176580}")[..]]
|
||||
);
|
||||
// mapping core_id == the owned_item_id for that wire (both stores agree).
|
||||
for m in &plan.mappings {
|
||||
assert_eq!(m.core_id, owned_item_id(33068179, m.wire_id));
|
||||
}
|
||||
// squad + opaque extension.
|
||||
let sq = plan.request.squad.as_ref().expect("squad present");
|
||||
assert_eq!(sq.formation, "f433");
|
||||
assert_eq!(sq.slots.len(), 2);
|
||||
assert_eq!(sq.extension.namespace, "fifa17.squad");
|
||||
assert_eq!(sq.extension.schema_version, 1);
|
||||
assert!(
|
||||
sq.extension.payload.contains("[0,0,0]"),
|
||||
"custom carried verbatim"
|
||||
);
|
||||
// captain flag follows wire 100000001, keyed by its OwnedItemId.
|
||||
let cap_owned = owned_item_id(33068179, 100000001);
|
||||
assert!(sq
|
||||
.slots
|
||||
.iter()
|
||||
.any(|s| s.owned_item_id == cap_owned && s.is_captain));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_apply_refuses_when_blockers_present() {
|
||||
// unapproved same-resourceId conflict -> blocker.
|
||||
let items = vec![
|
||||
player(100000003, 20801, 20801, 94),
|
||||
player(100000004, 20801, 20801, 90),
|
||||
];
|
||||
let (report, raw) = report_and_raw(&items, "[]", 100000500);
|
||||
assert!(plan_apply(&report, &raw, "fp").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_staging_requires_explicit_optin_for_deferred() {
|
||||
// one supported + one NoName (unresolvable asset) deferred instance.
|
||||
let items = vec![
|
||||
player(100000001, 20801, 20801, 94),
|
||||
format!(
|
||||
r#"{{"id":100000009,"resourceId":999999,"assetId":999999,"itemType":"player","rating":80,
|
||||
"preferredPosition":"ST","nation":38,"teamid":243,"leagueId":53,"attributeList":{}}}"#,
|
||||
attrs()
|
||||
),
|
||||
];
|
||||
let (report, raw) = report_and_raw(&items, "[]", 100000500);
|
||||
let plan = plan_apply(&report, &raw, "fp").unwrap();
|
||||
assert_eq!(plan.deferred_instances, 1);
|
||||
assert!(gate_staging(&plan, false).is_err(), "deferred needs opt-in");
|
||||
assert!(gate_staging(&plan, true).unwrap(), "staging with opt-in");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_staging_production_complete_needs_no_flag() {
|
||||
let items = vec![player(100000001, 20801, 20801, 94)];
|
||||
let (report, raw) = report_and_raw(&items, "[]", 100000500);
|
||||
let plan = plan_apply(&report, &raw, "fp").unwrap();
|
||||
assert_eq!(plan.deferred_instances, 0);
|
||||
assert!(!gate_staging(&plan, false).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_preflight_rejects_owned_card_absent_from_content() {
|
||||
let items = vec![player(100000001, 20801, 20801, 94)];
|
||||
let (report, raw) = report_and_raw(&items, "[]", 100000500);
|
||||
let plan = plan_apply(&report, &raw, "fp").unwrap();
|
||||
let mut present = BTreeSet::new();
|
||||
present.insert("fifa17_20801".to_string());
|
||||
assert!(local_core_preflight(&plan, &present).is_ok());
|
||||
assert!(local_core_preflight(&plan, &BTreeSet::new()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_seed_dry_postvalidate_and_idempotent_rerun() {
|
||||
let items = vec![
|
||||
player(100000001, 20801, 20801, 94),
|
||||
player(100000002, VER5_176580, 176580, 92),
|
||||
];
|
||||
let (report, raw) = report_and_raw(&items, SQUAD_F433, 100004617);
|
||||
let plan = plan_apply(&report, &raw, "fp").unwrap();
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = JsonIdentityStore::open(dir.path().join("ids.json")).unwrap();
|
||||
identity_dry_preflight(&store, &plan).unwrap();
|
||||
seed_identity(&store, &plan).unwrap();
|
||||
|
||||
// every preserved wire id resolves exactly; watermark set.
|
||||
for m in &plan.mappings {
|
||||
assert_eq!(
|
||||
store
|
||||
.external_for("fifa17", "owned-item", &m.core_id)
|
||||
.unwrap(),
|
||||
Some(m.wire_id)
|
||||
);
|
||||
}
|
||||
assert_eq!(store.watermark_for("fifa17", "owned-item"), Some(100004617));
|
||||
post_validate_identity(&store, &plan).unwrap();
|
||||
|
||||
// re-seeding the SAME plan is an idempotent no-op (crash recovery).
|
||||
seed_identity(&store, &plan).unwrap();
|
||||
post_validate_identity(&store, &plan).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_dry_preflight_detects_conflicting_existing_mapping() {
|
||||
let items = vec![player(100000001, 20801, 20801, 94)];
|
||||
let (report, raw) = report_and_raw(&items, "[]", 100000500);
|
||||
let plan = plan_apply(&report, &raw, "fp").unwrap();
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = JsonIdentityStore::open(dir.path().join("ids.json")).unwrap();
|
||||
// wire 100000001 already owned by a DIFFERENT core id.
|
||||
store
|
||||
.insert_existing_mapping("fifa17", "owned-item", "someone-else", 100000001)
|
||||
.unwrap();
|
||||
assert!(identity_dry_preflight(&store, &plan).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_card_ids_reads_emitted_pack() {
|
||||
let items = vec![player(100000001, 20801, 20801, 94)];
|
||||
let rep = analyze(
|
||||
&profile(&items, "[]", 100000500),
|
||||
&roster(),
|
||||
&entities(),
|
||||
&none(),
|
||||
);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sum = emit_content(&rep, dir.path(), "fp").unwrap();
|
||||
let ids = content_card_ids(&sum.content_pack).unwrap();
|
||||
assert!(ids.contains("fifa17_20801"));
|
||||
}
|
||||
|
||||
/// The full apply spawns the Core binary; that end-to-end path is exercised by
|
||||
/// the staged runtime gate, not here. This asserts the orchestration refuses
|
||||
/// cleanly (no panic, no partial identity seed) when the Core binary is absent
|
||||
/// AFTER the two local gates pass — proving ordering: gates first, spawn last.
|
||||
#[test]
|
||||
fn apply_fails_gracefully_when_core_binary_missing() {
|
||||
use crate::apply::ApplyPaths;
|
||||
let items = vec![player(100000001, 20801, 20801, 94)];
|
||||
let (report, raw) = report_and_raw(&items, "[]", 100000500);
|
||||
let plan = plan_apply(&report, &raw, "fp").unwrap();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// emit a real content pack so local preflight passes.
|
||||
let rep2 = analyze(
|
||||
&profile(&items, "[]", 100000500),
|
||||
&roster(),
|
||||
&entities(),
|
||||
&none(),
|
||||
);
|
||||
let sum = emit_content(&rep2, dir.path(), "fp").unwrap();
|
||||
let paths = ApplyPaths {
|
||||
core_bin: dir.path().join("no-such-core-binary"),
|
||||
core_db_url: "sqlite::memory:".to_string(),
|
||||
content_pack: sum.content_pack,
|
||||
data_dir: "data".to_string(),
|
||||
identity_store: dir.path().join("ids.json"),
|
||||
request_out: dir.path().join("req.json"),
|
||||
completion_out: dir.path().join("done.json"),
|
||||
};
|
||||
let err = apply_import(&plan, &paths, false).unwrap_err();
|
||||
assert!(format!("{err:#}").contains("spawn core import"), "{err:#}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user