Files
OpenFUT-Core/src/services/squad.rs
T
funman300 9f3c545c46 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.
2026-08-12 20:01:27 +00:00

1141 lines
36 KiB
Rust

use crate::{
db::Pool,
error::{AppError, AppResult},
models::{
card::{CardDefinition, OwnedCard},
game_ext::{GameEntityExt, OpaqueExtensionWrite},
squad::{
SaveSquadRequest, SlotAssignment, Squad, SquadPlayer, SquadPlayerInput, SquadReplaced,
SquadReplacement,
},
},
services::{
card_db::CardDb,
game_ext,
squad_rules::{
ClientReportedEvaluation, DefaultSquadRules, SquadPlayerCard, SquadRules, SquadSnapshot,
},
},
};
use std::collections::HashSet;
use uuid::Uuid;
pub async fn get_squad(pool: &Pool, club_id: &str) -> AppResult<(Squad, Vec<SquadPlayer>)> {
let squad = sqlx::query_as::<_, Squad>(
"SELECT id, club_id, name, formation, created_at, updated_at FROM squads WHERE club_id = ? ORDER BY updated_at DESC LIMIT 1"
)
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound("no squad found for this club".into()))?;
let players = get_players(pool, &squad.id).await?;
Ok((squad, players))
}
pub async fn get_squad_by_id(
pool: &Pool,
club_id: &str,
squad_id: &str,
) -> AppResult<(Squad, Vec<SquadPlayer>)> {
let squad = sqlx::query_as::<_, Squad>(
"SELECT id, club_id, name, formation, created_at, updated_at FROM squads WHERE id = ? AND club_id = ?",
)
.bind(squad_id)
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("squad '{squad_id}' not found")))?;
let players = get_players(pool, &squad.id).await?;
Ok((squad, players))
}
pub async fn list_squads(pool: &Pool, club_id: &str) -> AppResult<Vec<Squad>> {
let squads = sqlx::query_as::<_, Squad>(
"SELECT id, club_id, name, formation, created_at, updated_at FROM squads WHERE club_id = ? ORDER BY updated_at DESC",
)
.bind(club_id)
.fetch_all(pool)
.await?;
Ok(squads)
}
async fn get_players(pool: &Pool, squad_id: &str) -> AppResult<Vec<SquadPlayer>> {
let players = sqlx::query_as::<_, SquadPlayer>(
"SELECT id, squad_id, owned_card_id, position_index, is_captain, is_on_bench FROM squad_players WHERE squad_id = ?",
)
.bind(squad_id)
.fetch_all(pool)
.await?;
Ok(players)
}
pub async fn validate_formation(
pool: &Pool,
card_db: &CardDb,
club_id: &str,
players: &[SquadPlayerInput],
) -> AppResult<()> {
let starters: Vec<&SquadPlayerInput> = players.iter().filter(|p| !p.is_on_bench).collect();
if starters.len() != 11 {
return Err(AppError::BadRequest(format!(
"need exactly 11 starters, got {}",
starters.len()
)));
}
let mut gk_count = 0usize;
for sp in &starters {
let owned = sqlx::query_as::<_, OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ? AND club_id = ?",
)
.bind(&sp.owned_card_id)
.bind(club_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("owned card {} not found or does not belong to this club", sp.owned_card_id)))?;
if let Some(card) = card_db.get(&owned.card_id) {
if card.position == "GK" {
gk_count += 1;
}
}
}
if gk_count == 0 {
return Err(AppError::BadRequest(
"squad must include exactly one goalkeeper (GK)".into(),
));
}
if gk_count > 1 {
return Err(AppError::BadRequest(
"squad cannot have more than one starting goalkeeper".into(),
));
}
Ok(())
}
/// Chemistry v2: FUT-style link scoring.
///
/// Each player earns up to 10 chemistry from three link types:
/// - Club links: strongest signal — +3 per shared club teammate (max 6 pts)
/// - League links: medium signal — +1 per shared league mate (max 4 pts)
/// - Nation links: weakest signal — +1 per shared nation mate (max 3 pts)
///
/// Individual player chemistry is capped at 10.
/// Team chemistry = sum of all player chemistries, capped at 100.
pub async fn calculate_chemistry(
pool: &Pool,
card_db: &CardDb,
players: &[SquadPlayer],
) -> AppResult<serde_json::Value> {
let starters: Vec<&SquadPlayer> = players.iter().filter(|p| !p.is_on_bench).collect();
// Load all starter card definitions (N separate queries, fine for 11 players)
let mut player_cards: Vec<(String, CardDefinition)> = Vec::new();
for sp in &starters {
let owned = sqlx::query_as::<_, OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \
FROM owned_cards WHERE id = ?",
)
.bind(&sp.owned_card_id)
.fetch_optional(pool)
.await?;
if let Some(o) = owned {
if let Some(card) = card_db.get(&o.card_id) {
player_cards.push((sp.owned_card_id.clone(), card.clone()));
}
}
}
let mut player_chemistries = Vec::with_capacity(player_cards.len());
let mut total_chem: i64 = 0;
for (i, (owned_id, card)) in player_cards.iter().enumerate() {
let club_links = player_cards
.iter()
.enumerate()
.filter(|(j, (_, c))| *j != i && c.club == card.club)
.count() as i64;
let league_links = player_cards
.iter()
.enumerate()
.filter(|(j, (_, c))| *j != i && c.league == card.league)
.count() as i64;
let nation_links = player_cards
.iter()
.enumerate()
.filter(|(j, (_, c))| *j != i && c.nation == card.nation)
.count() as i64;
let club_pts = (club_links * 3).min(6);
let league_pts = league_links.min(4);
let nation_pts = nation_links.min(3);
let player_chem = (club_pts + league_pts + nation_pts).min(10);
total_chem += player_chem;
player_chemistries.push(serde_json::json!({
"owned_card_id": owned_id,
"card_id": &card.id,
"name": &card.name,
"chemistry": player_chem,
"breakdown": {
"club_links": club_links,
"league_links": league_links,
"nation_links": nation_links,
"club_pts": club_pts,
"league_pts": league_pts,
"nation_pts": nation_pts,
}
}));
}
let team_chemistry = total_chem.min(100);
Ok(serde_json::json!({
"total": team_chemistry,
"max": 100,
"player_count": player_chemistries.len(),
"player_chemistries": player_chemistries,
}))
}
/// Replace a squad's entire slot assignment, atomically.
///
/// # Why this exists alongside `save_squad`
///
/// `save_squad` wrote outside a transaction: it UPDATEd the squad, DELETEd every
/// row from `squad_players`, then INSERTed the new ones one at a time. A failure
/// part-way through left a squad with some of its old players deleted and only
/// some of its new ones written — a state no client asked for and none can
/// detect. It also never checked that the cards being placed belonged to the
/// club, and happily accepted the same card in two slots.
///
/// Those are acceptable in a single-user REST toy and not acceptable under a
/// real client, so this is the one write path now and `save_squad` delegates to
/// it.
///
/// # Order of work
///
/// Validation happens BEFORE any write, so a rejected replacement leaves the
/// existing squad exactly as it was. Everything that does write happens inside
/// one transaction.
#[allow(clippy::too_many_arguments)]
async fn replace_squad_inner(
pool: &Pool,
card_db: &CardDb,
rules: &dyn SquadRules,
game_id: Option<&str>,
club_id: &str,
squad_id: Option<&str>,
replacement: &SquadReplacement,
client_reported: &ClientReportedEvaluation,
ext: Option<&OpaqueExtensionWrite>,
) -> AppResult<SquadReplaced> {
// Generic bounds on the opaque extension, before any write (fail fast, no
// partial state). Core guards size only — the adapter owns payload meaning.
if let Some(ext) = ext {
ext.validate().map_err(AppError::BadRequest)?;
}
// ── validate before touching anything ────────────────────────────────
let mut seen: HashSet<&str> = HashSet::new();
let mut slots_seen: HashSet<i64> = HashSet::new();
for s in &replacement.slots {
if s.slot < 0 {
return Err(AppError::BadRequest(format!(
"slot index must not be negative, got {}",
s.slot
)));
}
if !slots_seen.insert(s.slot) {
return Err(AppError::BadRequest(format!(
"slot {} assigned more than once",
s.slot
)));
}
if !seen.insert(s.owned_card_id.as_str()) {
return Err(AppError::BadRequest(format!(
"card {} assigned to more than one slot",
s.owned_card_id
)));
}
}
// Ownership: every card must belong to THIS club. Without this a client
// could place a card it does not own, and the squad would read back as
// though it did.
let mut resolved: Vec<(SlotAssignmentRef, OwnedCard)> = Vec::new();
for s in &replacement.slots {
let owned = sqlx::query_as::<_, OwnedCard>(
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards WHERE id = ?",
)
.bind(&s.owned_card_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("owned card {} not found", s.owned_card_id)))?;
if owned.club_id != club_id {
// Deliberately the same message as "not found": whether a card
// exists in someone else's club is not this caller's business.
return Err(AppError::NotFound(format!(
"owned card {} not found",
s.owned_card_id
)));
}
resolved.push((
SlotAssignmentRef {
slot: s.slot,
is_captain: s.is_captain,
is_on_bench: s.is_on_bench,
},
owned,
));
}
// ── one transaction for every write ──────────────────────────────────
let now = chrono::Utc::now().to_rfc3339();
let mut tx = pool.begin().await?;
let squad_id = match squad_id {
Some(id) => {
let verified = sqlx::query_scalar::<_, String>(
"SELECT id FROM squads WHERE id = ? AND club_id = ?",
)
.bind(id)
.bind(club_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| AppError::NotFound(format!("squad '{id}' not found")))?;
sqlx::query(
"UPDATE squads SET name = COALESCE(?, name), formation = COALESCE(?, formation), updated_at = ? WHERE id = ?",
)
.bind(replacement.name.as_deref())
.bind(replacement.formation.as_deref())
.bind(&now)
.bind(&verified)
.execute(&mut *tx)
.await?;
verified
}
None => {
let squad = Squad::new(
club_id,
replacement.name.as_deref().unwrap_or("My Squad"),
replacement.formation.as_deref().unwrap_or("4-4-2"),
);
sqlx::query(
"INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&squad.id)
.bind(&squad.club_id)
.bind(&squad.name)
.bind(&squad.formation)
.bind(&squad.created_at)
.bind(&squad.updated_at)
.execute(&mut *tx)
.await?;
squad.id
}
};
sqlx::query("DELETE FROM squad_players WHERE squad_id = ?")
.bind(&squad_id)
.execute(&mut *tx)
.await?;
for (slot, owned) in &resolved {
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(&owned.id)
.bind(slot.slot)
.bind(slot.is_captain)
.bind(slot.is_on_bench)
.execute(&mut *tx)
.await?;
}
let squad = sqlx::query_as::<_, Squad>(
"SELECT id, club_id, name, formation, created_at, updated_at FROM squads WHERE id = ?",
)
.bind(&squad_id)
.fetch_one(&mut *tx)
.await?;
// Fingerprint the COMMITTED canonical state (server-computed; never a
// client/adapter value) and, atomically in this same tx, persist the opaque
// game extension anchored to it. Canonical squad + extension commit together
// or not at all — no split-brain, no distributed protocol.
let canonical_fingerprint = squad_fingerprint(
&squad_id,
&squad.formation,
resolved
.iter()
.map(|(s, o)| (s.slot, o.id.as_str(), s.is_captain, s.is_on_bench)),
);
if let Some(ext) = ext {
let gid = game_id.expect("game_id is required whenever an extension is written");
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(gid)
.bind(&squad_id)
.bind(&ext.namespace)
.bind(ext.schema_version)
.bind(&canonical_fingerprint)
.bind(&ext.payload)
.bind(&now)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
// ── evaluate with the game's rules, never with the client's numbers ──
let snapshot = SquadSnapshot {
formation: squad.formation.clone(),
players: resolved
.iter()
.filter_map(|(slot, owned)| {
card_db.get(&owned.card_id).map(|card| SquadPlayerCard {
owned_card_id: owned.id.clone(),
card_id: card.id.clone(),
name: card.name.clone(),
overall: card.overall,
position: card.position.clone(),
nation: card.nation.clone(),
league: card.league.clone(),
club: card.club.clone(),
slot: slot.slot,
on_bench: slot.is_on_bench,
})
})
.collect(),
};
let evaluation = rules.evaluate(&snapshot);
let client_disagreements = client_reported.compare(&evaluation);
Ok(SquadReplaced {
squad,
slots_written: resolved.len(),
evaluation,
client_disagreements,
canonical_fingerprint,
})
}
struct SlotAssignmentRef {
slot: i64,
is_captain: bool,
is_on_bench: bool,
}
/// Replace a squad's slots atomically (no game extension).
pub async fn replace_squad(
pool: &Pool,
card_db: &CardDb,
rules: &dyn SquadRules,
club_id: &str,
squad_id: Option<&str>,
replacement: &SquadReplacement,
client_reported: &ClientReportedEvaluation,
) -> AppResult<SquadReplaced> {
replace_squad_inner(
pool,
card_db,
rules,
None,
club_id,
squad_id,
replacement,
client_reported,
None,
)
.await
}
/// Replace a squad AND persist an opaque game extension in ONE transaction, so
/// the canonical squad and its game-only round-trip state can never split-brain.
/// The extension is anchored to the committed squad by a server-computed
/// fingerprint; Core never interprets the payload.
#[allow(clippy::too_many_arguments)]
pub async fn replace_squad_with_extension(
pool: &Pool,
card_db: &CardDb,
rules: &dyn SquadRules,
game_id: &str,
club_id: &str,
squad_id: Option<&str>,
replacement: &SquadReplacement,
client_reported: &ClientReportedEvaluation,
ext: &OpaqueExtensionWrite,
) -> AppResult<SquadReplaced> {
replace_squad_inner(
pool,
card_db,
rules,
Some(game_id),
club_id,
squad_id,
replacement,
client_reported,
Some(ext),
)
.await
}
/// Deterministic, order-stable fingerprint of a squad's canonical state. Server-
/// 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.
pub(crate) fn squad_fingerprint<'a>(
squad_id: &str,
formation: &str,
slots: impl Iterator<Item = (i64, &'a str, bool, bool)>,
) -> String {
let mut items: Vec<String> = slots
.map(|(slot, owned, cap, bench)| format!("{slot}:{owned}:{}:{}", cap as u8, bench as u8))
.collect();
items.sort();
let canon = format!("v1|{squad_id}|{formation}|{}", items.join(";"));
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in canon.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{h:016x}")
}
/// Freshness of a squad's opaque extension vs the current canonical squad.
pub enum SquadExtState {
Fresh(GameEntityExt),
Stale {
stored: GameEntityExt,
current_fingerprint: String,
},
Missing,
}
/// Read a club's active squad, its players, and its opaque game extension for
/// `namespace`, with an explicit freshness verdict. NEVER silently projects a
/// stale blob — the caller decides policy on `Stale`/`Missing`.
pub async fn read_squad_with_ext(
pool: &Pool,
game_id: &str,
club_id: &str,
namespace: &str,
) -> AppResult<(Squad, Vec<SquadPlayer>, SquadExtState)> {
let (squad, players) = get_squad(pool, club_id).await?;
let current = squad_fingerprint(
&squad.id,
&squad.formation,
players.iter().map(|p| {
(
p.position_index,
p.owned_card_id.as_str(),
p.is_captain,
p.is_on_bench,
)
}),
);
let state = match game_ext::get_ext(pool, game_id, "squad", &squad.id, namespace).await? {
None => SquadExtState::Missing,
Some(row) if row.canonical_fingerprint == current => SquadExtState::Fresh(row),
Some(row) => SquadExtState::Stale {
stored: row,
current_fingerprint: current,
},
};
Ok((squad, players, state))
}
/// Compatibility wrapper over [`replace_squad`].
///
/// Kept so the existing Core REST route keeps working, but it no longer has its
/// own write path. That means this route now also validates ownership and
/// rejects duplicate cards — a deliberate tightening, not an accident: those
/// were bugs, and having two write paths with different guarantees is how the
/// stricter one gets bypassed.
pub async fn save_squad(
pool: &Pool,
card_db: &CardDb,
club_id: &str,
req: &SaveSquadRequest,
) -> AppResult<Squad> {
let replacement = SquadReplacement {
name: req.name.clone(),
formation: req.formation.clone(),
slots: req
.players
.iter()
.map(|p| SlotAssignment {
owned_card_id: p.owned_card_id.clone(),
slot: p.position_index,
is_captain: p.is_captain,
is_on_bench: p.is_on_bench,
})
.collect(),
};
let out = replace_squad(
pool,
card_db,
&DefaultSquadRules,
club_id,
req.squad_id.as_deref(),
&replacement,
&ClientReportedEvaluation::default(),
)
.await?;
Ok(out.squad)
}
pub async fn delete_squad(pool: &Pool, club_id: &str, squad_id: &str) -> AppResult<()> {
let deleted = sqlx::query("DELETE FROM squads WHERE id = ? AND club_id = ?")
.bind(squad_id)
.bind(club_id)
.execute(pool)
.await?;
if deleted.rows_affected() == 0 {
return Err(AppError::NotFound(format!("squad '{squad_id}' not found")));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::squad::SlotAssignment;
/// A pool with the real schema, plus two clubs that own one card each.
///
/// Two clubs specifically: the guarantee under test is that a card
/// belonging to somebody else cannot be placed, and that cannot be
/// expressed with one club.
const TS: &str = "2026-01-01T00:00:00Z";
async fn fixture() -> (Pool, CardDb) {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.connect("sqlite::memory:")
.await
.expect("in-memory sqlite");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrations");
for (profile, club) in [("prof-a", "club-a"), ("prof-b", "club-b")] {
sqlx::query(
"INSERT INTO profiles (id, username, created_at, updated_at) VALUES (?, ?, ?, ?)",
)
.bind(profile)
.bind(profile)
.bind(TS)
.bind(TS)
.execute(&pool)
.await
.expect("profile");
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
.bind(club).bind(profile).bind(club).bind(1000i64).bind(TS).bind(TS)
.execute(&pool).await.expect("club");
}
// club-a owns card-1 and card-2; club-b owns card-foreign.
for (id, club) in [
("card-1", "club-a"),
("card-2", "club-a"),
("card-foreign", "club-b"),
] {
sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)")
.bind(id).bind(club).bind("def-1").bind(TS)
.execute(&pool).await.expect("owned card");
}
// An empty card database is enough: none of these guarantees consult it.
(
pool,
CardDb::load("/nonexistent-card-dir").expect("empty card db"),
)
}
fn slot(card: &str, n: i64) -> SlotAssignment {
SlotAssignment {
owned_card_id: card.into(),
slot: n,
is_captain: false,
is_on_bench: false,
}
}
async fn replace(
pool: &Pool,
db: &CardDb,
club: &str,
id: Option<&str>,
slots: Vec<SlotAssignment>,
) -> AppResult<SquadReplaced> {
replace_squad(
pool,
db,
&DefaultSquadRules,
club,
id,
&SquadReplacement {
name: Some("S".into()),
formation: Some("4-4-2".into()),
slots,
},
&ClientReportedEvaluation::default(),
)
.await
}
async fn slots_of(pool: &Pool, squad_id: &str) -> Vec<(String, i64)> {
sqlx::query_as::<_, (String, i64)>(
"SELECT owned_card_id, position_index FROM squad_players WHERE squad_id = ? ORDER BY position_index",
).bind(squad_id).fetch_all(pool).await.unwrap()
}
#[tokio::test]
async fn a_card_owned_by_another_club_cannot_be_placed() {
let (pool, db) = fixture().await;
let err = replace(&pool, &db, "club-a", None, vec![slot("card-foreign", 0)])
.await
.unwrap_err();
// Same message as a missing card: whether it exists elsewhere is not
// this caller's business.
assert!(
matches!(err, AppError::NotFound(ref m) if m.contains("card-foreign")),
"{err:?}"
);
}
#[tokio::test]
async fn the_same_card_cannot_occupy_two_slots() {
let (pool, db) = fixture().await;
let err = replace(
&pool,
&db,
"club-a",
None,
vec![slot("card-1", 0), slot("card-1", 1)],
)
.await
.unwrap_err();
assert!(
matches!(err, AppError::BadRequest(ref m) if m.contains("more than one slot")),
"{err:?}"
);
}
#[tokio::test]
async fn two_cards_cannot_occupy_the_same_slot() {
let (pool, db) = fixture().await;
let err = replace(
&pool,
&db,
"club-a",
None,
vec![slot("card-1", 3), slot("card-2", 3)],
)
.await
.unwrap_err();
assert!(
matches!(err, AppError::BadRequest(ref m) if m.contains("slot 3")),
"{err:?}"
);
}
#[tokio::test]
async fn a_negative_slot_is_refused() {
let (pool, db) = fixture().await;
let err = replace(&pool, &db, "club-a", None, vec![slot("card-1", -1)])
.await
.unwrap_err();
assert!(
matches!(err, AppError::BadRequest(ref m) if m.contains("negative")),
"{err:?}"
);
}
/// The atomicity guarantee, and the reason this operation exists.
///
/// The old implementation deleted every squad player before inserting the
/// new ones, outside a transaction. A replacement rejected part-way through
/// therefore destroyed the squad it failed to replace.
#[tokio::test]
async fn a_rejected_replacement_leaves_the_previous_squad_untouched() {
let (pool, db) = fixture().await;
let first = replace(
&pool,
&db,
"club-a",
None,
vec![slot("card-1", 0), slot("card-2", 1)],
)
.await
.expect("first save");
let before = slots_of(&pool, &first.squad.id).await;
assert_eq!(before.len(), 2);
// Valid card in slot 0, then one owned by another club.
let err = replace(
&pool,
&db,
"club-a",
Some(&first.squad.id),
vec![slot("card-1", 0), slot("card-foreign", 1)],
)
.await
.unwrap_err();
assert!(matches!(err, AppError::NotFound(_)), "{err:?}");
assert_eq!(
slots_of(&pool, &first.squad.id).await,
before,
"a rejected replacement must not disturb the stored squad"
);
}
/// Replacement means replacement: slots present before and absent from the
/// new assignment must be gone, not merged.
#[tokio::test]
async fn replacement_removes_slots_absent_from_the_new_assignment() {
let (pool, db) = fixture().await;
let first = replace(
&pool,
&db,
"club-a",
None,
vec![slot("card-1", 0), slot("card-2", 1)],
)
.await
.expect("first");
let second = replace(
&pool,
&db,
"club-a",
Some(&first.squad.id),
vec![slot("card-2", 5)],
)
.await
.expect("second");
assert_eq!(second.slots_written, 1);
assert_eq!(
slots_of(&pool, &first.squad.id).await,
vec![("card-2".to_string(), 5)]
);
}
#[tokio::test]
async fn a_squad_belonging_to_another_club_cannot_be_replaced() {
let (pool, db) = fixture().await;
let mine = replace(&pool, &db, "club-a", None, vec![slot("card-1", 0)])
.await
.expect("mine");
let err = replace(&pool, &db, "club-b", Some(&mine.squad.id), vec![])
.await
.unwrap_err();
assert!(matches!(err, AppError::NotFound(_)), "{err:?}");
assert_eq!(slots_of(&pool, &mine.squad.id).await.len(), 1);
}
/// The client's numbers must never become the server's.
#[tokio::test]
async fn client_reported_values_are_reported_as_disagreement_not_stored() {
let (pool, db) = fixture().await;
let out = replace_squad(
&pool,
&db,
&DefaultSquadRules,
"club-a",
None,
&SquadReplacement {
name: Some("S".into()),
formation: Some("4-4-2".into()),
slots: vec![slot("card-1", 0)],
},
&ClientReportedEvaluation {
client_reported_chemistry: Some(52),
client_reported_rating: Some(99),
client_reported_star_rating: None,
},
)
.await
.expect("save");
// The card db is empty, so the server derives nothing: 0.
assert_eq!(out.evaluation.chemistry, 0);
assert_eq!(out.evaluation.rating, 0);
// And the disagreement is surfaced rather than reconciled.
let fields: Vec<&str> = out
.client_disagreements
.iter()
.map(|d| d.field.as_str())
.collect();
assert_eq!(
fields,
vec!["chemistry", "rating"],
"{:?}",
out.client_disagreements
);
assert_eq!(out.evaluation.rules, "openfut-default-v2");
}
// ── opaque game-extension (co-located, single-transaction) ──────────────
const NS: &str = "fifa17.squad.v1";
fn ext(payload: &str) -> OpaqueExtensionWrite {
OpaqueExtensionWrite {
namespace: NS.into(),
schema_version: 1,
payload: payload.into(),
}
}
async fn replace_ext(
pool: &Pool,
db: &CardDb,
game: &str,
club: &str,
id: Option<&str>,
slots: Vec<SlotAssignment>,
payload: &str,
) -> AppResult<SquadReplaced> {
replace_squad_with_extension(
pool,
db,
&DefaultSquadRules,
game,
club,
id,
&SquadReplacement {
name: Some("S".into()),
formation: Some("4-4-2".into()),
slots,
},
&ClientReportedEvaluation::default(),
&ext(payload),
)
.await
}
#[tokio::test]
async fn squad_and_extension_commit_atomically_and_read_fresh() {
let (pool, db) = fixture().await;
let out = replace_ext(
&pool,
&db,
"fifa17",
"club-a",
None,
vec![slot("card-1", 0)],
"{\"custom\":[1,2,3]}",
)
.await
.unwrap();
assert!(!out.canonical_fingerprint.is_empty());
let (_s, _p, state) = read_squad_with_ext(&pool, "fifa17", "club-a", NS)
.await
.unwrap();
match state {
SquadExtState::Fresh(row) => {
assert_eq!(row.payload, "{\"custom\":[1,2,3]}");
assert_eq!(row.schema_version, 1);
assert_eq!(row.canonical_fingerprint, out.canonical_fingerprint);
}
_ => panic!("expected Fresh extension"),
}
}
#[tokio::test]
async fn oversized_extension_rejected_with_no_partial_write() {
let (pool, db) = fixture().await;
let big = "x".repeat(crate::models::game_ext::MAX_EXT_PAYLOAD_BYTES + 1);
let err = replace_ext(
&pool,
&db,
"fifa17",
"club-a",
None,
vec![slot("card-1", 0)],
&big,
)
.await;
assert!(
matches!(err, Err(AppError::BadRequest(_))),
"oversized payload must be rejected"
);
// Fail-fast before the tx: no squad was created.
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM squads WHERE club_id = 'club-a'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(n, 0, "rejected replacement leaves no partial squad");
let e: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM game_entity_ext")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(e, 0, "no extension row written");
}
#[tokio::test]
async fn fingerprint_is_deterministic_and_placement_sensitive() {
let (pool, db) = fixture().await;
let a = replace_ext(
&pool,
&db,
"fifa17",
"club-a",
None,
vec![slot("card-1", 0), slot("card-2", 1)],
"p",
)
.await
.unwrap();
// Same placement again → identical fingerprint (idempotent, deterministic).
let b = replace_ext(
&pool,
&db,
"fifa17",
"club-a",
Some(&a.squad.id),
vec![slot("card-1", 0), slot("card-2", 1)],
"p",
)
.await
.unwrap();
assert_eq!(a.canonical_fingerprint, b.canonical_fingerprint);
// Different placement (swap the two slots) → different fingerprint.
let c = replace_ext(
&pool,
&db,
"fifa17",
"club-a",
Some(&a.squad.id),
vec![slot("card-1", 1), slot("card-2", 0)],
"p",
)
.await
.unwrap();
assert_ne!(a.canonical_fingerprint, c.canonical_fingerprint);
}
#[tokio::test]
async fn stale_extension_is_detected_never_silently_fresh() {
let (pool, db) = fixture().await;
let first = replace_ext(
&pool,
&db,
"fifa17",
"club-a",
None,
vec![slot("card-1", 0)],
"p1",
)
.await
.unwrap();
// A later plain replace (no extension) changes the canonical squad.
replace(
&pool,
&db,
"club-a",
Some(&first.squad.id),
vec![slot("card-2", 0)],
)
.await
.unwrap();
let (_s, _p, state) = read_squad_with_ext(&pool, "fifa17", "club-a", NS)
.await
.unwrap();
match state {
SquadExtState::Stale {
stored,
current_fingerprint,
} => {
assert_eq!(stored.canonical_fingerprint, first.canonical_fingerprint);
assert_ne!(current_fingerprint, first.canonical_fingerprint);
}
_ => panic!("expected Stale extension after canonical squad changed"),
}
}
#[tokio::test]
async fn idempotent_repeat_does_not_duplicate_extension() {
let (pool, db) = fixture().await;
let a = replace_ext(
&pool,
&db,
"fifa17",
"club-a",
None,
vec![slot("card-1", 0)],
"same",
)
.await
.unwrap();
replace_ext(
&pool,
&db,
"fifa17",
"club-a",
Some(&a.squad.id),
vec![slot("card-1", 0)],
"same",
)
.await
.unwrap();
let rows: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM game_entity_ext WHERE entity_id = ?")
.bind(&a.squad.id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(rows, 1, "identical repeat converges on one extension row");
}
#[tokio::test]
async fn extension_is_scoped_by_game_and_namespace() {
let (pool, db) = fixture().await;
let out = replace_ext(
&pool,
&db,
"fifa17",
"club-a",
None,
vec![slot("card-1", 0)],
"p",
)
.await
.unwrap();
let sid = &out.squad.id;
assert!(game_ext::get_ext(&pool, "fifa17", "squad", sid, NS)
.await
.unwrap()
.is_some());
assert!(
game_ext::get_ext(&pool, "fifa17", "squad", sid, "other.ns")
.await
.unwrap()
.is_none(),
"wrong namespace"
);
assert!(
game_ext::get_ext(&pool, "fifa23", "squad", sid, NS)
.await
.unwrap()
.is_none(),
"wrong game"
);
}
}