diff --git a/migrations/0025_owned_content_kind.sql b/migrations/0025_owned_content_kind.sql new file mode 100644 index 0000000..f1eb890 --- /dev/null +++ b/migrations/0025_owned_content_kind.sql @@ -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); diff --git a/migrations/0026_club_active_items.sql b/migrations/0026_club_active_items.sql new file mode 100644 index 0000000..d518916 --- /dev/null +++ b/migrations/0026_club_active_items.sql @@ -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; diff --git a/migrations/0027_consumable_applications.sql b/migrations/0027_consumable_applications.sql new file mode 100644 index 0000000..29954fc --- /dev/null +++ b/migrations/0027_consumable_applications.sql @@ -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); diff --git a/src/app.rs b/src/app.rs index 6722e86..901ea55 100644 --- a/src/app.rs +++ b/src/app.rs @@ -172,8 +172,9 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { // 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)) diff --git a/src/models/card.rs b/src/models/card.rs index 66d1f75..888ab6f 100644 --- a/src/models/card.rs +++ b/src/models/card.rs @@ -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 { + 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 { + 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, } -/// 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, pub training_bonus: i64, + pub content_kind: ContentKind, + pub quantity: Option, +} + +/// 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::(&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"); + } } diff --git a/src/routes/cards.rs b/src/routes/cards.rs index a3bae52..1d01d9a 100644 --- a/src/routes/cards.rs +++ b/src/routes/cards.rs @@ -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 = 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 = 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) diff --git a/src/routes/club.rs b/src/routes/club.rs index 88bdd17..d014bf2 100644 --- a/src/routes/club.rs +++ b/src/routes/club.rs @@ -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, game: GameId) -> AppResult> { 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 { + 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, game: GameId, ) -> AppResult> { 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, - pub away_owned_card_id: Option, +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, } -/// 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, game: GameId, - Json(req): Json, + Json(req): Json, ) -> AppResult> { + 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)? }))) } diff --git a/src/services/club.rs b/src/services/club.rs index 06f4932..bc34f60 100644 --- a/src/services/club.rs +++ b/src/services/club.rs @@ -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 AppResult> { @@ -166,7 +166,8 @@ pub async fn get_squad_manager_for_squad( club_id: &str, ) -> AppResult> { 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, - pub away: Option, +pub struct ActiveClubItems { + pub items: Vec<(ActiveSlot, OwnedCard)>, } -async fn get_club_kit_slot(pool: &Pool, club_id: &str, slot: &str) -> AppResult> { +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> { 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 { - 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 { + 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::>(), + 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" + ); } } diff --git a/src/services/consume.rs b/src/services/consume.rs new file mode 100644 index 0000000..5d2cc7f --- /dev/null +++ b/src/services/consume.rs @@ -0,0 +1,1062 @@ +//! Atomic "apply one consumable to a target" primitive. +//! +//! ONE Core transaction that does, in this order and nothing else: +//! +//! 1. validate the SOURCE — it exists, belongs to the club, and is the +//! `ContentKind` the caller expected; +//! 2. validate the TARGET — nothing at all (`ConsumeTarget::Club`) or an owned +//! instance that exists, belongs to the club, and is the expected kind; +//! 3. write the replay guard — `UNIQUE(profile_id, action_identity)` on +//! `consumable_applications`, so a duplicate is refused BEFORE anything is +//! mutated or consumed (same discipline as `match_completions`); +//! 4. apply the caller's mutation to the target; +//! 5. consume the source EXACTLY ONCE — destroy the instance, or decrement its +//! stack and destroy it at zero; +//! 6. commit. +//! +//! Anything failing at any step rolls the whole thing back: the source is never +//! spent without the effect landing, and the effect never lands without the +//! source being spent. +//! +//! Core deliberately supplies **no per-category formula**. What a fitness card, +//! a contract, a chemistry style or a position modifier actually DOES to its +//! target is the calling game adapter's reversed behaviour, passed in as +//! [`ItemMutation`]; an unreversed behaviour must not be invented here, and the +//! honest stopping point for one is ownership + projection, i.e. not calling +//! this function at all. + +use std::future::Future; +use std::pin::Pin; + +use chrono::Utc; +use serde::Serialize; +use serde_json::Value; +use sqlx::SqliteConnection; +use uuid::Uuid; + +use crate::{ + db::Pool, + error::{AppError, AppResult}, + models::card::{ContentKind, OwnedCard, OWNED_CARD_SELECT}, +}; + +/// What the transaction does to the source instance once the effect is applied. +/// +/// Both variants run inside the one transaction and under the one replay guard. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceConsumption { + /// Destroy the instance: exactly one `owned_cards` row is DELETEd. + DestroyInstance, + /// Decrement a stack by `amount`, destroying the row when it reaches zero. + /// + /// Only valid for a source that actually carries a stack size + /// (`owned_cards.quantity IS NOT NULL`); a bare instance has no count to + /// decrement and is refused rather than silently destroyed. + DecrementStack { amount: i64 }, +} + +/// What the consumable is being applied to. +/// +/// Kind validation is explicit at the call site: the caller states which +/// `ContentKind` the target must be, because only the caller knows that (say) a +/// chemistry style goes on a player and a manager-league modifier goes on a +/// manager. +#[derive(Debug, Clone, Copy)] +pub enum ConsumeTarget<'a> { + /// Another owned instance of the same club. + OwnedCard { + owned_card_id: &'a str, + expected_kind: ContentKind, + }, + /// Club-scoped state rather than an owned instance (the mutation writes + /// whatever club row it owns; Core validates only the source). + Club, +} + +/// One application request. +#[derive(Debug, Clone, Copy)] +pub struct ConsumeRequest<'a> { + /// Opaque, stable per-application token supplied by the caller. Core never + /// parses it; it only enforces `UNIQUE(profile_id, action_identity)`. + pub action_identity: &'a str, + pub source_owned_card_id: &'a str, + /// The kind the source MUST be. A mismatch is refused. + pub expected_source_kind: ContentKind, + pub consumption: SourceConsumption, + pub target: ConsumeTarget<'a>, +} + +/// Validated context handed to the caller's mutation. Both rows are as they were +/// read inside the transaction, before any mutation or consumption. +#[derive(Debug, Clone)] +pub struct ConsumeContext { + pub profile_id: String, + pub club_id: String, + pub source: OwnedCard, + /// `None` for [`ConsumeTarget::Club`]. + pub target: Option, +} + +/// A future returned by an [`ItemMutation`], borrowing the transaction. +pub type MutationFuture<'c> = Pin> + Send + 'c>>; + +/// The caller's effect on the target, applied inside Core's transaction. +/// +/// It receives the transaction connection, so every write it makes is committed +/// or rolled back together with the source consumption. The `Value` it returns is +/// stored verbatim as the application's recorded outcome and echoed on replay — +/// Core never interprets it. +pub trait ItemMutation: Send + Sync { + fn apply<'c>( + &'c self, + tx: &'c mut SqliteConnection, + ctx: &'c ConsumeContext, + ) -> MutationFuture<'c>; +} + +impl ItemMutation for F +where + F: for<'c> Fn(&'c mut SqliteConnection, &'c ConsumeContext) -> MutationFuture<'c> + Send + Sync, +{ + fn apply<'c>( + &'c self, + tx: &'c mut SqliteConnection, + ctx: &'c ConsumeContext, + ) -> MutationFuture<'c> { + self(tx, ctx) + } +} + +/// The outcome of an application (fresh or replayed). +#[derive(Debug, Clone, Serialize)] +pub struct ConsumeOutcome { + /// `true` when THIS call applied the effect; `false` when it was a replay of + /// an already-recorded application, which mutated nothing. + pub applied: bool, + pub action_identity: String, + pub source_owned_card_id: String, + /// `true` when the source instance was destroyed, `false` when a stack was + /// decremented and survived. + pub source_destroyed: bool, + /// Remaining stack size after a decrement; `None` when the instance was + /// destroyed or carried no stack. + pub source_quantity_after: Option, + pub target_owned_card_id: Option, + /// The caller mutation's own recorded summary, verbatim. + pub effect: Value, +} + +async fn fetch_owned( + conn: &mut SqliteConnection, + owned_card_id: &str, + club_id: &str, +) -> AppResult { + sqlx::query_as::<_, OwnedCard>(&format!("{OWNED_CARD_SELECT} WHERE id = ? AND club_id = ?")) + .bind(owned_card_id) + .bind(club_id) + .fetch_optional(&mut *conn) + .await? + .ok_or_else(|| AppError::NotFound(format!("owned card '{owned_card_id}' not found"))) +} + +fn require_kind(card: &OwnedCard, expected: ContentKind, role: &str) -> AppResult<()> { + if card.content_kind != expected { + return Err(AppError::BadRequest(format!( + "{role} '{}' is content kind '{}', expected '{expected}'", + card.id, card.content_kind + ))); + } + Ok(()) +} + +/// Apply one consumable to one target, exactly once. See the module docs. +pub async fn consume_item( + pool: &Pool, + profile_id: &str, + club_id: &str, + req: &ConsumeRequest<'_>, + mutation: &M, +) -> AppResult { + if req.action_identity.trim().is_empty() { + return Err(AppError::BadRequest( + "action_identity must not be empty".into(), + )); + } + if let SourceConsumption::DecrementStack { amount } = req.consumption { + if amount < 1 { + return Err(AppError::BadRequest( + "stack decrement amount must be >= 1".into(), + )); + } + } + if let ConsumeTarget::OwnedCard { owned_card_id, .. } = req.target { + if owned_card_id == req.source_owned_card_id { + return Err(AppError::BadRequest( + "a consumable cannot be applied to itself".into(), + )); + } + } + + let mut tx = pool.begin().await?; + + // 1. source: owned by this club, and the kind the caller expected. + let source = fetch_owned(&mut tx, req.source_owned_card_id, club_id).await?; + require_kind(&source, req.expected_source_kind, "source item")?; + + // A source that is fielded in a squad cannot be consumed: `squad_players` + // holds a FK onto `owned_cards(id)`, so the DELETE below would fail anyway. + // Refuse explicitly instead of surfacing SQLITE_CONSTRAINT, and never + // silently evict a lineup as a side effect of spending an item. + let fielded = + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM squad_players WHERE owned_card_id = ?") + .bind(req.source_owned_card_id) + .fetch_one(&mut *tx) + .await?; + if fielded > 0 { + return Err(AppError::Conflict(format!( + "source item '{}' is fielded in a squad and cannot be consumed", + req.source_owned_card_id + ))); + } + + // 2. target. + let target = match req.target { + ConsumeTarget::OwnedCard { + owned_card_id, + expected_kind, + } => { + let card = fetch_owned(&mut tx, owned_card_id, club_id).await?; + require_kind(&card, expected_kind, "target item")?; + Some(card) + } + ConsumeTarget::Club => None, + }; + + // 3. replay guard FIRST — before the mutation and before the consumption, so + // a duplicate cannot apply a second effect or spend a second charge. The + // recorded `effect` is filled in below, once the mutation has produced it. + let application_id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + let guard = sqlx::query( + "INSERT INTO consumable_applications \ + (id, profile_id, action_identity, source_owned_card_id, source_card_id, \ + source_content_kind, source_consumed, source_quantity_after, \ + target_owned_card_id, effect, applied_at) \ + VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?, '', ?)", + ) + .bind(&application_id) + .bind(profile_id) + .bind(req.action_identity) + .bind(&source.id) + .bind(&source.card_id) + .bind(source.content_kind.as_str()) + .bind(target.as_ref().map(|t| t.id.as_str())) + .bind(&now) + .execute(&mut *tx) + .await; + match guard { + Ok(_) => {} + Err(sqlx::Error::Database(e)) if e.is_unique_violation() => { + tx.rollback().await?; + return already_applied(pool, profile_id, req.action_identity).await; + } + Err(e) => { + tx.rollback().await?; + return Err(e.into()); + } + } + + // 4. the caller's effect on the target, inside this transaction. + let ctx = ConsumeContext { + profile_id: profile_id.to_string(), + club_id: club_id.to_string(), + source, + target, + }; + let effect = mutation.apply(&mut tx, &ctx).await?; + + // 5. consume the source exactly once. Both paths assert rows_affected == 1, + // so a concurrent spend of the same instance (which lost the SQLite write + // lock and now sees the row gone / already decremented) fails instead of + // granting a second effect. + let (source_destroyed, source_quantity_after) = match req.consumption { + SourceConsumption::DestroyInstance => { + let deleted = sqlx::query("DELETE FROM owned_cards WHERE id = ? AND club_id = ?") + .bind(&ctx.source.id) + .bind(club_id) + .execute(&mut *tx) + .await? + .rows_affected(); + if deleted != 1 { + return Err(AppError::Conflict(format!( + "source item '{}' was already consumed", + ctx.source.id + ))); + } + (true, None) + } + SourceConsumption::DecrementStack { amount } => { + let Some(have) = ctx.source.quantity else { + return Err(AppError::BadRequest(format!( + "source item '{}' carries no stack size; it can only be destroyed", + ctx.source.id + ))); + }; + if have < amount { + return Err(AppError::Conflict(format!( + "source item '{}' holds {have}, cannot consume {amount}", + ctx.source.id + ))); + } + let remaining = have - amount; + if remaining == 0 { + let deleted = sqlx::query( + "DELETE FROM owned_cards WHERE id = ? AND club_id = ? AND quantity = ?", + ) + .bind(&ctx.source.id) + .bind(club_id) + .bind(have) + .execute(&mut *tx) + .await? + .rows_affected(); + if deleted != 1 { + return Err(AppError::Conflict(format!( + "source item '{}' changed under us", + ctx.source.id + ))); + } + (true, None) + } else { + let updated = sqlx::query( + "UPDATE owned_cards SET quantity = ? WHERE id = ? AND club_id = ? \ + AND quantity = ?", + ) + .bind(remaining) + .bind(&ctx.source.id) + .bind(club_id) + .bind(have) + .execute(&mut *tx) + .await? + .rows_affected(); + if updated != 1 { + return Err(AppError::Conflict(format!( + "source item '{}' changed under us", + ctx.source.id + ))); + } + (false, Some(remaining)) + } + } + }; + + let effect_text = serde_json::to_string(&effect)?; + sqlx::query( + "UPDATE consumable_applications \ + SET effect = ?, source_consumed = ?, source_quantity_after = ? WHERE id = ?", + ) + .bind(&effect_text) + .bind(i64::from(source_destroyed)) + .bind(source_quantity_after) + .bind(&application_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(ConsumeOutcome { + applied: true, + action_identity: req.action_identity.to_string(), + source_owned_card_id: ctx.source.id.clone(), + source_destroyed, + source_quantity_after, + target_owned_card_id: ctx.target.as_ref().map(|t| t.id.clone()), + effect, + }) +} + +/// Echo the recorded outcome of an application that already happened. Mutates +/// nothing and reports `applied = false`. +async fn already_applied( + pool: &Pool, + profile_id: &str, + action_identity: &str, +) -> AppResult { + let row = sqlx::query_as::<_, (String, i64, Option, Option, String)>( + "SELECT source_owned_card_id, source_consumed, source_quantity_after, \ + target_owned_card_id, effect FROM consumable_applications \ + WHERE profile_id = ? AND action_identity = ?", + ) + .bind(profile_id) + .bind(action_identity) + .fetch_optional(pool) + .await? + .ok_or_else(|| { + AppError::Internal(anyhow::anyhow!( + "consumable_applications row missing after unique violation" + )) + })?; + let (source_owned_card_id, source_consumed, source_quantity_after, target, effect_text) = row; + Ok(ConsumeOutcome { + applied: false, + action_identity: action_identity.to_string(), + source_owned_card_id, + source_destroyed: source_consumed != 0, + source_quantity_after, + target_owned_card_id: target, + // Written by this module as JSON, so a parse failure is corruption, not + // an expected case — surface it instead of quietly returning null. + effect: serde_json::from_str(&effect_text)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + use serde_json::json; + + const TS: &str = "2026-01-01T00:00:00Z"; + + /// A file-backed pool (so a "restart" can reopen the same DB) with one club + /// holding: a stacked consumable, bare consumables, players (one fielded), a + /// kit, and a consumable owned by ANOTHER club. + async fn fixture() -> (tempfile::TempDir, String, db::Pool) { + let dir = tempfile::tempdir().expect("tempdir"); + let url = format!("sqlite://{}", dir.path().join("core.db").display()); + let pool = db::init_pool(&url, 5).await.expect("init pool"); + db::run_migrations(&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 (?, ?, ?, 0, ?, ?)", + ) + .bind(club) + .bind(profile) + .bind(club) + .bind(TS) + .bind(TS) + .execute(&pool) + .await + .expect("club"); + } + for (id, club, kind, quantity) in [ + ("stack", "club-a", ContentKind::Consumable, Some(15i64)), + ("single", "club-a", ContentKind::Consumable, None), + ("single2", "club-a", ContentKind::Consumable, None), + ("player", "club-a", ContentKind::Player, None), + ("fielded", "club-a", ContentKind::Player, None), + ("kit", "club-a", ContentKind::Kit, None), + ("foreign", "club-b", ContentKind::Consumable, None), + ] { + sqlx::query( + "INSERT INTO owned_cards \ + (id, club_id, card_id, is_loan, acquired_at, content_kind, quantity) \ + VALUES (?, ?, ?, 0, ?, ?, ?)", + ) + .bind(id) + .bind(club) + .bind(format!("def-{id}")) + .bind(TS) + .bind(kind.as_str()) + .bind(quantity) + .execute(&pool) + .await + .expect("owned card"); + } + sqlx::query( + "INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) \ + VALUES ('sq-a', 'club-a', 'S', '4-4-2', ?, ?)", + ) + .bind(TS) + .bind(TS) + .execute(&pool) + .await + .expect("squad"); + sqlx::query( + "INSERT INTO squad_players (id, squad_id, owned_card_id, position_index) \ + VALUES ('sp-1', 'sq-a', 'fielded', 0)", + ) + .execute(&pool) + .await + .expect("squad player"); + (dir, url, pool) + } + + /// Bumps the target's training bonus — a stand-in for a caller-owned effect. + /// Core supplies no formula; this one lives entirely in the test. + struct BumpTraining; + + impl ItemMutation for BumpTraining { + fn apply<'c>( + &'c self, + tx: &'c mut SqliteConnection, + ctx: &'c ConsumeContext, + ) -> MutationFuture<'c> { + Box::pin(async move { + let target = ctx.target.as_ref().expect("target required"); + sqlx::query( + "UPDATE owned_cards SET training_bonus = training_bonus + 1 WHERE id = ?", + ) + .bind(&target.id) + .execute(&mut *tx) + .await?; + Ok(json!({ "training_bonus_delta": 1 })) + }) + } + } + + /// A mutation that always fails, to prove the whole transaction unwinds. + struct Failing; + + impl ItemMutation for Failing { + fn apply<'c>( + &'c self, + _tx: &'c mut SqliteConnection, + _ctx: &'c ConsumeContext, + ) -> MutationFuture<'c> { + Box::pin(async move { Err(AppError::BadRequest("effect refused".into())) }) + } + } + + /// Coerces a closure into the higher-ranked shape the blanket [`ItemMutation`] + /// impl requires — proving a caller can pass an inline effect, not just a + /// named type. + fn mutation(f: F) -> F + where + F: for<'c> Fn(&'c mut SqliteConnection, &'c ConsumeContext) -> MutationFuture<'c> + + Send + + Sync, + { + f + } + + fn req<'a>( + identity: &'a str, + source: &'a str, + consumption: SourceConsumption, + target: &'a str, + ) -> ConsumeRequest<'a> { + ConsumeRequest { + action_identity: identity, + source_owned_card_id: source, + expected_source_kind: ContentKind::Consumable, + consumption, + target: ConsumeTarget::OwnedCard { + owned_card_id: target, + expected_kind: ContentKind::Player, + }, + } + } + + async fn count(pool: &db::Pool, sql: &str) -> i64 { + sqlx::query_scalar::<_, i64>(sql) + .fetch_one(pool) + .await + .unwrap() + } + + async fn training(pool: &db::Pool, id: &str) -> i64 { + sqlx::query_scalar::<_, i64>("SELECT training_bonus FROM owned_cards WHERE id = ?") + .bind(id) + .fetch_one(pool) + .await + .unwrap() + } + + async fn quantity(pool: &db::Pool, id: &str) -> Option { + sqlx::query_scalar::<_, Option>("SELECT quantity FROM owned_cards WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await + .unwrap() + .flatten() + } + + #[tokio::test] + async fn applies_effect_and_destroys_the_instance() { + let (_dir, _url, pool) = fixture().await; + let out = consume_item( + &pool, + "prof-a", + "club-a", + &req( + "act-1", + "single", + SourceConsumption::DestroyInstance, + "player", + ), + &BumpTraining, + ) + .await + .expect("apply"); + + assert!(out.applied); + assert!(out.source_destroyed); + assert_eq!(out.source_quantity_after, None); + assert_eq!(out.effect, json!({ "training_bonus_delta": 1 })); + assert_eq!(training(&pool, "player").await, 1, "effect landed"); + assert_eq!( + count( + &pool, + "SELECT COUNT(*) FROM owned_cards WHERE id = 'single'" + ) + .await, + 0, + "a consumed card must no longer be owned" + ); + assert_eq!( + count(&pool, "SELECT COUNT(*) FROM consumable_applications").await, + 1 + ); + } + + #[tokio::test] + async fn inline_closure_effect_is_accepted() { + let (_dir, _url, pool) = fixture().await; + let effect = mutation(|tx: &mut SqliteConnection, ctx: &ConsumeContext| { + let target = ctx.target.as_ref().expect("target").id.clone(); + Box::pin(async move { + sqlx::query("UPDATE owned_cards SET chemistry_style = 'anchor' WHERE id = ?") + .bind(&target) + .execute(&mut *tx) + .await?; + Ok(json!({ "chemistry_style": "anchor" })) + }) as MutationFuture<'_> + }); + let out = consume_item( + &pool, + "prof-a", + "club-a", + &req( + "act-closure", + "single", + SourceConsumption::DestroyInstance, + "player", + ), + &effect, + ) + .await + .expect("apply"); + assert!(out.applied); + let style = sqlx::query_scalar::<_, String>( + "SELECT chemistry_style FROM owned_cards WHERE id = 'player'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(style, "anchor"); + } + + /// The core replay guarantee: the same identity twice = ONE mutation and ONE + /// charge, with the recorded outcome echoed back as `applied = false`. + #[tokio::test] + async fn replay_of_one_identity_mutates_once() { + let (dir, url, pool) = fixture().await; + let spend = |identity| { + req( + identity, + "stack", + SourceConsumption::DecrementStack { amount: 5 }, + "player", + ) + }; + let first = consume_item(&pool, "prof-a", "club-a", &spend("act-1"), &BumpTraining) + .await + .expect("first"); + assert!(first.applied); + assert_eq!(first.source_quantity_after, Some(10)); + + let replay = consume_item(&pool, "prof-a", "club-a", &spend("act-1"), &BumpTraining) + .await + .expect("replay"); + assert!(!replay.applied, "a replay must not re-apply"); + assert_eq!(replay.source_quantity_after, Some(10)); + assert_eq!(replay.effect, json!({ "training_bonus_delta": 1 })); + assert_eq!(training(&pool, "player").await, 1, "effect applied once"); + assert_eq!(quantity(&pool, "stack").await, Some(10), "charged once"); + assert_eq!( + count(&pool, "SELECT COUNT(*) FROM consumable_applications").await, + 1 + ); + + // RESTART: the guard is durable, not in-memory. + pool.close().await; + let reopened = db::init_pool(&url, 5).await.expect("reopen"); + db::run_migrations(&reopened).await.expect("migrations"); + let after_restart = consume_item( + &reopened, + "prof-a", + "club-a", + &spend("act-1"), + &BumpTraining, + ) + .await + .expect("restart replay"); + assert!(!after_restart.applied); + assert_eq!(quantity(&reopened, "stack").await, Some(10)); + assert_eq!(training(&reopened, "player").await, 1); + drop(dir); + } + + #[tokio::test] + async fn concurrent_duplicates_apply_exactly_once() { + let (_dir, url, pool) = fixture().await; + drop(pool); + let pool = db::init_pool(&url, 8).await.expect("pool"); + + let mut handles = Vec::new(); + for _ in 0..6 { + let p = pool.clone(); + handles.push(tokio::spawn(async move { + consume_item( + &p, + "prof-a", + "club-a", + &req( + "race", + "stack", + SourceConsumption::DecrementStack { amount: 3 }, + "player", + ), + &BumpTraining, + ) + .await + })); + } + let mut applied = 0; + for h in handles { + if let Ok(Ok(out)) = h.await { + if out.applied { + applied += 1; + } + } + } + assert_eq!(applied, 1, "exactly one racer applies the effect"); + assert_eq!(quantity(&pool, "stack").await, Some(12), "charged once"); + assert_eq!(training(&pool, "player").await, 1); + assert_eq!( + count(&pool, "SELECT COUNT(*) FROM consumable_applications").await, + 1 + ); + } + + #[tokio::test] + async fn stack_is_destroyed_when_it_reaches_zero() { + let (_dir, _url, pool) = fixture().await; + let out = consume_item( + &pool, + "prof-a", + "club-a", + &req( + "act-all", + "stack", + SourceConsumption::DecrementStack { amount: 15 }, + "player", + ), + &BumpTraining, + ) + .await + .expect("apply"); + assert!(out.source_destroyed); + assert_eq!(out.source_quantity_after, None); + assert_eq!( + count(&pool, "SELECT COUNT(*) FROM owned_cards WHERE id = 'stack'").await, + 0 + ); + } + + #[tokio::test] + async fn overdrawing_a_stack_is_refused_whole() { + let (_dir, _url, pool) = fixture().await; + let err = consume_item( + &pool, + "prof-a", + "club-a", + &req( + "act-over", + "stack", + SourceConsumption::DecrementStack { amount: 16 }, + "player", + ), + &BumpTraining, + ) + .await + .expect_err("cannot spend more than is held"); + assert!(matches!(err, AppError::Conflict(_)), "got {err:?}"); + assert_eq!(quantity(&pool, "stack").await, Some(15)); + assert_eq!(training(&pool, "player").await, 0, "effect rolled back"); + assert_eq!( + count(&pool, "SELECT COUNT(*) FROM consumable_applications").await, + 0 + ); + } + + #[tokio::test] + async fn decrementing_a_non_stack_is_refused() { + let (_dir, _url, pool) = fixture().await; + let err = consume_item( + &pool, + "prof-a", + "club-a", + &req( + "act-nostack", + "single", + SourceConsumption::DecrementStack { amount: 1 }, + "player", + ), + &BumpTraining, + ) + .await + .expect_err("a bare instance has no count to decrement"); + assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}"); + assert_eq!( + count( + &pool, + "SELECT COUNT(*) FROM owned_cards WHERE id = 'single'" + ) + .await, + 1, + "and it must NOT be silently destroyed instead" + ); + } + + #[tokio::test] + async fn a_failing_effect_rolls_back_the_charge() { + let (_dir, _url, pool) = fixture().await; + let spend = || { + req( + "act-fail", + "single", + SourceConsumption::DestroyInstance, + "player", + ) + }; + let err = consume_item(&pool, "prof-a", "club-a", &spend(), &Failing) + .await + .expect_err("effect refused"); + assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}"); + assert_eq!( + count( + &pool, + "SELECT COUNT(*) FROM owned_cards WHERE id = 'single'" + ) + .await, + 1, + "the source must survive an unapplied effect" + ); + assert_eq!( + count(&pool, "SELECT COUNT(*) FROM consumable_applications").await, + 0, + "and the guard must not block a legitimate retry" + ); + + // The retry with the SAME identity now succeeds, because nothing landed. + let out = consume_item(&pool, "prof-a", "club-a", &spend(), &BumpTraining) + .await + .expect("retry"); + assert!(out.applied); + } + + #[tokio::test] + async fn validates_ownership_and_kinds_before_anything_moves() { + let (_dir, _url, pool) = fixture().await; + + // Source owned by another club. + assert!(consume_item( + &pool, + "prof-a", + "club-a", + &req( + "v1", + "foreign", + SourceConsumption::DestroyInstance, + "player" + ), + &BumpTraining, + ) + .await + .is_err()); + + // Source is not the kind the caller expected (a kit is not a consumable). + let err = consume_item( + &pool, + "prof-a", + "club-a", + &req("v2", "kit", SourceConsumption::DestroyInstance, "player"), + &BumpTraining, + ) + .await + .expect_err("kind mismatch"); + assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}"); + + // Target is not the kind the caller expected (a kit is not a player). + let err = consume_item( + &pool, + "prof-a", + "club-a", + &req("v3", "single", SourceConsumption::DestroyInstance, "kit"), + &BumpTraining, + ) + .await + .expect_err("target kind mismatch"); + assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}"); + + // Target owned by another club. + assert!(consume_item( + &pool, + "prof-a", + "club-a", + &req( + "v4", + "single", + SourceConsumption::DestroyInstance, + "foreign" + ), + &BumpTraining, + ) + .await + .is_err()); + + // Applying an item to itself. + assert!(consume_item( + &pool, + "prof-a", + "club-a", + &req("v5", "single", SourceConsumption::DestroyInstance, "single"), + &BumpTraining, + ) + .await + .is_err()); + + // An empty identity has no replay identity at all. + assert!(consume_item( + &pool, + "prof-a", + "club-a", + &req(" ", "single", SourceConsumption::DestroyInstance, "player"), + &BumpTraining, + ) + .await + .is_err()); + + assert_eq!(count(&pool, "SELECT COUNT(*) FROM owned_cards").await, 7); + assert_eq!( + count(&pool, "SELECT COUNT(*) FROM consumable_applications").await, + 0 + ); + assert_eq!(training(&pool, "player").await, 0); + } + + /// A source fielded in a squad is refused explicitly — never destroyed, and + /// never silently evicted from the lineup as a side effect. + #[tokio::test] + async fn a_fielded_source_cannot_be_consumed() { + let (_dir, _url, pool) = fixture().await; + let err = consume_item( + &pool, + "prof-a", + "club-a", + &ConsumeRequest { + action_identity: "act-fielded", + source_owned_card_id: "fielded", + expected_source_kind: ContentKind::Player, + consumption: SourceConsumption::DestroyInstance, + target: ConsumeTarget::Club, + }, + &mutation(|_tx: &mut SqliteConnection, _ctx: &ConsumeContext| { + Box::pin(async move { Ok(Value::Null) }) as MutationFuture<'_> + }), + ) + .await + .expect_err("a fielded item cannot be consumed"); + assert!(matches!(err, AppError::Conflict(_)), "got {err:?}"); + assert_eq!(count(&pool, "SELECT COUNT(*) FROM squad_players").await, 1); + assert_eq!( + count( + &pool, + "SELECT COUNT(*) FROM owned_cards WHERE id = 'fielded'" + ) + .await, + 1 + ); + } + + /// Lifecycle invariant: once consumed, the instance is gone — a second spend + /// under a DIFFERENT identity cannot resurrect it. + #[tokio::test] + async fn a_consumed_instance_cannot_be_spent_again() { + let (_dir, _url, pool) = fixture().await; + consume_item( + &pool, + "prof-a", + "club-a", + &req( + "first", + "single", + SourceConsumption::DestroyInstance, + "player", + ), + &BumpTraining, + ) + .await + .expect("first spend"); + + let err = consume_item( + &pool, + "prof-a", + "club-a", + &req( + "second", + "single", + SourceConsumption::DestroyInstance, + "player", + ), + &BumpTraining, + ) + .await + .expect_err("a consumed instance is no longer owned"); + assert!(matches!(err, AppError::NotFound(_)), "got {err:?}"); + assert_eq!(training(&pool, "player").await, 1, "effect applied once"); + } + + /// Two DISTINCT instances of the same definition are two separate charges — + /// the real profile owns exactly that shape (two copies of one resourceId), + /// so consuming one must leave the other spendable. + #[tokio::test] + async fn two_instances_of_one_definition_are_two_charges() { + let (_dir, _url, pool) = fixture().await; + for (identity, source) in [("i1", "single"), ("i2", "single2")] { + let out = consume_item( + &pool, + "prof-a", + "club-a", + &req( + identity, + source, + SourceConsumption::DestroyInstance, + "player", + ), + &BumpTraining, + ) + .await + .expect("spend"); + assert!(out.applied); + } + assert_eq!(training(&pool, "player").await, 2); + assert_eq!( + count(&pool, "SELECT COUNT(*) FROM consumable_applications").await, + 2 + ); + } +} diff --git a/src/services/import.rs b/src/services/import.rs index f170ff6..9af22db 100644 --- a/src/services/import.rs +++ b/src/services/import.rs @@ -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, } #[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)> = 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))?; diff --git a/src/services/inventory.rs b/src/services/inventory.rs index 546bd88..1d7abff 100755 --- a/src/services/inventory.rs +++ b/src/services/inventory.rs @@ -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, + /// Owned-content kind (player/consumable/kit/…). Serialized lowercase. + #[serde(default)] + pub content_kind: Option, /// Playing position, e.g. "ST" (matched case-insensitively). #[serde(default)] pub position: Option, @@ -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()); diff --git a/src/services/market.rs b/src/services/market.rs index fe95bdf..45f26d9 100644 --- a/src/services/market.rs +++ b/src/services/market.rs @@ -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) diff --git a/src/services/match_service.rs b/src/services/match_service.rs index af49e39..9e7dbfc 100644 --- a/src/services/match_service.rs +++ b/src/services/match_service.rs @@ -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?; diff --git a/src/services/mod.rs b/src/services/mod.rs index ae16b60..08a02af 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -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; diff --git a/src/services/sbc.rs b/src/services/sbc.rs index b6d65d9..b7c7e66 100644 --- a/src/services/sbc.rs +++ b/src/services/sbc.rs @@ -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) diff --git a/src/services/squad.rs b/src/services/squad.rs index 8780182..e095af5 100644 --- a/src/services/squad.rs +++ b/src/services/squad.rs @@ -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 diff --git a/src/services/upgrades.rs b/src/services/upgrades.rs index cccc15d..7ae362a 100644 --- a/src/services/upgrades.rs +++ b/src/services/upgrades.rs @@ -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. diff --git a/tests/import_service_test.rs b/tests/import_service_test.rs index e8cba1d..905d196 100644 --- a/tests/import_service_test.rs +++ b/tests/import_service_test.rs @@ -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 { .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 diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 49a1696..df83e19 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -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")); +} diff --git a/tests/owned_content_migration_test.rs b/tests/owned_content_migration_test.rs new file mode 100644 index 0000000..b9fe958 --- /dev/null +++ b/tests/owned_content_migration_test.rs @@ -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 { + 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)>( + "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); +}