8b1081019f
CI / Build, lint & test (push) Successful in 3m21s
Core could only own players. Everything else a FUT club holds — managers, staff, consumables, kits, badges, balls, stadiums — had no representation, so the only way to show one to a client was to synthesise it on read. That is the failure mode this commit exists to make impossible: read authority, write authority and persistent ownership authority are now the same rows. MODEL. There is deliberately NO parallel items table. A manager, a consumable, a kit and a player are all rows in `owned_cards`, differing only by a new game-INDEPENDENT `content_kind` (player|manager|staff|consumable|kit|badge|ball| stadium|misc). A game adapter translates its own taxonomy — FIFA 17's `cardsubtypeid` and resource ranges — into one of those tokens before ownership reaches Core; no game's numerics land here. Ownership stays INSTANCE-based: `card_id` is the definition, `id` is the instance, and two copies of one definition remain two rows. `quantity` is a nullable per-instance attribute, not a replacement for the instance. The real profile settles this: its 17 consumables are instance-based and only SOME carry a wire `amount` (observed 1,2,4,5,10,15), while two copies of definition 5003068 exist as two distinct instances. So NULL means "not a stack" and a positive integer is the stack size; collapsing instances into counts is forbidden by the model. ACTIVE DESIGNATIONS. Migration 0024's two-slot kit table becomes `club_active_items` over the five slots that correspond exactly to the client's recovered equipped-state vocabulary (activeBadge 100, activeHomeKit 101, activeAwayKit 102, activeBall 103, activeStadium 104). There is no activeLeagueLogo or activeMisc token, so those kinds correctly get no slot. The invariants are schema-enforced rather than conventional: PK(club_id, slot) allows at most one item per role, `owned_card_id UNIQUE` makes "the same card is both home and away kit" unstorable, and ON DELETE CASCADE means a quick-sold or consumed item cannot be projected back as active. 0024's trigger is preserved in semantics — and dropped EXPLICITLY before its table, because it lives ON `owned_cards`, so DROP TABLE would have orphaned it and broken every later ownership transfer. It still exists because the market moves ownership by UPDATE, which no foreign key can observe. CONSUMABLE ACTIONS. `services/consume.rs` is one transaction primitive — validate source ownership and kind, validate target, mutate, consume the source exactly once, commit — guarded by `UNIQUE(profile_id, action_identity)` in migration 0027, the same discipline as `match_completions`. It supports both deleting the row and decrementing a stack, chosen by the caller, inside the one transaction and the one replay guard. It deliberately contains NO category formulas: an unreversed effect must not be invented, so callers supply the mutation and category validation stays explicit. `/club/kits` is replaced by slot-generic `/club/active-items`. `get_collection` now carries `content_kind` and `quantity`, accepts a `content_kind` filter, and — importantly — stops dropping an owned card with a missing definition silently: the envelope reports `owned_rows`, `unresolved_items` and the offending definition ids. That silent `filter_map` is the documented cause of a club that looks empty while the rows are all present. Verified against a REAL populated club, not a fixture: the production snapshot (migration 19) is copied to a tempdir, migrated to 0024, given two kit designations on real owned instances, then migrated to head. 1986 owned rows survive as content_kind='player', both designations land in `club_active_items`, no row gains a quantity, and the old table is gone. 258 tests pass, clippy clean.
290 lines
9.2 KiB
Rust
290 lines
9.2 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::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<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(),
|
|
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<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(),
|
|
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:#}");
|
|
}
|