//! Generic transactional profile import (services::import). These also serve as //! the Core-level half of the migration mutation battery: each hostile input is //! rejected BEFORE any partial write, and re-runs converge instead of duplicating. use openfut_core::models::card::ContentKind; use openfut_core::services::card_db::CardDb; use openfut_core::services::import::{ apply_profile_import, ImportClub, ImportEntitlement, ImportExtension, ImportOwnedCard, ImportProfile, ImportSlot, ImportSquad, ProfileImportRequest, }; async fn fresh_pool() -> sqlx::SqlitePool { let p = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .expect("in-memory sqlite"); sqlx::migrate!("./migrations") .run(&p) .await .expect("migrations"); p } fn valid_ids(n: usize) -> Vec { let db = CardDb::load("data").expect("load data catalog"); let ids: Vec = db.all().iter().take(n).map(|c| c.id.clone()).collect(); assert!(ids.len() >= n, "data catalog too small for test"); ids } fn owned(ids: &[String]) -> Vec { ids.iter() .enumerate() .map(|(i, id)| ImportOwnedCard { owned_item_id: format!("oc-{i}"), card_id: id.clone(), content_kind: ContentKind::Player, quantity: None, }) .collect() } fn squad_over(owned: &[ImportOwnedCard]) -> ImportSquad { ImportSquad { formation: "f433".into(), name: "OpenFUT".into(), slots: owned .iter() .take(3) .enumerate() .map(|(i, o)| ImportSlot { owned_item_id: o.owned_item_id.clone(), position_index: i as i64, is_captain: i == 0, is_on_bench: false, }) .collect(), extension: ImportExtension { namespace: "fifa17.squad.v1".into(), schema_version: 1, payload: r#"{"custom":[]}"#.into(), }, } } fn request( game: &str, fp: &str, owned: Vec, squad: Option, ) -> ProfileImportRequest { ProfileImportRequest { source_fingerprint: fp.into(), profile: ImportProfile { username: format!("CAGE-{game}"), game_id: game.into(), }, club: ImportClub { name: "OpenFUT".into(), coins: 28_112_944, }, owned, squad, entitlements: Vec::new(), } } async fn count(pool: &sqlx::SqlitePool, table: &str) -> i64 { sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}")) .fetch_one(pool) .await .unwrap() } #[tokio::test] async fn imports_profile_club_owned_and_squad_in_one_shot() { let pool = fresh_pool().await; let db = CardDb::load("data").unwrap(); let ids = valid_ids(5); let ow = owned(&ids); let sq = squad_over(&ow); let req = request("g_happy", "fp-happy", ow, Some(sq)); let out = apply_profile_import(&pool, &db, &req) .await .expect("import"); assert!(matches!( out, openfut_core::services::import::ImportOutcome::Imported { owned: 5, squad_slots: 3 } )); assert_eq!(count(&pool, "profiles").await, 1); assert_eq!(count(&pool, "clubs").await, 1); assert_eq!(count(&pool, "owned_cards").await, 5); assert_eq!(count(&pool, "squad_players").await, 3); // opaque extension persisted with a Core-computed fingerprint. let fp: String = sqlx::query_scalar("SELECT canonical_fingerprint FROM game_entity_ext LIMIT 1") .fetch_one(&pool) .await .unwrap(); assert_eq!(fp.len(), 16, "16-hex FNV fingerprint"); let stored_import_fp: String = sqlx::query_scalar("SELECT import_fingerprint FROM profiles LIMIT 1") .fetch_one(&pool) .await .unwrap(); assert_eq!(stored_import_fp, "fp-happy"); } #[tokio::test] async fn imports_entitlements_seeds_unopened_packs() { let pool = fresh_pool().await; let db = CardDb::load("data").unwrap(); let ids = valid_ids(2); let ow = owned(&ids); let mut req = request("g_ent", "fp-ent", ow, None); req.entitlements = vec![ ImportEntitlement { definition_id: "70".into(), }, ImportEntitlement { definition_id: "70".into(), }, ]; apply_profile_import(&pool, &db, &req) .await .expect("import"); // Two unconsumed entitlements seeded into packs (opened = 0). assert_eq!(count(&pool, "packs").await, 2); let unopened: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM packs WHERE opened = 0") .fetch_one(&pool) .await .unwrap(); assert_eq!(unopened, 2); } #[tokio::test] async fn rerun_same_fingerprint_is_idempotent_noop() { let pool = fresh_pool().await; let db = CardDb::load("data").unwrap(); let ids = valid_ids(4); let mk = || { request( "g_rerun", "fp-x", owned(&ids), Some(squad_over(&owned(&ids))), ) }; apply_profile_import(&pool, &db, &mk()) .await .expect("first"); let out = apply_profile_import(&pool, &db, &mk()) .await .expect("second"); assert_eq!( out, openfut_core::services::import::ImportOutcome::AlreadyImported ); // no duplication. assert_eq!(count(&pool, "profiles").await, 1); assert_eq!(count(&pool, "owned_cards").await, 4); } #[tokio::test] async fn different_fingerprint_on_imported_game_fails() { let pool = fresh_pool().await; let db = CardDb::load("data").unwrap(); let ids = valid_ids(3); apply_profile_import(&pool, &db, &request("g_diff", "fp-a", owned(&ids), None)) .await .expect("first"); let err = apply_profile_import(&pool, &db, &request("g_diff", "fp-b", owned(&ids), None)) .await .expect_err("second, different fingerprint"); assert!(format!("{err:#}").contains("different source"), "{err:#}"); assert_eq!(count(&pool, "profiles").await, 1); } #[tokio::test] async fn missing_definition_fails_preflight_with_no_writes() { let pool = fresh_pool().await; let db = CardDb::load("data").unwrap(); let mut ow = owned(&valid_ids(2)); ow.push(ImportOwnedCard { owned_item_id: "oc-bad".into(), card_id: "fifa17_definitely_absent_999999".into(), content_kind: ContentKind::Player, quantity: None, }); let err = apply_profile_import(&pool, &db, &request("g_miss", "fp", ow, None)) .await .expect_err("missing definition must fail"); assert!( format!("{err:#}").contains("definition preflight failed"), "{err:#}" ); // preflight is before the tx: nothing was written. assert_eq!(count(&pool, "profiles").await, 0); assert_eq!(count(&pool, "owned_cards").await, 0); } #[tokio::test] async fn squad_slot_not_in_ownership_fails() { let pool = fresh_pool().await; let db = CardDb::load("data").unwrap(); let ids = valid_ids(3); let ow = owned(&ids); let mut sq = squad_over(&ow); sq.slots[1].owned_item_id = "oc-not-owned".into(); let err = apply_profile_import(&pool, &db, &request("g_sq", "fp", ow, Some(sq))) .await .expect_err("squad slot not owned must fail"); assert!(format!("{err:#}").contains("all-or-nothing"), "{err:#}"); assert_eq!(count(&pool, "profiles").await, 0); } #[tokio::test] async fn duplicate_owned_item_id_fails() { let pool = fresh_pool().await; let db = CardDb::load("data").unwrap(); let ids = valid_ids(2); let mut ow = owned(&ids); ow[1].owned_item_id = ow[0].owned_item_id.clone(); let err = apply_profile_import(&pool, &db, &request("g_dup", "fp", ow, None)) .await .expect_err("duplicate OwnedItemId must fail"); assert!( format!("{err:#}").contains("duplicate OwnedItemId"), "{err:#}" ); assert_eq!(count(&pool, "profiles").await, 0); } #[tokio::test] async fn non_imported_profile_is_not_clobbered() { let pool = fresh_pool().await; let db = CardDb::load("data").unwrap(); // simulate a gameplay/dev profile with NO import_fingerprint for this game. sqlx::query( "INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at) \ VALUES ('p0','someone',1,0,'g_clobber','2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')", ) .execute(&pool) .await .unwrap(); let ids = valid_ids(2); let err = apply_profile_import(&pool, &db, &request("g_clobber", "fp", owned(&ids), None)) .await .expect_err("must refuse to clobber a non-imported profile"); assert!(format!("{err:#}").contains("non-imported"), "{err:#}"); assert_eq!(count(&pool, "owned_cards").await, 0); } #[tokio::test] async fn empty_owned_fails() { let pool = fresh_pool().await; let db = CardDb::load("data").unwrap(); let err = apply_profile_import(&pool, &db, &request("g_empty", "fp", vec![], None)) .await .expect_err("empty owned must fail"); assert!(format!("{err:#}").contains("zero owned cards"), "{err:#}"); } /// A profile import is once-only, so a taxonomy fix cannot arrive by /// re-importing: the same fingerprint no-ops and a different one is refused. /// Every pre-taxonomy import therefore left coaches, kits and consumables /// durably recorded as players — wrong in the ownership authority even where a /// catalog-driven wire still looked right. #[tokio::test] async fn reclassify_corrects_already_imported_rows_and_is_idempotent() { use openfut_core::services::import::{ reclassify_owned_content, ContentKindAssignment, ReclassifyRequest, }; let pool = fresh_pool().await; let db = CardDb::load("data").unwrap(); let ids = valid_ids(3); // Imported before the taxonomy existed: everything landed as `player`. let ow = owned(&ids); let req = request("g_reclass", "fp-reclass", ow, None); apply_profile_import(&pool, &db, &req) .await .expect("import"); let kind_of = |card: String| { let pool = pool.clone(); async move { sqlx::query_scalar::<_, String>( "SELECT content_kind FROM owned_cards WHERE card_id = ?", ) .bind(card) .fetch_one(&pool) .await .unwrap() } }; assert_eq!(kind_of(ids[0].clone()).await, "player"); let rc = ReclassifyRequest { game_id: "g_reclass".into(), dry_run: false, assignments: vec![ ContentKindAssignment { card_id: ids[0].clone(), content_kind: ContentKind::Staff, }, ContentKindAssignment { card_id: ids[1].clone(), content_kind: ContentKind::Consumable, }, ContentKindAssignment { card_id: "fifa17_definition_nobody_owns".into(), content_kind: ContentKind::Kit, }, ], }; let out = reclassify_owned_content(&pool, &rc) .await .expect("reclassify"); assert_eq!(out.updated, 2); assert_eq!(out.unchanged, 0); assert_eq!( out.unmatched_definitions, vec!["fifa17_definition_nobody_owns".to_string()], "an assignment nobody owns is reported, never invented" ); assert_eq!(kind_of(ids[0].clone()).await, "staff"); assert_eq!(kind_of(ids[1].clone()).await, "consumable"); // Untouched definitions keep their kind. assert_eq!(kind_of(ids[2].clone()).await, "player"); // Rerunning converges: nothing left to change. let again = reclassify_owned_content(&pool, &rc).await.expect("rerun"); assert_eq!(again.updated, 0); assert_eq!(again.unchanged, 2); } /// Reclassification is scoped to one game, so a shared database cannot have /// another game's identically-named definition rewritten underneath it. #[tokio::test] async fn reclassify_never_crosses_a_game_boundary() { use openfut_core::services::import::{ reclassify_owned_content, ContentKindAssignment, ReclassifyRequest, }; let pool = fresh_pool().await; let db = CardDb::load("data").unwrap(); let ids = valid_ids(2); apply_profile_import(&pool, &db, &request("g_a", "fp-a", owned(&ids), None)) .await .expect("import a"); // Same definitions, but owned-item ids are globally unique. let mut b_owned = owned(&ids); for o in &mut b_owned { o.owned_item_id = format!("b-{}", o.owned_item_id); } apply_profile_import(&pool, &db, &request("g_b", "fp-b", b_owned, None)) .await .expect("import b"); let out = reclassify_owned_content( &pool, &ReclassifyRequest { game_id: "g_a".into(), dry_run: false, assignments: vec![ContentKindAssignment { card_id: ids[0].clone(), content_kind: ContentKind::Kit, }], }, ) .await .expect("reclassify"); assert_eq!(out.updated, 1, "only game A's copy"); let kinds: Vec = sqlx::query_scalar( "SELECT o.content_kind FROM owned_cards o \ JOIN clubs c ON c.id = o.club_id JOIN profiles p ON p.id = c.profile_id \ WHERE p.game_id = 'g_b' AND o.card_id = ?", ) .bind(&ids[0]) .fetch_all(&pool) .await .unwrap(); assert_eq!(kinds, vec!["player".to_string()], "game B untouched"); } /// A dry run must report the SAME counts a real run would, and leave the /// database byte-for-byte unchanged. It runs the real UPDATEs and rolls back, so /// the numbers are measured rather than predicted — which is the only reason an /// operator can trust them before touching a frozen production database. #[tokio::test] async fn reclassify_dry_run_reports_the_real_counts_and_commits_nothing() { use openfut_core::services::import::{ reclassify_owned_content, ContentKindAssignment, ReclassifyRequest, }; let pool = fresh_pool().await; let db = CardDb::load("data").unwrap(); let ids = valid_ids(3); apply_profile_import(&pool, &db, &request("g_dry", "fp-dry", owned(&ids), None)) .await .expect("import"); let kinds = || { let pool = pool.clone(); async move { sqlx::query_scalar::<_, String>("SELECT content_kind FROM owned_cards ORDER BY card_id") .fetch_all(&pool) .await .unwrap() } }; let before = kinds().await; let mut rc = ReclassifyRequest { game_id: "g_dry".into(), dry_run: true, assignments: vec![ ContentKindAssignment { card_id: ids[0].clone(), content_kind: ContentKind::Staff, }, ContentKindAssignment { card_id: ids[1].clone(), content_kind: ContentKind::Consumable, }, ], }; let dry = reclassify_owned_content(&pool, &rc).await.expect("dry run"); assert!(dry.dry_run); assert_eq!(dry.updated, 2); assert_eq!( dry.updated_by_kind, [("consumable".to_string(), 1), ("staff".to_string(), 1)] .into_iter() .collect(), "the per-kind shape is what an operator sanity-checks" ); assert_eq!(kinds().await, before, "a dry run commits NOTHING"); // The real run then reports exactly what the dry run promised. rc.dry_run = false; let real = reclassify_owned_content(&pool, &rc) .await .expect("real run"); assert!(!real.dry_run); assert_eq!(real.updated, dry.updated); assert_eq!(real.updated_by_kind, dry.updated_by_kind); assert_ne!(kinds().await, before, "the real run DID commit"); }