Files
OpenFUT-Core/tests/import_service_test.rs
T
OpenFUT Agent bcc4f5104a feat(economy): purchase_items primitive + entitlement import seeding
purchase_items: generic atomic debit + mint of several items (fail-closed) for
open-on-buy Store packs (Store BUY returns items immediately) + POST
/economy/purchase-items route. Import: add optional entitlements[] to
ProfileImportRequest, seeding unconsumed packs rows in the same transaction (so a
source's unopened packs become Core entitlements); idempotency via the existing
import fingerprint. Tests: 2 purchase_items unit + endpoint + entitlement-seed
import. Core matrix 45 lib + 116 integration + 9 import green; clippy clean.
2026-08-13 19:27:06 +00:00

285 lines
9.0 KiB
Rust

//! 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::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<String> {
let db = CardDb::load("data").expect("load data catalog");
let ids: Vec<String> = 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<ImportOwnedCard> {
ids.iter()
.enumerate()
.map(|(i, id)| ImportOwnedCard {
owned_item_id: format!("oc-{i}"),
card_id: id.clone(),
})
.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<ImportOwnedCard>,
squad: Option<ImportSquad>,
) -> 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(),
});
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:#}");
}