feat(core): one instance-based ownership model for every kind of owned content
CI / Build, lint & test (push) Successful in 3m21s
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.
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
-- Generic owned-content classification on the EXISTING ownership table.
|
||||
--
|
||||
-- Core owns ONE instance-based ownership model for every kind of owned content.
|
||||
-- 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
|
||||
-- `content_kind`. Two copies of one definition remain TWO rows (instance-based
|
||||
-- ownership: `card_id` is the definition, `id` is the instance).
|
||||
--
|
||||
-- `content_kind` is a game-INDEPENDENT vocabulary. Game adapters translate their
|
||||
-- own taxonomy (e.g. FIFA 17 `cardsubtypeid` / resource ranges) into one of these
|
||||
-- tokens before ownership reaches Core; a game's numeric ids NEVER land here.
|
||||
--
|
||||
-- BACKFILL: none needed — every pre-existing row is a player card, which is
|
||||
-- exactly the column DEFAULT, so the ALTER backfills all existing ownership as
|
||||
-- 'player' in place. (Verified against a real populated club snapshot: 1986
|
||||
-- owned rows, all players.)
|
||||
ALTER TABLE owned_cards ADD COLUMN content_kind TEXT NOT NULL DEFAULT 'player'
|
||||
CHECK (content_kind IN (
|
||||
'player', 'manager', 'staff', 'consumable',
|
||||
'kit', 'badge', 'ball', 'stadium', 'misc'
|
||||
));
|
||||
|
||||
-- Optional stack count for content that is owned as an instance CARRYING a
|
||||
-- count rather than as a bare instance.
|
||||
--
|
||||
-- Evidence (real profile, 1995 owned items): consumables are instance-based with
|
||||
-- an OPTIONAL count — some carry a wire `amount` (observed 1,2,4,5,10,15), some
|
||||
-- omit the key entirely, and two copies of one definition exist as two distinct
|
||||
-- instances. So a count is a per-instance ATTRIBUTE, never a replacement for the
|
||||
-- instance: NULL means "not a stack", a positive integer is the stack size.
|
||||
-- Collapsing instances into counts is forbidden by the ownership model above.
|
||||
ALTER TABLE owned_cards ADD COLUMN quantity INTEGER
|
||||
CHECK (quantity IS NULL OR quantity >= 1);
|
||||
|
||||
-- Every club projection reads one kind at a time (players for the squad, kits
|
||||
-- for the club room, consumables for the item list), so the club+kind pair is
|
||||
-- the hot access path.
|
||||
CREATE INDEX IF NOT EXISTS idx_owned_cards_club_kind
|
||||
ON owned_cards(club_id, content_kind);
|
||||
@@ -0,0 +1,55 @@
|
||||
-- Generalise the two-slot kit designation (migration 0024) into the full set of
|
||||
-- active club designations.
|
||||
--
|
||||
-- Ownership still lives ONLY in `owned_cards`; this table records which owned
|
||||
-- INSTANCE currently occupies each club-scoped role. A row here is a pointer,
|
||||
-- never a second ownership authority.
|
||||
--
|
||||
-- Lifecycle invariants enforced by the schema, not by convention:
|
||||
-- * `PRIMARY KEY (club_id, slot)` — at most one active item per role.
|
||||
-- * `owned_card_id ... UNIQUE` — one owned instance can occupy at most ONE
|
||||
-- slot, so "the same card is both the home and the away kit" is unstorable.
|
||||
-- * `REFERENCES owned_cards(id) ON DELETE CASCADE` — quick-selling/consuming
|
||||
-- the item removes the designation, so a sold item can never be projected
|
||||
-- back to the client as active.
|
||||
-- * the BEFORE UPDATE trigger below — a market transfer moves ownership by
|
||||
-- UPDATE (the row id survives), which no FK action can see, so the
|
||||
-- designation is dropped explicitly before the owner changes.
|
||||
--
|
||||
-- `squad_managers` (migration 0023) is squad-scoped, not club-scoped, and is
|
||||
-- deliberately NOT folded in here.
|
||||
CREATE TABLE IF NOT EXISTS club_active_items (
|
||||
club_id TEXT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
|
||||
slot TEXT NOT NULL CHECK (slot IN (
|
||||
'home_kit', 'away_kit', 'badge', 'ball', 'stadium'
|
||||
)),
|
||||
owned_card_id TEXT NOT NULL UNIQUE REFERENCES owned_cards(id) ON DELETE CASCADE,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (club_id, slot)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_active_items_owned
|
||||
ON club_active_items(owned_card_id);
|
||||
|
||||
-- Carry every existing kit designation over: 'home' -> 'home_kit',
|
||||
-- 'away' -> 'away_kit'. No designation is lost and none is invented.
|
||||
INSERT INTO club_active_items (club_id, slot, owned_card_id, updated_at)
|
||||
SELECT club_id,
|
||||
CASE slot WHEN 'home' THEN 'home_kit' ELSE 'away_kit' END,
|
||||
owned_card_id,
|
||||
updated_at
|
||||
FROM club_kit_assignments
|
||||
WHERE slot IN ('home', 'away');
|
||||
|
||||
-- 0024's trigger lives ON owned_cards, so DROP TABLE would NOT remove it and
|
||||
-- every subsequent ownership transfer would fail on a missing table. Drop it
|
||||
-- explicitly first, then replace it with the generalised one.
|
||||
DROP TRIGGER IF EXISTS clear_club_kit_assignment_before_transfer;
|
||||
DROP TABLE club_kit_assignments;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS clear_club_active_item_before_transfer
|
||||
BEFORE UPDATE OF club_id ON owned_cards
|
||||
WHEN OLD.club_id <> NEW.club_id
|
||||
BEGIN
|
||||
DELETE FROM club_active_items WHERE owned_card_id = OLD.id;
|
||||
END;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Durable idempotency for applying a consumable to a target.
|
||||
--
|
||||
-- Same discipline as `match_completions` (migration 0022): ONE effect per
|
||||
-- (profile_id, action_identity). A sequential replay, a restart replay, a
|
||||
-- concurrent duplicate, or a retried HTTP request all collide on this UNIQUE and
|
||||
-- are refused BEFORE the target is mutated and BEFORE the source is consumed —
|
||||
-- so a consumable can never be spent twice, and its effect can never be applied
|
||||
-- twice from one spend.
|
||||
--
|
||||
-- `action_identity` is opaque to Core: the game adapter/host derives a stable
|
||||
-- per-application token from its own wire request. Core never parses it.
|
||||
--
|
||||
-- `source_owned_card_id` / `target_owned_card_id` are deliberately NOT foreign
|
||||
-- keys: the source row is DELETEd (or decremented to zero and deleted) by the
|
||||
-- very transaction that writes this record, and the target may later be sold.
|
||||
-- This table is an audit + replay record, not an ownership reference.
|
||||
--
|
||||
-- `effect` is the caller-supplied outcome summary stored verbatim as JSON text.
|
||||
-- Core defines NO per-category formula: what a given consumable does to its
|
||||
-- target is the calling game adapter's reversed behaviour, and an unreversed
|
||||
-- behaviour must not be invented here.
|
||||
CREATE TABLE consumable_applications (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
profile_id TEXT NOT NULL REFERENCES profiles(id),
|
||||
action_identity TEXT NOT NULL,
|
||||
source_owned_card_id TEXT NOT NULL,
|
||||
source_card_id TEXT NOT NULL,
|
||||
source_content_kind TEXT NOT NULL,
|
||||
-- 1 = the source instance was destroyed; 0 = a stack was decremented.
|
||||
source_consumed INTEGER NOT NULL,
|
||||
-- Remaining stack size after a decrement, NULL when the instance was destroyed.
|
||||
source_quantity_after INTEGER,
|
||||
target_owned_card_id TEXT,
|
||||
effect TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL,
|
||||
UNIQUE(profile_id, action_identity)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_consumable_applications_profile
|
||||
ON consumable_applications(profile_id);
|
||||
+3
-2
@@ -172,8 +172,9 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||
// ClubB: squad manager assignment (append-only; own lines).
|
||||
.route("/club/manager", get(routes::club::get_squad_manager))
|
||||
.route("/club/manager", put(routes::club::put_squad_manager))
|
||||
.route("/club/kits", get(routes::club::get_active_kits))
|
||||
.route("/club/kits", put(routes::club::put_active_kits))
|
||||
// Active club-item designations (home/away kit, badge, ball, stadium).
|
||||
.route("/club/active-items", get(routes::club::get_active_items))
|
||||
.route("/club/active-items", put(routes::club::put_active_item))
|
||||
.route("/cards", get(routes::cards::get_cards))
|
||||
.route("/cards/:card_id", get(routes::cards::get_card))
|
||||
.route("/collection", get(routes::cards::get_collection))
|
||||
|
||||
+245
-1
@@ -54,6 +54,165 @@ impl Quality {
|
||||
}
|
||||
}
|
||||
|
||||
/// What KIND of content one owned instance is.
|
||||
///
|
||||
/// The game-independent ownership vocabulary: Core has exactly one instance-based
|
||||
/// ownership model (`owned_cards`) and this enum is the only thing that
|
||||
/// distinguishes a manager from a player from a chemistry style. It carries NO
|
||||
/// game numerics — a game adapter translates its own taxonomy (FIFA 17
|
||||
/// `cardsubtypeid`, resource ranges, …) into these tokens before ownership
|
||||
/// reaches Core, and translates them back on the way out.
|
||||
///
|
||||
/// The tokens are the persisted values of `owned_cards.content_kind` and are
|
||||
/// pinned by that column's CHECK constraint (migration 0025).
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[sqlx(rename_all = "lowercase")]
|
||||
pub enum ContentKind {
|
||||
/// A playable footballer card.
|
||||
#[default]
|
||||
Player,
|
||||
/// A squad manager.
|
||||
Manager,
|
||||
/// Non-manager club staff (fitness/goalkeeping/… coaches, physios, scouts).
|
||||
Staff,
|
||||
/// A single-use item applied to a target (contract, fitness, healing,
|
||||
/// chemistry style, position modifier, training).
|
||||
Consumable,
|
||||
/// A club kit (occupies the home or away designation).
|
||||
Kit,
|
||||
/// A club badge/crest.
|
||||
Badge,
|
||||
/// A match ball.
|
||||
Ball,
|
||||
/// A club stadium.
|
||||
Stadium,
|
||||
/// Owned content that is legitimately none of the above.
|
||||
Misc,
|
||||
}
|
||||
|
||||
impl ContentKind {
|
||||
/// The canonical persisted token.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ContentKind::Player => "player",
|
||||
ContentKind::Manager => "manager",
|
||||
ContentKind::Staff => "staff",
|
||||
ContentKind::Consumable => "consumable",
|
||||
ContentKind::Kit => "kit",
|
||||
ContentKind::Badge => "badge",
|
||||
ContentKind::Ball => "ball",
|
||||
ContentKind::Stadium => "stadium",
|
||||
ContentKind::Misc => "misc",
|
||||
}
|
||||
}
|
||||
|
||||
/// Every kind, in declaration order (for exhaustive round-trip checks).
|
||||
pub const ALL: [ContentKind; 9] = [
|
||||
ContentKind::Player,
|
||||
ContentKind::Manager,
|
||||
ContentKind::Staff,
|
||||
ContentKind::Consumable,
|
||||
ContentKind::Kit,
|
||||
ContentKind::Badge,
|
||||
ContentKind::Ball,
|
||||
ContentKind::Stadium,
|
||||
ContentKind::Misc,
|
||||
];
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ContentKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ContentKind {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"player" => Ok(ContentKind::Player),
|
||||
"manager" => Ok(ContentKind::Manager),
|
||||
"staff" => Ok(ContentKind::Staff),
|
||||
"consumable" => Ok(ContentKind::Consumable),
|
||||
"kit" => Ok(ContentKind::Kit),
|
||||
"badge" => Ok(ContentKind::Badge),
|
||||
"ball" => Ok(ContentKind::Ball),
|
||||
"stadium" => Ok(ContentKind::Stadium),
|
||||
"misc" => Ok(ContentKind::Misc),
|
||||
other => Err(format!("unknown content kind '{other}'")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A club-scoped "active item" designation slot.
|
||||
///
|
||||
/// One owned instance may occupy at most one slot and each slot holds at most one
|
||||
/// instance (migration 0026 `club_active_items`). Each slot admits exactly one
|
||||
/// [`ContentKind`], so a badge can never be installed as a kit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ActiveSlot {
|
||||
HomeKit,
|
||||
AwayKit,
|
||||
Badge,
|
||||
Ball,
|
||||
Stadium,
|
||||
}
|
||||
|
||||
impl ActiveSlot {
|
||||
/// The canonical persisted token (`club_active_items.slot`).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ActiveSlot::HomeKit => "home_kit",
|
||||
ActiveSlot::AwayKit => "away_kit",
|
||||
ActiveSlot::Badge => "badge",
|
||||
ActiveSlot::Ball => "ball",
|
||||
ActiveSlot::Stadium => "stadium",
|
||||
}
|
||||
}
|
||||
|
||||
/// The one content kind this slot accepts.
|
||||
pub fn required_kind(self) -> ContentKind {
|
||||
match self {
|
||||
ActiveSlot::HomeKit | ActiveSlot::AwayKit => ContentKind::Kit,
|
||||
ActiveSlot::Badge => ContentKind::Badge,
|
||||
ActiveSlot::Ball => ContentKind::Ball,
|
||||
ActiveSlot::Stadium => ContentKind::Stadium,
|
||||
}
|
||||
}
|
||||
|
||||
pub const ALL: [ActiveSlot; 5] = [
|
||||
ActiveSlot::HomeKit,
|
||||
ActiveSlot::AwayKit,
|
||||
ActiveSlot::Badge,
|
||||
ActiveSlot::Ball,
|
||||
ActiveSlot::Stadium,
|
||||
];
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ActiveSlot {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ActiveSlot {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"home_kit" => Ok(ActiveSlot::HomeKit),
|
||||
"away_kit" => Ok(ActiveSlot::AwayKit),
|
||||
"badge" => Ok(ActiveSlot::Badge),
|
||||
"ball" => Ok(ActiveSlot::Ball),
|
||||
"stadium" => Ok(ActiveSlot::Stadium),
|
||||
other => Err(format!("unknown active-item slot '{other}'")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A card definition loaded from JSON data files.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CardDefinition {
|
||||
@@ -74,7 +233,12 @@ pub struct CardDefinition {
|
||||
pub image_path: Option<String>,
|
||||
}
|
||||
|
||||
/// A card instance owned by a club (stored in DB).
|
||||
/// One owned content INSTANCE (stored in DB).
|
||||
///
|
||||
/// Instance-based: `id` is the instance, `card_id` the definition, so two copies
|
||||
/// of one definition are two rows. `content_kind` says what the instance IS;
|
||||
/// `quantity` is an optional per-instance stack size (`None` = not a stack) and
|
||||
/// never a substitute for an instance.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct OwnedCard {
|
||||
pub id: String,
|
||||
@@ -86,4 +250,84 @@ pub struct OwnedCard {
|
||||
pub chemistry_style: String,
|
||||
pub position_override: Option<String>,
|
||||
pub training_bonus: i64,
|
||||
pub content_kind: ContentKind,
|
||||
pub quantity: Option<i64>,
|
||||
}
|
||||
|
||||
/// The ONE canonical column list for reading an [`OwnedCard`].
|
||||
///
|
||||
/// `sqlx::FromRow` needs every field present in the row, so a hand-written
|
||||
/// partial column list decodes into a runtime `ColumnNotFound` rather than a
|
||||
/// compile error. Every read goes through this const so adding a column can
|
||||
/// never leave a stale SELECT behind; append `WHERE …` to it.
|
||||
pub const OWNED_CARD_SELECT: &str = "SELECT id, club_id, card_id, is_loan, \
|
||||
loan_matches_remaining, acquired_at, chemistry_style, position_override, \
|
||||
training_bonus, content_kind, quantity FROM owned_cards";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// The persisted token, the serde token and the parser MUST agree for every
|
||||
/// kind: the DB CHECK, the HTTP body and the adapter all read the same
|
||||
/// vocabulary, so a divergence would silently mis-classify ownership.
|
||||
#[test]
|
||||
fn content_kind_round_trips_token_serde_and_parse() {
|
||||
for kind in ContentKind::ALL {
|
||||
let token = kind.as_str();
|
||||
assert_eq!(
|
||||
serde_json::to_string(&kind).unwrap(),
|
||||
format!("\"{token}\""),
|
||||
"serde token must equal the persisted token"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ContentKind>(&format!("\"{token}\"")).unwrap(),
|
||||
kind
|
||||
);
|
||||
assert_eq!(ContentKind::from_str(token).unwrap(), kind);
|
||||
assert_eq!(kind.to_string(), token);
|
||||
}
|
||||
}
|
||||
|
||||
/// The vocabulary is closed and pinned to migration 0025's CHECK list.
|
||||
#[test]
|
||||
fn content_kind_vocabulary_is_exactly_the_contract() {
|
||||
let tokens: Vec<&str> = ContentKind::ALL.iter().map(|k| k.as_str()).collect();
|
||||
assert_eq!(
|
||||
tokens,
|
||||
vec![
|
||||
"player",
|
||||
"manager",
|
||||
"staff",
|
||||
"consumable",
|
||||
"kit",
|
||||
"badge",
|
||||
"ball",
|
||||
"stadium",
|
||||
"misc"
|
||||
]
|
||||
);
|
||||
assert!(ContentKind::from_str("Player").is_err(), "case-sensitive");
|
||||
assert!(ContentKind::from_str("coach").is_err());
|
||||
assert_eq!(ContentKind::default(), ContentKind::Player);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_slot_round_trips_and_pins_its_required_kind() {
|
||||
for slot in ActiveSlot::ALL {
|
||||
let token = slot.as_str();
|
||||
assert_eq!(ActiveSlot::from_str(token).unwrap(), slot);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&slot).unwrap(),
|
||||
format!("\"{token}\"")
|
||||
);
|
||||
}
|
||||
assert_eq!(ActiveSlot::HomeKit.required_kind(), ContentKind::Kit);
|
||||
assert_eq!(ActiveSlot::AwayKit.required_kind(), ContentKind::Kit);
|
||||
assert_eq!(ActiveSlot::Badge.required_kind(), ContentKind::Badge);
|
||||
assert_eq!(ActiveSlot::Ball.required_kind(), ContentKind::Ball);
|
||||
assert_eq!(ActiveSlot::Stadium.required_kind(), ContentKind::Stadium);
|
||||
assert!(ActiveSlot::from_str("home").is_err(), "0024's old token");
|
||||
}
|
||||
}
|
||||
|
||||
+75
-43
@@ -9,7 +9,7 @@ use serde_json::{json, Value};
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::{AppError, AppResult},
|
||||
models::card::OwnedCard,
|
||||
models::card::{OwnedCard, OWNED_CARD_SELECT},
|
||||
services::{
|
||||
club as club_svc, economy as economy_svc,
|
||||
inventory::{self, OwnedItemQuery, OwnedItemView},
|
||||
@@ -114,45 +114,73 @@ pub async fn get_collection(
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
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 club_id = ?"
|
||||
)
|
||||
.bind(&club.id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
let owned = sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} WHERE club_id = ?"))
|
||||
.bind(&club.id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let views: Vec<OwnedItemView> = owned
|
||||
.iter()
|
||||
.filter_map(|o| {
|
||||
state.card_db.get(&o.card_id).map(|def| {
|
||||
let effective_overall = def.overall as i64 + o.training_bonus;
|
||||
let effective_position = o.position_override.as_deref().unwrap_or(&def.position);
|
||||
let body = json!({
|
||||
"owned_card_id": o.id,
|
||||
"is_loan": o.is_loan,
|
||||
"loan_matches_remaining": o.loan_matches_remaining,
|
||||
"acquired_at": o.acquired_at,
|
||||
"chemistry_style": o.chemistry_style,
|
||||
"position_override": o.position_override,
|
||||
"training_bonus": o.training_bonus,
|
||||
"effective_overall": effective_overall,
|
||||
"effective_position": effective_position,
|
||||
"card": def,
|
||||
});
|
||||
OwnedItemView {
|
||||
owned_card_id: o.id.clone(),
|
||||
base_overall: def.overall,
|
||||
effective_overall,
|
||||
position: effective_position.to_string(),
|
||||
nation: def.nation.clone(),
|
||||
league: def.league.clone(),
|
||||
club: def.club.clone(),
|
||||
body,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// An owned row whose definition is absent from the loaded content CANNOT be
|
||||
// projected (there is nothing to project), but it must never vanish in
|
||||
// silence: that silent `filter_map` drop is how a real club once served
|
||||
// `total: 0` while 1986 owned rows sat in the DB. So: keep the drop (a
|
||||
// missing definition is not a 500), but LOG each one and report the count in
|
||||
// the envelope so a caller and an operator both see it.
|
||||
let mut unresolved: Vec<&str> = Vec::new();
|
||||
let mut views: Vec<OwnedItemView> = Vec::with_capacity(owned.len());
|
||||
for o in &owned {
|
||||
let Some(def) = state.card_db.get(&o.card_id) else {
|
||||
tracing::warn!(
|
||||
owned_card_id = %o.id,
|
||||
card_id = %o.card_id,
|
||||
content_kind = %o.content_kind,
|
||||
club_id = %club.id,
|
||||
"owned item dropped from /collection: no card definition loaded"
|
||||
);
|
||||
unresolved.push(o.card_id.as_str());
|
||||
continue;
|
||||
};
|
||||
let effective_overall = def.overall as i64 + o.training_bonus;
|
||||
let effective_position = o.position_override.as_deref().unwrap_or(&def.position);
|
||||
let body = json!({
|
||||
"owned_card_id": o.id,
|
||||
"content_kind": o.content_kind,
|
||||
"quantity": o.quantity,
|
||||
"is_loan": o.is_loan,
|
||||
"loan_matches_remaining": o.loan_matches_remaining,
|
||||
"acquired_at": o.acquired_at,
|
||||
"chemistry_style": o.chemistry_style,
|
||||
"position_override": o.position_override,
|
||||
"training_bonus": o.training_bonus,
|
||||
"effective_overall": effective_overall,
|
||||
"effective_position": effective_position,
|
||||
"card": def,
|
||||
});
|
||||
views.push(OwnedItemView {
|
||||
owned_card_id: o.id.clone(),
|
||||
content_kind: o.content_kind,
|
||||
base_overall: def.overall,
|
||||
effective_overall,
|
||||
position: effective_position.to_string(),
|
||||
nation: def.nation.clone(),
|
||||
league: def.league.clone(),
|
||||
club: def.club.clone(),
|
||||
body,
|
||||
});
|
||||
}
|
||||
if !unresolved.is_empty() {
|
||||
unresolved.sort_unstable();
|
||||
unresolved.dedup();
|
||||
tracing::warn!(
|
||||
club_id = %club.id,
|
||||
owned_rows = owned.len(),
|
||||
dropped = owned.len() - views.len(),
|
||||
definitions = ?unresolved,
|
||||
"/collection dropped owned items with missing definitions"
|
||||
);
|
||||
}
|
||||
|
||||
let owned_rows = owned.len();
|
||||
let unresolved_items = owned_rows - views.len();
|
||||
let page = inventory::apply_query(views, &query);
|
||||
let returned = page.items.len();
|
||||
Ok(Json(json!({
|
||||
@@ -161,6 +189,12 @@ pub async fn get_collection(
|
||||
"returned": returned,
|
||||
"offset": page.offset,
|
||||
"limit": page.limit,
|
||||
// Ownership truth vs. what could be projected. `owned_rows` counts every
|
||||
// row Core actually owns for this club; `unresolved_items` counts those
|
||||
// dropped for want of a definition. Both zero-cost when nothing is wrong.
|
||||
"owned_rows": owned_rows,
|
||||
"unresolved_items": unresolved_items,
|
||||
"unresolved_definitions": unresolved,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -173,11 +207,9 @@ pub async fn delete_owned_card(
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
|
||||
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 = ?",
|
||||
)
|
||||
let owned = sqlx::query_as::<_, OwnedCard>(&format!(
|
||||
"{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?"
|
||||
))
|
||||
.bind(&owned_card_id)
|
||||
.bind(&club.id)
|
||||
.fetch_optional(&state.pool)
|
||||
|
||||
+45
-22
@@ -1,8 +1,8 @@
|
||||
use crate::extractors::GameId;
|
||||
use crate::{
|
||||
app::AppState,
|
||||
error::AppResult,
|
||||
models::club::Club,
|
||||
error::{AppError, AppResult},
|
||||
models::{card::ActiveSlot, club::Club},
|
||||
services::{
|
||||
checkin as checkin_svc, club as club_svc, profile as profile_svc, statistics as stats_svc,
|
||||
},
|
||||
@@ -10,6 +10,7 @@ use crate::{
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::str::FromStr;
|
||||
|
||||
pub async fn get_club(State(state): State<AppState>, game: GameId) -> AppResult<Json<Club>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
@@ -164,39 +165,61 @@ pub async fn put_squad_manager(
|
||||
Ok(Json(json!({ "manager": manager })))
|
||||
}
|
||||
|
||||
/// Return the club's ownership-backed active home/away kit assignments.
|
||||
pub async fn get_active_kits(
|
||||
/// Every active club-item designation, slot-keyed and EXPLICIT: all five slots
|
||||
/// are always present, an empty slot being `null`. A caller therefore never has
|
||||
/// to guess whether a missing key means "no item" or "unsupported slot".
|
||||
fn active_items_body(items: &club_svc::ActiveClubItems) -> AppResult<Value> {
|
||||
let mut body = serde_json::Map::new();
|
||||
for slot in ActiveSlot::ALL {
|
||||
body.insert(
|
||||
slot.as_str().to_string(),
|
||||
serde_json::to_value(items.get(slot))?,
|
||||
);
|
||||
}
|
||||
Ok(Value::Object(body))
|
||||
}
|
||||
|
||||
/// Return the club's ownership-backed active item designations.
|
||||
pub async fn get_active_items(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let kits = club_svc::get_active_club_kits(&state.pool, &club.id).await?;
|
||||
Ok(Json(json!({ "home": kits.home, "away": kits.away })))
|
||||
let items = club_svc::get_active_club_items(&state.pool, &club.id).await?;
|
||||
Ok(Json(json!({ "active_items": active_items_body(&items)? })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetActiveKitsRequest {
|
||||
pub home_owned_card_id: Option<String>,
|
||||
pub away_owned_card_id: Option<String>,
|
||||
pub struct SetActiveItemRequest {
|
||||
/// Which club role to write: home_kit | away_kit | badge | ball | stadium.
|
||||
///
|
||||
/// Taken as a string and parsed here so an unknown slot comes back as this
|
||||
/// crate's `400 {"error": …}` envelope, like every other bad request, rather
|
||||
/// than axum's plain-text deserialization rejection.
|
||||
pub slot: String,
|
||||
/// The owned instance to designate, or `null`/absent to clear the slot.
|
||||
#[serde(default)]
|
||||
pub owned_card_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Atomically replace both active kit assignments. Core enforces ownership and
|
||||
/// distinct instances; game adapters enforce their own definition taxonomy.
|
||||
pub async fn put_active_kits(
|
||||
/// Write ONE active club-item designation. Core enforces ownership and that the
|
||||
/// slot admits the item's `content_kind`; game adapters own their own mapping
|
||||
/// from a wire item onto that generic kind.
|
||||
pub async fn put_active_item(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<SetActiveKitsRequest>,
|
||||
Json(req): Json<SetActiveItemRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let slot = ActiveSlot::from_str(&req.slot).map_err(AppError::BadRequest)?;
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
club_svc::set_active_club_kits(
|
||||
&state.pool,
|
||||
&club.id,
|
||||
req.home_owned_card_id.as_deref(),
|
||||
req.away_owned_card_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let kits = club_svc::get_active_club_kits(&state.pool, &club.id).await?;
|
||||
Ok(Json(json!({ "home": kits.home, "away": kits.away })))
|
||||
match req.owned_card_id {
|
||||
Some(owned_card_id) => {
|
||||
club_svc::set_active_club_item(&state.pool, &club.id, slot, &owned_card_id).await?
|
||||
}
|
||||
None => club_svc::clear_active_club_item(&state.pool, &club.id, slot).await?,
|
||||
}
|
||||
let items = club_svc::get_active_club_items(&state.pool, &club.id).await?;
|
||||
Ok(Json(json!({ "active_items": active_items_body(&items)? })))
|
||||
}
|
||||
|
||||
+297
-136
@@ -1,7 +1,10 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::{AppError, AppResult},
|
||||
models::{card::OwnedCard, club::Club},
|
||||
models::{
|
||||
card::{ActiveSlot, ContentKind, OwnedCard, OWNED_CARD_SELECT},
|
||||
club::Club,
|
||||
},
|
||||
};
|
||||
use chrono::Utc;
|
||||
|
||||
@@ -134,9 +137,6 @@ pub async fn spend_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i
|
||||
// durably and re-validates ownership on read; the FIFA 17 adapter owns the wire
|
||||
// meaning of "manager" (itemType/contract/chemistry), never Core.
|
||||
|
||||
const OWNED_SELECT: &str = "SELECT id, club_id, card_id, is_loan, loan_matches_remaining, \
|
||||
acquired_at, chemistry_style, position_override, training_bonus FROM owned_cards";
|
||||
|
||||
/// The club's most-recently-updated squad id (its "active" squad), matching the
|
||||
/// selection `squad::get_squad` uses, or `None` when the club has no squad yet.
|
||||
pub async fn active_squad_id(pool: &Pool, club_id: &str) -> AppResult<Option<String>> {
|
||||
@@ -166,7 +166,8 @@ pub async fn get_squad_manager_for_squad(
|
||||
club_id: &str,
|
||||
) -> AppResult<Option<OwnedCard>> {
|
||||
Ok(sqlx::query_as::<_, OwnedCard>(&format!(
|
||||
"{OWNED_SELECT} WHERE id = (SELECT owned_card_id FROM squad_managers WHERE squad_id = ?) \
|
||||
"{OWNED_CARD_SELECT} \
|
||||
WHERE id = (SELECT owned_card_id FROM squad_managers WHERE squad_id = ?) \
|
||||
AND club_id = ?"
|
||||
))
|
||||
.bind(squad_id)
|
||||
@@ -240,94 +241,137 @@ pub async fn clear_squad_manager(pool: &Pool, club_id: &str) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ───────────────────────── active club kits ────────────────────────────────
|
||||
// ─────────────────────── active club item designations ──────────────────────
|
||||
//
|
||||
// Generic, ownership-backed club state (migration 0026 `club_active_items`):
|
||||
// which owned INSTANCE currently occupies each club-scoped role (home/away kit,
|
||||
// badge, ball, stadium). Ownership itself never lives here — a designation is a
|
||||
// pointer into `owned_cards`, revalidated against current ownership on every
|
||||
// read, so a stale row can never project an item the club does not own.
|
||||
//
|
||||
// Core enforces the generic invariants (ownership, one instance per slot, slot
|
||||
// admits exactly one `ContentKind`); a game adapter maps its own taxonomy onto
|
||||
// `ContentKind` before it gets here.
|
||||
|
||||
/// The ownership-backed home and away kit assignments for one club.
|
||||
/// Every active club-item designation, keyed by slot.
|
||||
///
|
||||
/// Slots with no designation are simply absent. Held as a `Vec` rather than a
|
||||
/// map so the projection order is the canonical [`ActiveSlot::ALL`] order.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ActiveClubKits {
|
||||
pub home: Option<OwnedCard>,
|
||||
pub away: Option<OwnedCard>,
|
||||
pub struct ActiveClubItems {
|
||||
pub items: Vec<(ActiveSlot, OwnedCard)>,
|
||||
}
|
||||
|
||||
async fn get_club_kit_slot(pool: &Pool, club_id: &str, slot: &str) -> AppResult<Option<OwnedCard>> {
|
||||
impl ActiveClubItems {
|
||||
/// The owned instance occupying `slot`, if any.
|
||||
pub fn get(&self, slot: ActiveSlot) -> Option<&OwnedCard> {
|
||||
self.items
|
||||
.iter()
|
||||
.find(|(s, _)| *s == slot)
|
||||
.map(|(_, card)| card)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one slot's designation, revalidated against current club ownership.
|
||||
async fn get_active_club_item(
|
||||
pool: &Pool,
|
||||
club_id: &str,
|
||||
slot: ActiveSlot,
|
||||
) -> AppResult<Option<OwnedCard>> {
|
||||
Ok(sqlx::query_as::<_, OwnedCard>(&format!(
|
||||
"{OWNED_SELECT} WHERE id = ( \
|
||||
SELECT owned_card_id FROM club_kit_assignments WHERE club_id = ? AND slot = ? \
|
||||
"{OWNED_CARD_SELECT} WHERE id = ( \
|
||||
SELECT owned_card_id FROM club_active_items WHERE club_id = ? AND slot = ? \
|
||||
) AND club_id = ?"
|
||||
))
|
||||
.bind(club_id)
|
||||
.bind(slot)
|
||||
.bind(slot.as_str())
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Read both active kit roles. Each assignment is revalidated against current
|
||||
/// Read every active club-item designation. Each is revalidated against current
|
||||
/// ownership, so a stale/corrupt row never surfaces another club's item.
|
||||
pub async fn get_active_club_kits(pool: &Pool, club_id: &str) -> AppResult<ActiveClubKits> {
|
||||
Ok(ActiveClubKits {
|
||||
home: get_club_kit_slot(pool, club_id, "home").await?,
|
||||
away: get_club_kit_slot(pool, club_id, "away").await?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Atomically replace both active kit roles. Core enforces generic ownership and
|
||||
/// distinct-instance invariants; the game adapter validates that each definition
|
||||
/// is a kit before asking Core to assign it.
|
||||
pub async fn set_active_club_kits(
|
||||
pool: &Pool,
|
||||
club_id: &str,
|
||||
home_owned_card_id: Option<&str>,
|
||||
away_owned_card_id: Option<&str>,
|
||||
) -> AppResult<()> {
|
||||
if home_owned_card_id.is_some() && home_owned_card_id == away_owned_card_id {
|
||||
return Err(AppError::BadRequest(
|
||||
"home and away kits must be different owned items".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
for owned_card_id in [home_owned_card_id, away_owned_card_id]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let owned = sqlx::query_scalar::<_, String>(
|
||||
"SELECT id FROM owned_cards WHERE id = ? AND club_id = ?",
|
||||
)
|
||||
.bind(owned_card_id)
|
||||
.bind(club_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if owned.is_none() {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"owned card '{owned_card_id}' not found"
|
||||
)));
|
||||
pub async fn get_active_club_items(pool: &Pool, club_id: &str) -> AppResult<ActiveClubItems> {
|
||||
let mut items = Vec::new();
|
||||
for slot in ActiveSlot::ALL {
|
||||
if let Some(card) = get_active_club_item(pool, club_id, slot).await? {
|
||||
items.push((slot, card));
|
||||
}
|
||||
}
|
||||
Ok(ActiveClubItems { items })
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM club_kit_assignments WHERE club_id = ?")
|
||||
.bind(club_id)
|
||||
/// Designate `owned_card_id` as `club_id`'s active item for `slot`, replacing any
|
||||
/// existing designation for that slot.
|
||||
///
|
||||
/// Fail-closed, in one transaction:
|
||||
/// * the instance MUST be owned by `club_id` (so a client cannot install
|
||||
/// another club's item, nor an id that does not exist);
|
||||
/// * its `content_kind` MUST be the kind the slot admits (a badge in
|
||||
/// `home_kit` is rejected, not silently accepted);
|
||||
/// * an instance already designated for a DIFFERENT slot is released first, so
|
||||
/// the `owned_card_id UNIQUE` invariant is upheld by an explicit move rather
|
||||
/// than a constraint error.
|
||||
pub async fn set_active_club_item(
|
||||
pool: &Pool,
|
||||
club_id: &str,
|
||||
slot: ActiveSlot,
|
||||
owned_card_id: &str,
|
||||
) -> AppResult<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let owned = sqlx::query_as::<_, (String, ContentKind)>(
|
||||
"SELECT id, content_kind FROM owned_cards WHERE id = ? AND club_id = ?",
|
||||
)
|
||||
.bind(owned_card_id)
|
||||
.bind(club_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some((_, kind)) = owned else {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"owned card '{owned_card_id}' not found"
|
||||
)));
|
||||
};
|
||||
let required = slot.required_kind();
|
||||
if kind != required {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"slot '{slot}' requires content kind '{required}', but owned card \
|
||||
'{owned_card_id}' is '{kind}'"
|
||||
)));
|
||||
}
|
||||
|
||||
// Release this instance from any other slot, then take the target slot.
|
||||
sqlx::query("DELETE FROM club_active_items WHERE owned_card_id = ?")
|
||||
.bind(owned_card_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
for (slot, owned_card_id) in [("home", home_owned_card_id), ("away", away_owned_card_id)] {
|
||||
if let Some(owned_card_id) = owned_card_id {
|
||||
sqlx::query(
|
||||
"INSERT INTO club_kit_assignments \
|
||||
(club_id, slot, owned_card_id, updated_at) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(club_id)
|
||||
.bind(slot)
|
||||
.bind(owned_card_id)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO club_active_items \
|
||||
(club_id, slot, owned_card_id, updated_at) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(club_id)
|
||||
.bind(slot.as_str())
|
||||
.bind(owned_card_id)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear `club_id`'s designation for `slot` (idempotent — an already-empty slot
|
||||
/// is a successful no-op).
|
||||
pub async fn clear_active_club_item(pool: &Pool, club_id: &str, slot: ActiveSlot) -> AppResult<()> {
|
||||
sqlx::query("DELETE FROM club_active_items WHERE club_id = ? AND slot = ?")
|
||||
.bind(club_id)
|
||||
.bind(slot.as_str())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -357,18 +401,30 @@ mod tests {
|
||||
.bind(club).bind(profile).bind(club).bind(1000i64).bind(TS).bind(TS)
|
||||
.execute(&pool).await.expect("club");
|
||||
}
|
||||
for (id, club, definition) in [
|
||||
("mgr", "club-a", "def-mgr"),
|
||||
("mgr2", "club-a", "def-mgr"),
|
||||
("player", "club-a", "def-player"),
|
||||
("kit-home", "club-a", "def-kit-home"),
|
||||
("kit-away", "club-a", "def-kit-away"),
|
||||
("kit-away-2", "club-a", "def-kit-away-2"),
|
||||
("foreign", "club-b", "def-kit-foreign"),
|
||||
for (id, club, definition, kind) in [
|
||||
("mgr", "club-a", "def-mgr", ContentKind::Manager),
|
||||
("mgr2", "club-a", "def-mgr", ContentKind::Manager),
|
||||
("player", "club-a", "def-player", ContentKind::Player),
|
||||
("kit-home", "club-a", "def-kit-home", ContentKind::Kit),
|
||||
("kit-away", "club-a", "def-kit-away", ContentKind::Kit),
|
||||
("kit-away-2", "club-a", "def-kit-away-2", ContentKind::Kit),
|
||||
("badge", "club-a", "def-badge", ContentKind::Badge),
|
||||
("ball", "club-a", "def-ball", ContentKind::Ball),
|
||||
("stadium", "club-a", "def-stadium", ContentKind::Stadium),
|
||||
("foreign", "club-b", "def-kit-foreign", ContentKind::Kit),
|
||||
] {
|
||||
sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)")
|
||||
.bind(id).bind(club).bind(definition).bind(TS)
|
||||
.execute(&pool).await.expect("owned card");
|
||||
sqlx::query(
|
||||
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at, content_kind) \
|
||||
VALUES (?, ?, ?, 0, ?, ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(club)
|
||||
.bind(definition)
|
||||
.bind(TS)
|
||||
.bind(kind.as_str())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("owned card");
|
||||
}
|
||||
// club-a has one squad.
|
||||
sqlx::query("INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES ('sq-a', 'club-a', 'S', '4-4-2', ?, ?)")
|
||||
@@ -383,8 +439,8 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn kit_rows(pool: &db::Pool) -> i64 {
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM club_kit_assignments")
|
||||
async fn active_item_rows(pool: &db::Pool) -> i64 {
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM club_active_items")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -463,76 +519,160 @@ mod tests {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
assert!(get_squad_manager(&pool, "club-a").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
// ── active club item designations ──
|
||||
|
||||
#[tokio::test]
|
||||
async fn kits_persist_across_reload_and_restart() {
|
||||
async fn active_items_persist_across_reload_and_restart() {
|
||||
let (dir, url, pool) = fixture().await;
|
||||
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away"))
|
||||
.await
|
||||
.expect("assign kits");
|
||||
let current = get_active_club_kits(&pool, "club-a").await.unwrap();
|
||||
for (slot, id) in [
|
||||
(ActiveSlot::HomeKit, "kit-home"),
|
||||
(ActiveSlot::AwayKit, "kit-away"),
|
||||
(ActiveSlot::Badge, "badge"),
|
||||
(ActiveSlot::Ball, "ball"),
|
||||
(ActiveSlot::Stadium, "stadium"),
|
||||
] {
|
||||
set_active_club_item(&pool, "club-a", slot, id)
|
||||
.await
|
||||
.expect("designate");
|
||||
}
|
||||
let current = get_active_club_items(&pool, "club-a").await.unwrap();
|
||||
assert_eq!(current.items.len(), 5, "every slot filled");
|
||||
// Projection order is the canonical slot order, not DB insertion order.
|
||||
assert_eq!(
|
||||
current.home.as_ref().map(|item| item.id.as_str()),
|
||||
Some("kit-home")
|
||||
);
|
||||
assert_eq!(
|
||||
current.away.as_ref().map(|item| item.id.as_str()),
|
||||
Some("kit-away")
|
||||
current.items.iter().map(|(s, _)| *s).collect::<Vec<_>>(),
|
||||
ActiveSlot::ALL.to_vec()
|
||||
);
|
||||
|
||||
pool.close().await;
|
||||
let reopened = db::init_pool(&url, 5).await.expect("reopen");
|
||||
db::run_migrations(&reopened).await.expect("migrations");
|
||||
let persisted = get_active_club_kits(&reopened, "club-a").await.unwrap();
|
||||
assert_eq!(persisted.home.map(|item| item.id), Some("kit-home".into()));
|
||||
assert_eq!(persisted.away.map(|item| item.id), Some("kit-away".into()));
|
||||
let persisted = get_active_club_items(&reopened, "club-a").await.unwrap();
|
||||
assert_eq!(
|
||||
persisted.get(ActiveSlot::Stadium).map(|c| c.id.as_str()),
|
||||
Some("stadium"),
|
||||
"designations must survive a server restart"
|
||||
);
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kits_replace_clear_and_never_duplicate() {
|
||||
async fn active_item_replace_and_clear_never_duplicate() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away"))
|
||||
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "kit-away")
|
||||
.await
|
||||
.unwrap();
|
||||
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away-2"))
|
||||
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "kit-away-2")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(kit_rows(&pool).await, 2);
|
||||
let current = get_active_club_kits(&pool, "club-a").await.unwrap();
|
||||
assert_eq!(current.away.map(|item| item.id), Some("kit-away-2".into()));
|
||||
|
||||
set_active_club_kits(&pool, "club-a", None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(kit_rows(&pool).await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kits_reject_invalid_references_atomically() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(set_active_club_kits(&pool, "club-a", Some("foreign"), None)
|
||||
.await
|
||||
.is_err());
|
||||
assert!(
|
||||
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-home"))
|
||||
.await
|
||||
.is_err()
|
||||
assert_eq!(active_item_rows(&pool).await, 1, "one item per slot");
|
||||
let current = get_active_club_items(&pool, "club-a").await.unwrap();
|
||||
assert_eq!(
|
||||
current.get(ActiveSlot::AwayKit).map(|c| c.id.as_str()),
|
||||
Some("kit-away-2")
|
||||
);
|
||||
|
||||
let unchanged = get_active_club_kits(&pool, "club-a").await.unwrap();
|
||||
assert_eq!(unchanged.home.map(|item| item.id), Some("kit-home".into()));
|
||||
assert_eq!(unchanged.away.map(|item| item.id), Some("kit-away".into()));
|
||||
assert_eq!(kit_rows(&pool).await, 2);
|
||||
clear_active_club_item(&pool, "club-a", ActiveSlot::AwayKit)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(active_item_rows(&pool).await, 0);
|
||||
// Clearing an empty slot is an idempotent no-op.
|
||||
clear_active_club_item(&pool, "club-a", ActiveSlot::AwayKit)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kit_delete_and_transfer_clear_active_designations() {
|
||||
async fn active_item_rejects_unowned_card_and_leaves_state_intact() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away"))
|
||||
set_active_club_item(&pool, "club-a", ActiveSlot::HomeKit, "kit-home")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "foreign")
|
||||
.await
|
||||
.is_err(),
|
||||
"another club's item cannot be designated"
|
||||
);
|
||||
assert!(
|
||||
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "nope")
|
||||
.await
|
||||
.is_err(),
|
||||
"a non-existent instance cannot be designated"
|
||||
);
|
||||
|
||||
let unchanged = get_active_club_items(&pool, "club-a").await.unwrap();
|
||||
assert_eq!(
|
||||
unchanged.get(ActiveSlot::HomeKit).map(|c| c.id.as_str()),
|
||||
Some("kit-home")
|
||||
);
|
||||
assert_eq!(active_item_rows(&pool).await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn active_item_rejects_slot_kind_mismatch() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
// A badge is not a kit; a player is not a stadium.
|
||||
for (slot, id) in [
|
||||
(ActiveSlot::HomeKit, "badge"),
|
||||
(ActiveSlot::Stadium, "player"),
|
||||
(ActiveSlot::Ball, "kit-home"),
|
||||
] {
|
||||
let err = set_active_club_item(&pool, "club-a", slot, id)
|
||||
.await
|
||||
.expect_err("slot/kind mismatch must be refused");
|
||||
assert!(
|
||||
matches!(err, AppError::BadRequest(_)),
|
||||
"expected a bad-request, got {err:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(active_item_rows(&pool).await, 0);
|
||||
}
|
||||
|
||||
/// Lifecycle invariant: one owned instance can occupy at most ONE slot.
|
||||
/// Re-designating it moves it rather than duplicating it.
|
||||
#[tokio::test]
|
||||
async fn one_instance_cannot_occupy_two_slots() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
set_active_club_item(&pool, "club-a", ActiveSlot::HomeKit, "kit-home")
|
||||
.await
|
||||
.unwrap();
|
||||
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "kit-home")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(active_item_rows(&pool).await, 1);
|
||||
let current = get_active_club_items(&pool, "club-a").await.unwrap();
|
||||
assert!(current.get(ActiveSlot::HomeKit).is_none());
|
||||
assert_eq!(
|
||||
current.get(ActiveSlot::AwayKit).map(|c| c.id.as_str()),
|
||||
Some("kit-home")
|
||||
);
|
||||
|
||||
// The schema itself refuses the impossible state, not just the service.
|
||||
let raw = sqlx::query(
|
||||
"INSERT INTO club_active_items (club_id, slot, owned_card_id, updated_at) \
|
||||
VALUES ('club-a', 'home_kit', 'kit-home', ?)",
|
||||
)
|
||||
.bind(TS)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
assert!(
|
||||
raw.is_err(),
|
||||
"owned_card_id UNIQUE must reject a second slot"
|
||||
);
|
||||
}
|
||||
|
||||
/// Lifecycle invariant: a designation can never point at an item the club
|
||||
/// does not own — neither after a quick sell (DELETE) nor after a transfer
|
||||
/// (UPDATE of club_id, which no FK action can observe).
|
||||
#[tokio::test]
|
||||
async fn delete_and_transfer_clear_active_designations() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
set_active_club_item(&pool, "club-a", ActiveSlot::HomeKit, "kit-home")
|
||||
.await
|
||||
.unwrap();
|
||||
set_active_club_item(&pool, "club-a", ActiveSlot::AwayKit, "kit-away")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -540,19 +680,40 @@ mod tests {
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("quick sell kit");
|
||||
let after_delete = get_active_club_kits(&pool, "club-a").await.unwrap();
|
||||
assert!(after_delete.home.is_none());
|
||||
let after_delete = get_active_club_items(&pool, "club-a").await.unwrap();
|
||||
assert!(after_delete.get(ActiveSlot::HomeKit).is_none());
|
||||
assert_eq!(
|
||||
after_delete.away.map(|item| item.id),
|
||||
Some("kit-away".into())
|
||||
after_delete.get(ActiveSlot::AwayKit).map(|c| c.id.as_str()),
|
||||
Some("kit-away")
|
||||
);
|
||||
assert_eq!(active_item_rows(&pool).await, 1);
|
||||
|
||||
sqlx::query("UPDATE owned_cards SET club_id = 'club-b' WHERE id = 'kit-away'")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("transfer kit");
|
||||
assert_eq!(kit_rows(&pool).await, 0);
|
||||
let after_transfer = get_active_club_kits(&pool, "club-a").await.unwrap();
|
||||
assert!(after_transfer.home.is_none() && after_transfer.away.is_none());
|
||||
assert_eq!(active_item_rows(&pool).await, 0, "transfer clears the slot");
|
||||
let after_transfer = get_active_club_items(&pool, "club-a").await.unwrap();
|
||||
assert!(after_transfer.items.is_empty());
|
||||
}
|
||||
|
||||
/// A designation whose owned row is forced out of the club WITHOUT the
|
||||
/// trigger firing (raw row surgery mimicking corruption) must still never
|
||||
/// project: reads revalidate ownership.
|
||||
#[tokio::test]
|
||||
async fn read_revalidates_ownership_of_a_stale_designation() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
set_active_club_item(&pool, "club-a", ActiveSlot::Badge, "badge")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE club_active_items SET owned_card_id = 'foreign' WHERE slot = 'badge'")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("corrupt the designation");
|
||||
let items = get_active_club_items(&pool, "club-a").await.unwrap();
|
||||
assert!(
|
||||
items.get(ActiveSlot::Badge).is_none(),
|
||||
"another club's item must never be projected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+29
-2
@@ -20,6 +20,7 @@
|
||||
//! - The whole thing commits together or not at all.
|
||||
|
||||
use crate::db::Pool;
|
||||
use crate::models::card::ContentKind;
|
||||
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;
|
||||
@@ -49,6 +50,15 @@ pub struct ImportOwnedCard {
|
||||
pub owned_item_id: String,
|
||||
/// CardDefinitionId that MUST resolve in loaded production content.
|
||||
pub card_id: String,
|
||||
/// Generic content classification. Absent = `player`, which is what every
|
||||
/// pre-taxonomy import produced; the adapter maps its own taxonomy (FIFA 17
|
||||
/// `cardsubtypeid`, resource ranges, …) onto this before calling Core.
|
||||
#[serde(default)]
|
||||
pub content_kind: ContentKind,
|
||||
/// Optional per-instance stack size (a consumable's wire `amount`). Absent /
|
||||
/// `null` means "not a stack"; it never collapses two instances into one row.
|
||||
#[serde(default)]
|
||||
pub quantity: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -127,6 +137,19 @@ pub async fn apply_profile_import(
|
||||
if req.owned.is_empty() {
|
||||
bail!("import request has zero owned cards; refusing to import an empty profile");
|
||||
}
|
||||
// A stack size is either absent ("not a stack") or a real positive count.
|
||||
// Reject an explicit 0/negative up front rather than letting the column
|
||||
// CHECK surface it as an opaque constraint failure mid-transaction.
|
||||
for o in &req.owned {
|
||||
if let Some(q) = o.quantity {
|
||||
if q < 1 {
|
||||
bail!(
|
||||
"owned card {} has quantity {q}; a stack size must be omitted or >= 1",
|
||||
o.owned_item_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1. rerun identity / single-profile-per-game ──
|
||||
let existing: Option<(String, Option<String>)> = sqlx::query_as(
|
||||
@@ -242,13 +265,17 @@ pub async fn apply_profile_import(
|
||||
|
||||
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, ?)",
|
||||
"INSERT INTO owned_cards \
|
||||
(id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
|
||||
content_kind, quantity) \
|
||||
VALUES (?, ?, ?, 0, NULL, ?, ?, ?)",
|
||||
)
|
||||
.bind(&o.owned_item_id)
|
||||
.bind(&club_id)
|
||||
.bind(&o.card_id)
|
||||
.bind(&now)
|
||||
.bind(o.content_kind.as_str())
|
||||
.bind(o.quantity)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.with_context(|| format!("insert owned_card {}", o.owned_item_id))?;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::models::card::Quality;
|
||||
use crate::models::card::{ContentKind, Quality};
|
||||
|
||||
/// Semantic owned-inventory query. All values are game-independent: a quality
|
||||
/// tier, entity **names** (not ids), and semantic offset/limit. Every filter is
|
||||
@@ -25,6 +25,9 @@ pub struct OwnedItemQuery {
|
||||
/// Quality tier (gold/silver/bronze). Serialized lowercase.
|
||||
#[serde(default)]
|
||||
pub quality: Option<Quality>,
|
||||
/// Owned-content kind (player/consumable/kit/…). Serialized lowercase.
|
||||
#[serde(default)]
|
||||
pub content_kind: Option<ContentKind>,
|
||||
/// Playing position, e.g. "ST" (matched case-insensitively).
|
||||
#[serde(default)]
|
||||
pub position: Option<String>,
|
||||
@@ -47,8 +50,12 @@ pub struct OwnedItemQuery {
|
||||
|
||||
/// One owned item projected to the attributes needed for querying, plus the
|
||||
/// response body to hand back verbatim once it survives the filter+page.
|
||||
#[derive(Clone)]
|
||||
pub struct OwnedItemView {
|
||||
pub owned_card_id: String,
|
||||
/// What kind of content this instance is; lets a caller filter without
|
||||
/// re-deriving the taxonomy from definition fields.
|
||||
pub content_kind: ContentKind,
|
||||
/// Base card overall (drives quality tier).
|
||||
pub base_overall: u8,
|
||||
/// Effective overall (base + training bonus); drives ordering.
|
||||
@@ -78,6 +85,10 @@ pub struct QueryPage {
|
||||
/// Does an item satisfy every present filter (AND semantics)?
|
||||
fn matches(item: &OwnedItemView, q: &OwnedItemQuery) -> bool {
|
||||
let quality_ok = q.quality.map(|want| item.quality() == want).unwrap_or(true);
|
||||
let kind_ok = q
|
||||
.content_kind
|
||||
.map(|want| item.content_kind == want)
|
||||
.unwrap_or(true);
|
||||
let pos_ok = q
|
||||
.position
|
||||
.as_ref()
|
||||
@@ -98,7 +109,7 @@ fn matches(item: &OwnedItemView, q: &OwnedItemQuery) -> bool {
|
||||
.as_ref()
|
||||
.map(|c| item.club.eq_ignore_ascii_case(c))
|
||||
.unwrap_or(true);
|
||||
quality_ok && pos_ok && nation_ok && league_ok && club_ok
|
||||
quality_ok && kind_ok && pos_ok && nation_ok && league_ok && club_ok
|
||||
}
|
||||
|
||||
/// Apply the query: filter (AND) → deterministic order → paginate.
|
||||
@@ -152,6 +163,7 @@ mod tests {
|
||||
) -> OwnedItemView {
|
||||
OwnedItemView {
|
||||
owned_card_id: id.to_string(),
|
||||
content_kind: ContentKind::Player,
|
||||
base_overall: overall,
|
||||
effective_overall: overall as i64,
|
||||
position: position.to_string(),
|
||||
@@ -179,6 +191,52 @@ mod tests {
|
||||
]
|
||||
}
|
||||
|
||||
/// A club holds mixed content; a caller asking for one kind must get exactly
|
||||
/// that kind, and the unfiltered read must still return everything.
|
||||
#[test]
|
||||
fn content_kind_filters_mixed_inventory() {
|
||||
let mut items = fixture();
|
||||
let mut kit = view("k", 0, "", "", "", "");
|
||||
kit.content_kind = ContentKind::Kit;
|
||||
let mut style = view("s", 0, "", "", "", "");
|
||||
style.content_kind = ContentKind::Consumable;
|
||||
items.push(kit);
|
||||
items.push(style);
|
||||
|
||||
let all = apply_query(items.clone(), &OwnedItemQuery::default());
|
||||
assert_eq!(all.total, 7, "no filter returns every kind");
|
||||
|
||||
let kits = apply_query(
|
||||
items.clone(),
|
||||
&OwnedItemQuery {
|
||||
content_kind: Some(ContentKind::Kit),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(ids(&kits), ["k"]);
|
||||
|
||||
let players = apply_query(
|
||||
items.clone(),
|
||||
&OwnedItemQuery {
|
||||
content_kind: Some(ContentKind::Player),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(players.total, 5);
|
||||
|
||||
let none = apply_query(
|
||||
items,
|
||||
&OwnedItemQuery {
|
||||
content_kind: Some(ContentKind::Stadium),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
none.total, 0,
|
||||
"a kind the club owns none of is empty, not everything"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_filter_returns_all_in_overall_desc_order() {
|
||||
let p = apply_query(fixture(), &OwnedItemQuery::default());
|
||||
|
||||
@@ -208,11 +208,10 @@ pub async fn sell_card(
|
||||
return Err(AppError::BadRequest("price must be non-negative".into()));
|
||||
}
|
||||
|
||||
let owned = sqlx::query_as::<_, crate::models::card::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 = ?",
|
||||
)
|
||||
let owned = sqlx::query_as::<_, crate::models::card::OwnedCard>(&format!(
|
||||
"{} WHERE id = ? AND club_id = ?",
|
||||
crate::models::card::OWNED_CARD_SELECT
|
||||
))
|
||||
.bind(&req.owned_card_id)
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::{
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
achievement::AchievementDefinition,
|
||||
card::OwnedCard,
|
||||
card::{OwnedCard, OWNED_CARD_SELECT},
|
||||
match_result::{CompleteMatchRequest, Match, MatchCompletionResult, MatchResultKind},
|
||||
objective::ObjectiveDefinition,
|
||||
profile::{coins_for_level, level_for_xp, pack_for_level, LevelUpEvent},
|
||||
@@ -142,10 +142,9 @@ async fn expire_loans_tx(
|
||||
|
||||
let mut expired = Vec::new();
|
||||
for (_sp_id, owned_id) in starters {
|
||||
let card = sqlx::query_as::<_, OwnedCard>(
|
||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at \
|
||||
FROM owned_cards WHERE id = ? AND is_loan = 1",
|
||||
)
|
||||
let card = sqlx::query_as::<_, OwnedCard>(&format!(
|
||||
"{OWNED_CARD_SELECT} WHERE id = ? AND is_loan = 1"
|
||||
))
|
||||
.bind(&owned_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod achievement;
|
||||
pub mod card_db;
|
||||
pub mod checkin;
|
||||
pub mod club;
|
||||
pub mod consume;
|
||||
pub mod draft;
|
||||
pub mod economy;
|
||||
pub mod event;
|
||||
|
||||
+4
-5
@@ -350,11 +350,10 @@ async fn submit_sbc_transaction(
|
||||
|
||||
let mut cards = Vec::with_capacity(owned_card_ids.len());
|
||||
for owned_id in owned_card_ids {
|
||||
let row = sqlx::query_as::<_, crate::models::card::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 = ?",
|
||||
)
|
||||
let row = sqlx::query_as::<_, crate::models::card::OwnedCard>(&format!(
|
||||
"{} WHERE id = ? AND club_id = ?",
|
||||
crate::models::card::OWNED_CARD_SELECT
|
||||
))
|
||||
.bind(owned_id)
|
||||
.bind(club_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
|
||||
+21
-19
@@ -2,7 +2,7 @@ use crate::{
|
||||
db::Pool,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
card::{CardDefinition, OwnedCard},
|
||||
card::{CardDefinition, OwnedCard, OWNED_CARD_SELECT},
|
||||
game_ext::{GameEntityExt, OpaqueExtensionWrite},
|
||||
squad::{
|
||||
SaveSquadRequest, SlotAssignment, Squad, SquadPlayer, SquadPlayerInput, SquadReplaced,
|
||||
@@ -88,14 +88,19 @@ pub async fn validate_formation(
|
||||
|
||||
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 = ?",
|
||||
)
|
||||
let owned = sqlx::query_as::<_, OwnedCard>(&format!(
|
||||
"{OWNED_CARD_SELECT} 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)))?;
|
||||
.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" {
|
||||
@@ -137,13 +142,10 @@ pub async fn calculate_chemistry(
|
||||
// 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?;
|
||||
let owned = sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} 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) {
|
||||
@@ -270,13 +272,13 @@ async fn replace_squad_inner(
|
||||
// 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)))?;
|
||||
let owned = sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} 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
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::{AppError, AppResult},
|
||||
models::card::OwnedCard,
|
||||
models::card::{OwnedCard, OWNED_CARD_SELECT},
|
||||
models::chemistry_style::ChemistryStyle,
|
||||
};
|
||||
|
||||
const OWNED_CARD_SELECT: &str =
|
||||
"SELECT id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at, \
|
||||
chemistry_style, position_override, training_bonus \
|
||||
FROM owned_cards";
|
||||
|
||||
pub const MAX_TRAINING_BONUS: i64 = 3;
|
||||
|
||||
/// Cost in coins to change a player's position.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! 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,
|
||||
@@ -34,6 +35,8 @@ fn owned(ids: &[String]) -> Vec<ImportOwnedCard> {
|
||||
.map(|(i, id)| ImportOwnedCard {
|
||||
owned_item_id: format!("oc-{i}"),
|
||||
card_id: id.clone(),
|
||||
content_kind: ContentKind::Player,
|
||||
quantity: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -208,6 +211,8 @@ async fn missing_definition_fails_preflight_with_no_writes() {
|
||||
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
|
||||
|
||||
@@ -3378,3 +3378,188 @@ async fn test_economy_settle_sale_route_rejects_self_dealing() {
|
||||
Some(SELLER_CLUB)
|
||||
);
|
||||
}
|
||||
|
||||
// ── generic active club-item designations + collection taxonomy ───────────────
|
||||
|
||||
/// Insert one owned instance of a given content kind directly, since there is no
|
||||
/// route that grants a kit/badge/ball/stadium yet (the game adapter/import does).
|
||||
async fn seed_owned_kind(
|
||||
pool: &sqlx::SqlitePool,
|
||||
id: &str,
|
||||
club_id: &str,
|
||||
card_id: &str,
|
||||
kind: &str,
|
||||
) {
|
||||
sqlx::query(
|
||||
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at, content_kind) \
|
||||
VALUES (?, ?, ?, 0, '2026-01-01T00:00:00Z', ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(club_id)
|
||||
.bind(card_id)
|
||||
.bind(kind)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("seed owned item");
|
||||
}
|
||||
|
||||
async fn club_id_of(pool: &sqlx::SqlitePool) -> String {
|
||||
sqlx::query_scalar::<_, String>("SELECT id FROM clubs LIMIT 1")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_active_items_get_returns_every_slot_explicitly() {
|
||||
let (app, _pool) = build_test_app_with_pool().await;
|
||||
auth(&app, "CAGE").await;
|
||||
let (s, j) = json_get(&app, "/club/active-items").await;
|
||||
assert_eq!(s, StatusCode::OK, "{j}");
|
||||
for slot in ["home_kit", "away_kit", "badge", "ball", "stadium"] {
|
||||
assert!(
|
||||
j["active_items"][slot].is_null(),
|
||||
"slot {slot} must be present and null on a fresh club: {j}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_active_items_put_set_and_clear_roundtrip() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
auth(&app, "CAGE").await;
|
||||
let club = club_id_of(&pool).await;
|
||||
// A real definition id keeps the collection projection honest; the kind is
|
||||
// what the designation validates against.
|
||||
seed_owned_kind(&pool, "kit-1", &club, "card_bronze_001", "kit").await;
|
||||
seed_owned_kind(&pool, "badge-1", &club, "card_bronze_002", "badge").await;
|
||||
|
||||
let (s, j) = json_put(
|
||||
&app,
|
||||
"/club/active-items",
|
||||
serde_json::json!({ "slot": "home_kit", "owned_card_id": "kit-1" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{j}");
|
||||
assert_eq!(j["active_items"]["home_kit"]["id"], "kit-1");
|
||||
assert_eq!(j["active_items"]["home_kit"]["content_kind"], "kit");
|
||||
assert!(j["active_items"]["badge"].is_null());
|
||||
|
||||
let (s, j) = json_put(
|
||||
&app,
|
||||
"/club/active-items",
|
||||
serde_json::json!({ "slot": "badge", "owned_card_id": "badge-1" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{j}");
|
||||
assert_eq!(j["active_items"]["badge"]["id"], "badge-1");
|
||||
assert_eq!(j["active_items"]["home_kit"]["id"], "kit-1");
|
||||
|
||||
// A null owned_card_id clears just that slot.
|
||||
let (s, j) = json_put(
|
||||
&app,
|
||||
"/club/active-items",
|
||||
serde_json::json!({ "slot": "home_kit", "owned_card_id": null }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{j}");
|
||||
assert!(j["active_items"]["home_kit"].is_null());
|
||||
assert_eq!(j["active_items"]["badge"]["id"], "badge-1");
|
||||
|
||||
// The designation is durable, not per-response.
|
||||
let (_, j) = json_get(&app, "/club/active-items").await;
|
||||
assert_eq!(j["active_items"]["badge"]["id"], "badge-1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_active_items_put_rejects_kind_and_ownership_violations() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
auth(&app, "CAGE").await;
|
||||
let club = club_id_of(&pool).await;
|
||||
seed_owned_kind(&pool, "badge-1", &club, "card_bronze_002", "badge").await;
|
||||
|
||||
// A badge is not a kit.
|
||||
let (s, _) = json_put(
|
||||
&app,
|
||||
"/club/active-items",
|
||||
serde_json::json!({ "slot": "home_kit", "owned_card_id": "badge-1" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
|
||||
// An item the club does not own.
|
||||
let (s, _) = json_put(
|
||||
&app,
|
||||
"/club/active-items",
|
||||
serde_json::json!({ "slot": "badge", "owned_card_id": "nope" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::NOT_FOUND);
|
||||
|
||||
// A slot outside the recovered equipped-state vocabulary.
|
||||
let (s, _) = json_put(
|
||||
&app,
|
||||
"/club/active-items",
|
||||
serde_json::json!({ "slot": "league_logo", "owned_card_id": "badge-1" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
|
||||
let (_, j) = json_get(&app, "/club/active-items").await;
|
||||
assert!(j["active_items"]["home_kit"].is_null());
|
||||
assert!(j["active_items"]["badge"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_collection_carries_content_kind_and_filters_on_it() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
auth(&app, "CAGE").await;
|
||||
let club = club_id_of(&pool).await;
|
||||
seed_owned_kind(&pool, "kit-1", &club, "card_bronze_001", "kit").await;
|
||||
seed_owned_kind(&pool, "player-1", &club, "card_bronze_002", "player").await;
|
||||
|
||||
let (s, j) = json_get(&app, "/collection").await;
|
||||
assert_eq!(s, StatusCode::OK, "{j}");
|
||||
let kinds: Vec<&str> = j["collection"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c["content_kind"].as_str().expect("content_kind present"))
|
||||
.collect();
|
||||
assert!(kinds.contains(&"kit"), "kinds: {kinds:?}");
|
||||
assert!(kinds.contains(&"player"), "kinds: {kinds:?}");
|
||||
|
||||
let (_, only_kits) = json_get(&app, "/collection?content_kind=kit").await;
|
||||
assert_eq!(only_kits["total"], 1);
|
||||
assert_eq!(only_kits["collection"][0]["owned_card_id"], "kit-1");
|
||||
|
||||
let (_, none) = json_get(&app, "/collection?content_kind=stadium").await;
|
||||
assert_eq!(none["total"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_collection_reports_owned_rows_it_cannot_project() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
auth(&app, "CAGE").await;
|
||||
let club = club_id_of(&pool).await;
|
||||
// An owned row whose definition is NOT in loaded content: it cannot be
|
||||
// projected, but it must be counted and named, never silently dropped.
|
||||
seed_owned_kind(&pool, "ghost", &club, "definitely_absent_999", "consumable").await;
|
||||
|
||||
let (s, j) = json_get(&app, "/collection").await;
|
||||
assert_eq!(s, StatusCode::OK, "a missing definition must not 500: {j}");
|
||||
assert_eq!(j["unresolved_items"], 1);
|
||||
assert_eq!(j["unresolved_definitions"][0], "definitely_absent_999");
|
||||
assert_eq!(
|
||||
j["owned_rows"].as_i64().unwrap(),
|
||||
j["total"].as_i64().unwrap() + 1,
|
||||
"owned_rows is ownership truth, total is what could be projected: {j}"
|
||||
);
|
||||
let ids: Vec<&str> = j["collection"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c["owned_card_id"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(!ids.contains(&"ghost"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
//! Owned-content model migrations (0025 content_kind/quantity, 0026
|
||||
//! club_active_items, 0027 consumable_applications).
|
||||
//!
|
||||
//! Two things must hold on a DB that already contains real ownership:
|
||||
//! * every pre-existing owned row survives and reads back as a `player` with no
|
||||
//! stack size (the migration is a pure widening, not a rewrite);
|
||||
//! * every existing kit designation lands in `club_active_items` under its
|
||||
//! generalised slot token, and the old table + trigger are gone.
|
||||
//!
|
||||
//! The first is proved against a COPY of a real populated club snapshot (1986
|
||||
//! owned rows) when `OPENFUT_CORE_SNAPSHOT_DB` points at one; the second is
|
||||
//! proved by staging a DB at migration 0025, writing 0024-era kit rows, and then
|
||||
//! letting the remaining migrations run.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use openfut_core::models::card::{ActiveSlot, ContentKind};
|
||||
use sqlx::migrate::Migrator;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
const OWNED_CONTENT_MIGRATION: i64 = 25;
|
||||
|
||||
async fn pool_for(path: &std::path::Path) -> SqlitePool {
|
||||
let opts = sqlx::sqlite::SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(true)
|
||||
.foreign_keys(true);
|
||||
SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(opts)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("open {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// The full migrator, truncated after `version`. Used to stage a DB in the state
|
||||
/// it had BEFORE the migrations under test, so their data carry-over is exercised
|
||||
/// on rows that really pre-date them.
|
||||
fn migrator_upto(version: i64) -> Migrator {
|
||||
let full = sqlx::migrate!("./migrations");
|
||||
let subset: Vec<_> = full
|
||||
.iter()
|
||||
.filter(|m| m.version < version)
|
||||
.cloned()
|
||||
.collect();
|
||||
Migrator {
|
||||
migrations: Cow::Owned(subset),
|
||||
ignore_missing: true,
|
||||
locking: true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn table_exists(pool: &SqlitePool, name: &str) -> bool {
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?")
|
||||
.bind(name)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
> 0
|
||||
}
|
||||
|
||||
async fn trigger_names(pool: &SqlitePool) -> Vec<String> {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT name FROM sqlite_master WHERE type='trigger' ORDER BY name",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Stage a DB at pre-0025 state with two 0024-era kit designations, then run the
|
||||
/// rest of the migrations: the designations MUST be carried over, not dropped.
|
||||
#[tokio::test]
|
||||
async fn kit_assignments_migrate_into_club_active_items() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let db = dir.path().join("staged.db");
|
||||
let pool = pool_for(&db).await;
|
||||
migrator_upto(OWNED_CONTENT_MIGRATION)
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("migrate to pre-0025");
|
||||
assert!(
|
||||
table_exists(&pool, "club_kit_assignments").await,
|
||||
"staging must actually be at the 0024 schema"
|
||||
);
|
||||
|
||||
let ts = "2026-01-01T00:00:00Z";
|
||||
sqlx::query("INSERT INTO profiles (id, username, created_at, updated_at) VALUES ('p','p',?,?)")
|
||||
.bind(ts)
|
||||
.bind(ts)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) \
|
||||
VALUES ('c','p','c',0,?,?)",
|
||||
)
|
||||
.bind(ts)
|
||||
.bind(ts)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
for id in ["kit-h", "kit-a", "spare"] {
|
||||
sqlx::query(
|
||||
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) \
|
||||
VALUES (?, 'c', ?, 0, ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(format!("def-{id}"))
|
||||
.bind(ts)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
for (slot, owned) in [("home", "kit-h"), ("away", "kit-a")] {
|
||||
sqlx::query(
|
||||
"INSERT INTO club_kit_assignments (club_id, slot, owned_card_id, updated_at) \
|
||||
VALUES ('c', ?, ?, ?)",
|
||||
)
|
||||
.bind(slot)
|
||||
.bind(owned)
|
||||
.bind(ts)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Now the migrations under test.
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("migrate to head");
|
||||
|
||||
assert!(
|
||||
!table_exists(&pool, "club_kit_assignments").await,
|
||||
"the old kit table must be gone"
|
||||
);
|
||||
assert!(table_exists(&pool, "club_active_items").await);
|
||||
assert!(table_exists(&pool, "consumable_applications").await);
|
||||
|
||||
let rows =
|
||||
sqlx::query("SELECT slot, owned_card_id, updated_at FROM club_active_items ORDER BY slot")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let carried: Vec<(String, String, String)> = rows
|
||||
.iter()
|
||||
.map(|r| (r.get(0), r.get(1), r.get(2)))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
carried,
|
||||
vec![
|
||||
(
|
||||
ActiveSlot::AwayKit.as_str().into(),
|
||||
"kit-a".to_string(),
|
||||
ts.to_string()
|
||||
),
|
||||
(
|
||||
ActiveSlot::HomeKit.as_str().into(),
|
||||
"kit-h".to_string(),
|
||||
ts.to_string()
|
||||
),
|
||||
],
|
||||
"home -> home_kit, away -> away_kit, timestamps preserved"
|
||||
);
|
||||
|
||||
// 0024's trigger is replaced, never merely orphaned: an ownership transfer
|
||||
// must still clear the designation (and must not fail on a missing table).
|
||||
let names = trigger_names(&pool).await;
|
||||
assert!(
|
||||
!names.contains(&"clear_club_kit_assignment_before_transfer".to_string()),
|
||||
"the old trigger must be dropped, got {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"clear_club_active_item_before_transfer".to_string()),
|
||||
"the generalised trigger must exist, got {names:?}"
|
||||
);
|
||||
sqlx::query(
|
||||
"INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) \
|
||||
VALUES ('c2','p','c2',0,?,?)",
|
||||
)
|
||||
.bind(ts)
|
||||
.bind(ts)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE owned_cards SET club_id = 'c2' WHERE id = 'kit-h'")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("transfer must succeed after the trigger swap");
|
||||
let remaining =
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM club_active_items WHERE club_id='c'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(remaining, 1, "the transferred kit's designation is cleared");
|
||||
|
||||
// Backfilled ownership reads back as the default kind with no stack size.
|
||||
let (kind, quantity) = sqlx::query_as::<_, (ContentKind, Option<i64>)>(
|
||||
"SELECT content_kind, quantity FROM owned_cards WHERE id = 'spare'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(kind, ContentKind::Player);
|
||||
assert_eq!(quantity, None);
|
||||
|
||||
// And the new column constraints are real, not documentation.
|
||||
assert!(
|
||||
sqlx::query("UPDATE owned_cards SET content_kind = 'coach' WHERE id = 'spare'")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.is_err(),
|
||||
"content_kind CHECK must reject a token outside the vocabulary"
|
||||
);
|
||||
assert!(
|
||||
sqlx::query("UPDATE owned_cards SET quantity = 0 WHERE id = 'spare'")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.is_err(),
|
||||
"quantity CHECK must reject a non-positive stack"
|
||||
);
|
||||
assert!(
|
||||
sqlx::query(
|
||||
"INSERT INTO club_active_items (club_id, slot, owned_card_id, updated_at) \
|
||||
VALUES ('c', 'league_logo', 'spare', ?)"
|
||||
)
|
||||
.bind(ts)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.is_err(),
|
||||
"slot CHECK must reject a token outside the recovered equipped-state set"
|
||||
);
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
/// The migrations must apply cleanly to a COPY of a REAL populated club DB,
|
||||
/// leave every owned row intact, and carry a real kit designation over.
|
||||
///
|
||||
/// The snapshot predates migration 0024, so the copy is first brought up to the
|
||||
/// 0024 schema and given two kit designations pointing at REAL owned instances;
|
||||
/// only then do the migrations under test run. That way the carry-over is proved
|
||||
/// on production ownership, not on synthetic rows.
|
||||
///
|
||||
/// Point `OPENFUT_CORE_SNAPSHOT_DB` at a real `core.db` to run it; without that
|
||||
/// the test reports the skip rather than passing silently on nothing.
|
||||
#[tokio::test]
|
||||
async fn migrations_apply_to_a_real_populated_snapshot() {
|
||||
let Ok(source) = std::env::var("OPENFUT_CORE_SNAPSHOT_DB") else {
|
||||
eprintln!(
|
||||
"SKIPPED migrations_apply_to_a_real_populated_snapshot: set \
|
||||
OPENFUT_CORE_SNAPSHOT_DB=/path/to/core.db to run it"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let copy = dir.path().join("core.db");
|
||||
// Copy, never open the source: the snapshot is read-only evidence.
|
||||
std::fs::copy(&source, ©).unwrap_or_else(|e| panic!("copy {source}: {e}"));
|
||||
let pool = pool_for(©).await;
|
||||
|
||||
let before = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("snapshot must already hold ownership");
|
||||
assert!(
|
||||
before > 0,
|
||||
"the snapshot must be populated to prove anything"
|
||||
);
|
||||
|
||||
// Bring the copy to the 0024 schema and designate two REAL owned instances
|
||||
// as this club's kits, exactly as the pre-generalisation server would have.
|
||||
migrator_upto(OWNED_CONTENT_MIGRATION)
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("migrate the snapshot to pre-0025");
|
||||
let real: Vec<(String, String)> =
|
||||
sqlx::query_as("SELECT id, club_id FROM owned_cards ORDER BY id LIMIT 2")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(real.len(), 2, "need two real owned instances");
|
||||
let ts = "2026-01-01T00:00:00Z";
|
||||
for (slot, (owned_id, club_id)) in ["home", "away"].into_iter().zip(&real) {
|
||||
sqlx::query(
|
||||
"INSERT INTO club_kit_assignments (club_id, slot, owned_card_id, updated_at) \
|
||||
VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(club_id)
|
||||
.bind(slot)
|
||||
.bind(owned_id)
|
||||
.bind(ts)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("stage a real kit designation");
|
||||
}
|
||||
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("migrations must apply to real populated data");
|
||||
|
||||
let (after, players, stacked) = sqlx::query_as::<_, (i64, i64, i64)>(
|
||||
"SELECT COUNT(*), \
|
||||
SUM(CASE WHEN content_kind = 'player' THEN 1 ELSE 0 END), \
|
||||
SUM(CASE WHEN quantity IS NOT NULL THEN 1 ELSE 0 END) \
|
||||
FROM owned_cards",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(after, before, "no owned row may be lost or duplicated");
|
||||
assert_eq!(players, before, "every backfilled row is a player");
|
||||
assert_eq!(stacked, 0, "no pre-existing row gains a stack size");
|
||||
|
||||
assert!(table_exists(&pool, "club_active_items").await);
|
||||
assert!(!table_exists(&pool, "club_kit_assignments").await);
|
||||
assert!(table_exists(&pool, "consumable_applications").await);
|
||||
|
||||
let carried: Vec<(String, String)> =
|
||||
sqlx::query_as("SELECT slot, owned_card_id FROM club_active_items ORDER BY slot")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
carried,
|
||||
vec![
|
||||
(ActiveSlot::AwayKit.as_str().into(), real[1].0.clone()),
|
||||
(ActiveSlot::HomeKit.as_str().into(), real[0].0.clone()),
|
||||
],
|
||||
"real kit designations must land in club_active_items"
|
||||
);
|
||||
eprintln!(
|
||||
"snapshot: {after} owned rows survive as content_kind='player'; \
|
||||
designations carried over: {carried:?}"
|
||||
);
|
||||
drop(dir);
|
||||
}
|
||||
Reference in New Issue
Block a user