feat(import): generic transactional profile-import service + CLI
Core performs a GAME-AGNOSTIC transactional profile import; all FIFA17 semantics (manifest parse, wire ids, resourceId/nextItemId, squad extension v1, CardDefinitionId/OwnedItemId choice) stay in openfut-import-fifa17. Core sees only opaque ids and opaque extension bytes. services::import::apply_profile_import(pool, card_db, ProfileImportRequest): - ONE SQLite transaction installs profile + club + all owned cards + canonical squad + one opaque game extension; commits together or not at all. - Definition preflight (pre-tx): every owned card_id MUST resolve in loaded production content, so ownership never points at absent content. - Squad all-or-nothing (pre-tx): every active-squad OwnedItemId MUST be in the imported ownership set. - OwnedItemId uniqueness + generic extension bounds enforced pre-tx. - Core computes the canonical squad fingerprint itself (never adapter-supplied) and persists the extension atomically, exactly as the live squad-write path. Rerun identity via profiles.import_fingerprint (migration 0018, nullable): - identical source_fingerprint on an already-imported game -> idempotent no-op; - differing token -> fail (needs explicit update mode); - pre-existing non-imported profile -> never clobbered. So a crash after identity-seeding re-runs cleanly with no cleanup/reminting. CLI: 'openfut-core import <request.json>' loads production content packs, parses a generic request, applies. squad_fingerprint made pub(crate) for reuse. 8 import-service tests (happy path, idempotent rerun, fingerprint mismatch, missing-definition no-write, squad-not-owned, dup OwnedItemId, non-imported clobber guard, empty-owned). clippy -D warnings clean; full suite 159 green.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
-- Generic provenance/rerun-identity token for a transactionally imported profile.
|
||||
--
|
||||
-- Set by the generic profile-import path (services::import). A NULL value means
|
||||
-- the profile was created by normal gameplay / dev seeding, not an import, and
|
||||
-- MUST NOT be silently clobbered by an import targeting the same game. A
|
||||
-- matching token on a re-run is an idempotent no-op; a differing token against
|
||||
-- an already-imported game fails until an explicit update mode exists.
|
||||
--
|
||||
-- Core never interprets the token's structure; the importer adapter chooses it.
|
||||
ALTER TABLE profiles ADD COLUMN import_fingerprint TEXT;
|
||||
+26
-1
@@ -1,4 +1,4 @@
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use openfut_core::{config, db, seed};
|
||||
use tracing::info;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
@@ -30,6 +30,31 @@ async fn main() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Opt-in generic import subcommand: `openfut-core import <request.json>`.
|
||||
// Reads a GAME-AGNOSTIC ProfileImportRequest (the importer adapter translates
|
||||
// FIFA17 source data into it), loads production content packs, runs preflight,
|
||||
// and applies one all-or-nothing transaction. FIFA17 semantics live entirely
|
||||
// in the adapter; Core only sees opaque ids + opaque extension bytes.
|
||||
if std::env::args().nth(1).as_deref() == Some("import") {
|
||||
let path = std::env::args()
|
||||
.nth(2)
|
||||
.context("usage: openfut-core import <request.json>")?;
|
||||
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
|
||||
db::run_migrations(&pool).await?;
|
||||
let mut card_db = openfut_core::services::card_db::CardDb::load(&cfg.data_dir)?;
|
||||
for pack in &cfg.content_packs {
|
||||
card_db.load_pack(pack)?;
|
||||
}
|
||||
let raw = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("read import request {path}"))?;
|
||||
let req: openfut_core::services::import::ProfileImportRequest =
|
||||
serde_json::from_str(&raw).context("parse import request JSON")?;
|
||||
let outcome =
|
||||
openfut_core::services::import::apply_profile_import(&pool, &card_db, &req).await?;
|
||||
println!("{}", serde_json::to_string_pretty(&outcome)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("OpenFUT Core starting on {}", cfg.listen_addr);
|
||||
|
||||
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
//! Generic, game-agnostic transactional profile import.
|
||||
//!
|
||||
//! Core installs a profile + club + owned cards + canonical squad + one opaque
|
||||
//! game extension in a SINGLE all-or-nothing SQLite transaction, stamped with a
|
||||
//! generic `source_fingerprint` provenance token. Core NEVER interprets FIFA17
|
||||
//! wire ids, resourceIds, `nextItemId`, or the extension payload — the
|
||||
//! `openfut-import-fifa17` adapter reads the Python profile, chooses every
|
||||
//! `CardDefinitionId` and every opaque `OwnedItemId`, builds the squad
|
||||
//! extension bytes, and hands Core this generic request.
|
||||
//!
|
||||
//! Invariants enforced here:
|
||||
//! - Definition preflight: every incoming `card_id` MUST already resolve in the
|
||||
//! loaded production content, so the transaction never creates ownership
|
||||
//! pointing at absent content.
|
||||
//! - Squad all-or-nothing: every active-squad `owned_item_id` MUST be among the
|
||||
//! imported ownership set before the transaction begins.
|
||||
//! - Rerun identity: identical `source_fingerprint` against an already-imported
|
||||
//! game is an idempotent no-op; a differing token fails; a pre-existing
|
||||
//! non-imported profile is never clobbered.
|
||||
//! - The whole thing commits together or not at all.
|
||||
|
||||
use crate::db::Pool;
|
||||
use crate::models::game_ext::{MAX_EXT_NAMESPACE_LEN, MAX_EXT_PAYLOAD_BYTES};
|
||||
use crate::services::card_db::CardDb;
|
||||
use crate::services::squad::squad_fingerprint;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ImportProfile {
|
||||
pub username: String,
|
||||
pub game_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ImportClub {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub coins: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ImportOwnedCard {
|
||||
/// Opaque, stable Core OwnedItemId chosen by the adapter. Core never parses
|
||||
/// why it is stable — it is a primary key, nothing more.
|
||||
pub owned_item_id: String,
|
||||
/// CardDefinitionId that MUST resolve in loaded production content.
|
||||
pub card_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ImportSlot {
|
||||
pub owned_item_id: String,
|
||||
pub position_index: i64,
|
||||
#[serde(default)]
|
||||
pub is_captain: bool,
|
||||
#[serde(default)]
|
||||
pub is_on_bench: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ImportExtension {
|
||||
/// Opaque adapter key, e.g. "fifa17.squad.v1".
|
||||
pub namespace: String,
|
||||
/// Adapter payload version (distinct from DB storage schema).
|
||||
pub schema_version: i64,
|
||||
/// Uninterpreted bytes-as-text. Core enforces only generic size bounds.
|
||||
pub payload: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ImportSquad {
|
||||
pub formation: String,
|
||||
#[serde(default = "default_squad_name")]
|
||||
pub name: String,
|
||||
pub slots: Vec<ImportSlot>,
|
||||
pub extension: ImportExtension,
|
||||
}
|
||||
|
||||
fn default_squad_name() -> String {
|
||||
"My Squad".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ProfileImportRequest {
|
||||
/// Generic provenance/rerun-identity token. Core stores it verbatim.
|
||||
pub source_fingerprint: String,
|
||||
pub profile: ImportProfile,
|
||||
pub club: ImportClub,
|
||||
pub owned: Vec<ImportOwnedCard>,
|
||||
#[serde(default)]
|
||||
pub squad: Option<ImportSquad>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||
#[serde(tag = "outcome", rename_all = "snake_case")]
|
||||
pub enum ImportOutcome {
|
||||
/// A fresh import committed.
|
||||
Imported { owned: usize, squad_slots: usize },
|
||||
/// The same fingerprint was already imported for this game — no-op.
|
||||
AlreadyImported,
|
||||
}
|
||||
|
||||
/// Apply a generic transactional profile import. See module docs for invariants.
|
||||
pub async fn apply_profile_import(
|
||||
pool: &Pool,
|
||||
card_db: &CardDb,
|
||||
req: &ProfileImportRequest,
|
||||
) -> Result<ImportOutcome> {
|
||||
// ── 0. generic input validation (no writes) ──
|
||||
if req.source_fingerprint.trim().is_empty() {
|
||||
bail!("source_fingerprint must be non-empty");
|
||||
}
|
||||
if req.owned.is_empty() {
|
||||
bail!("import request has zero owned cards; refusing to import an empty profile");
|
||||
}
|
||||
|
||||
// ── 1. rerun identity / single-profile-per-game ──
|
||||
let existing: Option<(String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT id, import_fingerprint FROM profiles \
|
||||
WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
|
||||
)
|
||||
.bind(&req.profile.game_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
if let Some((_id, fp)) = existing {
|
||||
match fp {
|
||||
Some(fp) if fp == req.source_fingerprint => return Ok(ImportOutcome::AlreadyImported),
|
||||
Some(fp) => bail!(
|
||||
"game '{}' already imported from a different source (stored fingerprint {fp}, \
|
||||
incoming {}); refusing to overwrite without an explicit update mode",
|
||||
req.profile.game_id,
|
||||
req.source_fingerprint
|
||||
),
|
||||
None => bail!(
|
||||
"game '{}' already has a non-imported profile; refusing to clobber it",
|
||||
req.profile.game_id
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. definition preflight: every card_id MUST resolve in loaded content ──
|
||||
let mut missing: Vec<&str> = req
|
||||
.owned
|
||||
.iter()
|
||||
.filter(|o| card_db.get(&o.card_id).is_none())
|
||||
.map(|o| o.card_id.as_str())
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
missing.sort_unstable();
|
||||
missing.dedup();
|
||||
let sample = &missing[..missing.len().min(5)];
|
||||
bail!(
|
||||
"definition preflight failed: {} owned card(s) reference CardDefinitionId(s) not in \
|
||||
loaded content (e.g. {sample:?}); refusing to create ownership pointing at absent content",
|
||||
missing.len()
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. owned-item-id uniqueness ──
|
||||
let mut owned_ids: HashSet<&str> = HashSet::with_capacity(req.owned.len());
|
||||
for o in &req.owned {
|
||||
if !owned_ids.insert(o.owned_item_id.as_str()) {
|
||||
bail!(
|
||||
"duplicate OwnedItemId in import request: {}",
|
||||
o.owned_item_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. squad all-or-nothing + generic extension bounds (no writes) ──
|
||||
if let Some(sq) = &req.squad {
|
||||
let ns_len = sq.extension.namespace.len();
|
||||
if ns_len == 0 || ns_len > MAX_EXT_NAMESPACE_LEN {
|
||||
bail!(
|
||||
"extension namespace length {ns_len} out of bounds (1..={MAX_EXT_NAMESPACE_LEN})"
|
||||
);
|
||||
}
|
||||
if sq.extension.payload.len() > MAX_EXT_PAYLOAD_BYTES {
|
||||
bail!(
|
||||
"extension payload {} bytes exceeds MAX_EXT_PAYLOAD_BYTES ({MAX_EXT_PAYLOAD_BYTES})",
|
||||
sq.extension.payload.len()
|
||||
);
|
||||
}
|
||||
for slot in &sq.slots {
|
||||
if !owned_ids.contains(slot.owned_item_id.as_str()) {
|
||||
bail!(
|
||||
"active squad references OwnedItemId {} not present in imported ownership set; \
|
||||
squad import is all-or-nothing",
|
||||
slot.owned_item_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. single transaction: everything commits together or not at all ──
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let profile_id = Uuid::new_v4().to_string();
|
||||
let club_id = Uuid::new_v4().to_string();
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO profiles (id, username, level, xp, game_id, created_at, updated_at, import_fingerprint) \
|
||||
VALUES (?, ?, 1, 0, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&profile_id)
|
||||
.bind(&req.profile.username)
|
||||
.bind(&req.profile.game_id)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.bind(&req.source_fingerprint)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("insert profile")?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO clubs (id, profile_id, name, coins, level, created_at, updated_at) \
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?)",
|
||||
)
|
||||
.bind(&club_id)
|
||||
.bind(&profile_id)
|
||||
.bind(&req.club.name)
|
||||
.bind(req.club.coins)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("insert club")?;
|
||||
|
||||
for o in &req.owned {
|
||||
sqlx::query(
|
||||
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \
|
||||
VALUES (?, ?, ?, 0, NULL, ?)",
|
||||
)
|
||||
.bind(&o.owned_item_id)
|
||||
.bind(&club_id)
|
||||
.bind(&o.card_id)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.with_context(|| format!("insert owned_card {}", o.owned_item_id))?;
|
||||
}
|
||||
|
||||
let mut squad_slots = 0usize;
|
||||
if let Some(sq) = &req.squad {
|
||||
let squad_id = Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&squad_id)
|
||||
.bind(&club_id)
|
||||
.bind(&sq.name)
|
||||
.bind(&sq.formation)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("insert squad")?;
|
||||
|
||||
for slot in &sq.slots {
|
||||
sqlx::query(
|
||||
"INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, is_captain, is_on_bench) \
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(Uuid::new_v4().to_string())
|
||||
.bind(&squad_id)
|
||||
.bind(&slot.owned_item_id)
|
||||
.bind(slot.position_index)
|
||||
.bind(slot.is_captain)
|
||||
.bind(slot.is_on_bench)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("insert squad_player")?;
|
||||
}
|
||||
squad_slots = sq.slots.len();
|
||||
|
||||
// Core computes the canonical fingerprint over the COMMITTED squad — never
|
||||
// an adapter-supplied value — and persists the opaque extension atomically
|
||||
// in the same tx, exactly as the live squad-write path does.
|
||||
let canonical_fingerprint = squad_fingerprint(
|
||||
&squad_id,
|
||||
&sq.formation,
|
||||
sq.slots.iter().map(|s| {
|
||||
(
|
||||
s.position_index,
|
||||
s.owned_item_id.as_str(),
|
||||
s.is_captain,
|
||||
s.is_on_bench,
|
||||
)
|
||||
}),
|
||||
);
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO game_entity_ext \
|
||||
(game_id, entity_kind, entity_id, namespace, schema_version, canonical_fingerprint, payload, updated_at) \
|
||||
VALUES (?, 'squad', ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&req.profile.game_id)
|
||||
.bind(&squad_id)
|
||||
.bind(&sq.extension.namespace)
|
||||
.bind(sq.extension.schema_version)
|
||||
.bind(&canonical_fingerprint)
|
||||
.bind(&sq.extension.payload)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("insert game_entity_ext")?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(ImportOutcome::Imported {
|
||||
owned: req.owned.len(),
|
||||
squad_slots,
|
||||
})
|
||||
}
|
||||
+3
-2
@@ -2,19 +2,20 @@ pub mod achievement;
|
||||
pub mod card_db;
|
||||
pub mod checkin;
|
||||
pub mod club;
|
||||
pub mod notification;
|
||||
pub mod draft;
|
||||
pub mod event;
|
||||
pub mod fut_champs;
|
||||
pub mod game_ext;
|
||||
pub mod import;
|
||||
pub mod inventory;
|
||||
pub mod season;
|
||||
pub mod market;
|
||||
pub mod match_service;
|
||||
pub mod notification;
|
||||
pub mod objective;
|
||||
pub mod pack;
|
||||
pub mod profile;
|
||||
pub mod sbc;
|
||||
pub mod season;
|
||||
pub mod settings;
|
||||
pub mod squad;
|
||||
pub mod squad_rules;
|
||||
|
||||
@@ -497,7 +497,7 @@ pub async fn replace_squad_with_extension(
|
||||
/// computed; non-cryptographic (FNV-1a-64) — a stale-extension guard, not a
|
||||
/// security boundary. The encoding is sorted + delimited so it never depends on
|
||||
/// row/iteration order.
|
||||
fn squad_fingerprint<'a>(
|
||||
pub(crate) fn squad_fingerprint<'a>(
|
||||
squad_id: &str,
|
||||
formation: &str,
|
||||
slots: impl Iterator<Item = (i64, &'a str, bool, bool)>,
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
//! 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, 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,
|
||||
}
|
||||
}
|
||||
|
||||
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 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:#}");
|
||||
}
|
||||
Reference in New Issue
Block a user