From e8be28966092b25f47cd517cf84f8467e75b30e6 Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 22 Aug 2026 18:23:05 +0000 Subject: [PATCH] feat(consume): durable per-instance contract state + HTTP apply route `consume_item` was a complete, tested, atomic apply transaction with zero production callers and no route -- it could not be reached over HTTP because its effect is an in-process `ItemMutation` trait object and the host is a separate process on a synchronous JSON boundary. Closes that gap with a CLOSED, Core-validated effect vocabulary rather than a pass-through: `InstanceEffect::AddContractMatches { amount, cap, default_when_unset }`. A generic "apply this field/value" escape hatch would hand economic authority back to the caller and break the architecture. The read-modify-write runs INSIDE the caller's transaction (`min(cap, COALESCE(contract_matches, default) + amount)`) so two concurrent applies cannot lose an update, and the reported `granted` stays the requested amount even when the cap clamps the total. Migration 0028 adds `owned_cards.contract_matches` NULLABLE: NULL means "Core tracks no contract here", which keeps the pack-fresh default (a FIFA-specific 7) out of Core and leaves every existing row unchanged in meaning. ADD COLUMN, not a rebuild -- a rebuild would drop 0026's transfer trigger. Two ordering fixes forced by putting this on the live path: * consume_item moves from DEFERRED `pool.begin()` to `BEGIN IMMEDIATE`, the discipline economy.rs documents: three reads precede the first write, which is exactly the shape that returns SQLITE_BUSY past the busy handler. * the replay answer now precedes source validation. With DestroyInstance the first apply deletes the source, so the old order answered a retry with 404 instead of the recorded outcome -- replay semantics were unreachable. --- migrations/0028_owned_card_contract.sql | 19 + src/app.rs | 4 + src/models/card.rs | 6 +- src/routes/cards.rs | 4 + src/routes/consumables.rs | 82 ++++ src/routes/mod.rs | 1 + src/services/consume.rs | 428 ++++++++++++-------- src/services/economy.rs | 2 +- src/services/instance_effect.rs | 497 ++++++++++++++++++++++++ src/services/mod.rs | 1 + tests/integration_test.rs | 112 ++++++ tests/owned_content_migration_test.rs | 35 +- 12 files changed, 1019 insertions(+), 172 deletions(-) create mode 100644 migrations/0028_owned_card_contract.sql create mode 100644 src/routes/consumables.rs create mode 100644 src/services/instance_effect.rs diff --git a/migrations/0028_owned_card_contract.sql b/migrations/0028_owned_card_contract.sql new file mode 100644 index 0000000..5004a3e --- /dev/null +++ b/migrations/0028_owned_card_contract.sql @@ -0,0 +1,19 @@ +-- Per-instance match-contract counter on an owned instance. +-- +-- NULLABLE ON PURPOSE. NULL means "Core tracks no contract for this instance", +-- which is NOT the same as zero: a game whose contracts start at a pack-fresh +-- default (FIFA 17 hands out 7) must supply that default itself, so the number +-- stays in the game adapter and never becomes a Core constant. Every row that +-- pre-dates this migration therefore reads back NULL and keeps its exact prior +-- meaning — the migration is a pure widening, not a backfill. +-- +-- `>= 0` only: the cap is a per-application input (the caller's game rule), not +-- a schema invariant, so the CHECK refuses the one value that is nonsense in +-- every game rather than pinning someone else's ceiling. +-- +-- ALTER TABLE ADD COLUMN, NEVER a table rebuild: `owned_cards` carries the +-- `clear_club_active_item_before_transfer` trigger installed by 0026, and a +-- DROP/recreate would silently take it with it — exactly the failure 0026:44-46 +-- documents for 0024's trigger. +ALTER TABLE owned_cards ADD COLUMN contract_matches INTEGER + CHECK (contract_matches IS NULL OR contract_matches >= 0); diff --git a/src/app.rs b/src/app.rs index 901ea55..a6b127a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -192,6 +192,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result { post(routes::economy::post_redeem_entitlement), ) .route("/economy/sell-item", post(routes::economy::post_sell_item)) + .route( + "/consumables/apply", + post(routes::consumables::post_apply_consumable), + ) .route( "/economy/grant-reward", post(routes::economy::post_grant_reward), diff --git a/src/models/card.rs b/src/models/card.rs index 888ab6f..15111a9 100644 --- a/src/models/card.rs +++ b/src/models/card.rs @@ -252,6 +252,10 @@ pub struct OwnedCard { pub training_bonus: i64, pub content_kind: ContentKind, pub quantity: Option, + /// Match-contracts remaining on this instance, or `None` when Core tracks + /// no contract for it. `None` is not zero: the pack-fresh starting value is + /// a per-game rule the caller supplies, never a Core default. + pub contract_matches: Option, } /// The ONE canonical column list for reading an [`OwnedCard`]. @@ -262,7 +266,7 @@ pub struct OwnedCard { /// 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"; + training_bonus, content_kind, quantity, contract_matches FROM owned_cards"; #[cfg(test)] mod tests { diff --git a/src/routes/cards.rs b/src/routes/cards.rs index 1d01d9a..c060181 100644 --- a/src/routes/cards.rs +++ b/src/routes/cards.rs @@ -151,6 +151,10 @@ pub async fn get_collection( "chemistry_style": o.chemistry_style, "position_override": o.position_override, "training_bonus": o.training_bonus, + // Core's stored value verbatim: `null` means Core tracks no contract + // for this instance, which is NOT zero. Substituting a default here + // would bake one game's pack-fresh number into every game's envelope. + "contract_matches": o.contract_matches, "effective_overall": effective_overall, "effective_position": effective_position, "card": def, diff --git a/src/routes/consumables.rs b/src/routes/consumables.rs new file mode 100644 index 0000000..f28d8f2 --- /dev/null +++ b/src/routes/consumables.rs @@ -0,0 +1,82 @@ +//! `POST /consumables/apply` — the HTTP boundary for Core's atomic +//! apply-one-consumable transaction. +//! +//! Game-neutral like the rest of Core's surface: the caller names an owned source +//! instance, an owned target and a described [`InstanceEffect`]; Core resolves the +//! game-scoped active profile and its club from the `X-OpenFUT-Game` header, so no +//! caller can reach across clubs. Everything after that is one durable SQLite +//! transaction in [`consume::consume_item`], guarded by +//! `UNIQUE(profile_id, action_identity)`. +//! +//! The effect vocabulary is closed and validated by Core — see +//! [`crate::services::instance_effect`] for why the host describes an effect +//! instead of supplying one. + +use axum::{extract::State, Json}; +use serde::Deserialize; + +use crate::{ + app::AppState, + error::AppResult, + extractors::GameId, + models::card::ContentKind, + services::{ + club as club_svc, + consume::{self, ConsumeOutcome, ConsumeRequest, ConsumeTarget, SourceConsumption}, + instance_effect::InstanceEffect, + profile as profile_svc, + }, +}; + +#[derive(Deserialize)] +pub struct ApplyConsumableRequest { + /// Opaque, stable per-application token. Core never parses it; it only + /// enforces uniqueness, so a retried HTTP request replays instead of + /// applying twice. + pub action_identity: String, + pub source_owned_card_id: String, + pub target_owned_card_id: String, + /// The kind the target MUST be. The caller states it because only the caller + /// knows which family its consumable belongs to; a mismatch is refused rather + /// than applied to whatever happens to be there. + pub target_kind: ContentKind, + pub effect: InstanceEffect, +} + +/// `POST /consumables/apply` — atomic validate + apply + consume-once. +/// +/// `applied: false` in the response means the `action_identity` was already +/// recorded: nothing was mutated and the recorded outcome is echoed. +pub async fn post_apply_consumable( + State(state): State, + game: GameId, + Json(req): Json, +) -> AppResult> { + // Both ids are needed: the profile scopes the replay guard, the club scopes + // ownership. Same resolution pair as `cards::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 outcome = consume::consume_item( + &state.pool, + &profile.id, + &club.id, + &ConsumeRequest { + action_identity: &req.action_identity, + source_owned_card_id: &req.source_owned_card_id, + // A consumable is the only thing that can be applied, and it is spent + // whole: FIFA-style stacking is the adapter's projection, not an + // ownership model Core has for these instances. + expected_source_kind: ContentKind::Consumable, + consumption: SourceConsumption::DestroyInstance, + target: ConsumeTarget::OwnedCard { + owned_card_id: &req.target_owned_card_id, + expected_kind: req.target_kind, + }, + }, + &req.effect, + ) + .await?; + + Ok(Json(outcome)) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 5741f12..93d8d4b 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -2,6 +2,7 @@ pub mod achievements; pub mod auth; pub mod cards; pub mod club; +pub mod consumables; pub mod division; pub mod draft; pub mod economy; diff --git a/src/services/consume.rs b/src/services/consume.rs index 5d2cc7f..752205d 100644 --- a/src/services/consume.rs +++ b/src/services/consume.rs @@ -2,17 +2,24 @@ //! //! ONE Core transaction that does, in this order and nothing else: //! -//! 1. validate the SOURCE — it exists, belongs to the club, and is the +//! 1. answer a REPLAY — if `(profile_id, action_identity)` is already recorded, +//! echo that outcome and touch nothing. This precedes every validation on +//! purpose: a completed application has already DESTROYED its source, so +//! checking the source first would answer "not found" to a retried request +//! that in fact succeeded; +//! 2. 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 +//! 3. 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 +//! 4. 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 +//! mutated or consumed (same discipline as `match_completions`). Step 1 is +//! a courtesy; THIS is the guarantee, and it holds against a writer on any +//! other connection or process; +//! 5. apply the caller's mutation to the target; +//! 6. consume the source EXACTLY ONCE — destroy the instance, or decrement its //! stack and destroy it at zero; -//! 6. commit. +//! 7. 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 @@ -21,9 +28,10 @@ //! 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. +//! [`ItemMutation`] — either in-process, or described over the wire through the +//! closed, Core-validated vocabulary in [`crate::services::instance_effect`]. 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; @@ -38,6 +46,7 @@ use crate::{ db::Pool, error::{AppError, AppResult}, models::card::{ContentKind, OwnedCard, OWNED_CARD_SELECT}, + services::economy, }; /// What the transaction does to the source instance once the effect is applied. @@ -169,6 +178,16 @@ fn require_kind(card: &OwnedCard, expected: ContentKind, role: &str) -> AppResul Ok(()) } +/// Whether this call did the work or found the identity already recorded. +/// +/// The replay branch cannot read the recorded outcome while the transaction is +/// still open on this connection, so it is reported out of the transaction and +/// answered once the connection is free again. +enum Applied { + Fresh(ConsumeOutcome), + Replay, +} + /// Apply one consumable to one target, exactly once. See the module docs. pub async fn consume_item( pool: &Pool, @@ -197,187 +216,231 @@ pub async fn consume_item( } } - let mut tx = pool.begin().await?; + // ONE connection with an explicit BEGIN IMMEDIATE, never a DEFERRED + // `pool.begin()`: this transaction performs three reads before its first + // write, and a deferred transaction only upgrades to a write at that first + // write — where SQLite answers SQLITE_BUSY *immediately*, bypassing + // `busy_timeout` (the full reasoning is on `economy::finish`). Two clients + // applying consumables at once is ordinary traffic, so take the write lock + // up front and let a rival wait instead of fail. + let mut conn = pool.acquire().await?; + sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).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) + let result = async { + // 1. a replay is answered before anything is validated: a completed + // application has already destroyed (or drawn down) its source, so + // validating first would answer NotFound to a retry of a request that + // actually succeeded. The write lock is already held, so this read + // cannot race the guard INSERT below — which stays as the real + // guarantee for a writer on any other connection. + if recorded(&mut conn, profile_id, req.action_identity).await? { + return Ok(Applied::Replay); } - 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; + // 2. source: owned by this club, and the kind the caller expected. + let source = fetch_owned(&mut conn, 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 *conn) + .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 + ))); } - 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 - ))); + // 3. target. + let target = match req.target { + ConsumeTarget::OwnedCard { + owned_card_id, + expected_kind, + } => { + let card = fetch_owned(&mut conn, owned_card_id, club_id).await?; + require_kind(&card, expected_kind, "target item")?; + Some(card) } - (true, None) + ConsumeTarget::Club => None, + }; + + // 4. replay guard — the durable one. It precedes the mutation and the + // consumption, so a duplicate that got past step 1 on another + // connection still 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 *conn) + .await; + match guard { + Ok(_) => {} + // A collision writes nothing, so this transaction has nothing to undo + // and simply ends; the recorded outcome is read back afterwards. + Err(sqlx::Error::Database(e)) if e.is_unique_violation() => return Ok(Applied::Replay), + Err(e) => return Err(e.into()), } - 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(); + + // 5. 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 conn, &ctx).await?; + + // 6. 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 *conn) + .await? + .rows_affected(); if deleted != 1 { return Err(AppError::Conflict(format!( - "source item '{}' changed under us", + "source item '{}' was already consumed", 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 { + } + 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 '{}' changed under us", + "source item '{}' holds {have}, cannot consume {amount}", ctx.source.id ))); } - (false, Some(remaining)) + 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 *conn) + .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 *conn) + .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 = ?", + 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 *conn) + .await?; + + Ok(Applied::Fresh(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, + })) + } + .await; + + match economy::finish(&mut conn, result).await? { + Applied::Fresh(outcome) => Ok(outcome), + // Read the recorded outcome on the SAME connection: acquiring a second one + // while still holding this one deadlocks a pool saturated with racing + // appliers, which is exactly the case a replay shows up in. + Applied::Replay => already_applied(&mut conn, profile_id, req.action_identity).await, + } +} + +/// Has this identity already been applied? Read inside the transaction, under +/// the write lock, so the answer cannot go stale before the guard INSERT. +async fn recorded( + conn: &mut SqliteConnection, + profile_id: &str, + action_identity: &str, +) -> AppResult { + let hits = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM consumable_applications \ + WHERE profile_id = ? AND action_identity = ?", ) - .bind(&effect_text) - .bind(i64::from(source_destroyed)) - .bind(source_quantity_after) - .bind(&application_id) - .execute(&mut *tx) + .bind(profile_id) + .bind(action_identity) + .fetch_one(&mut *conn) .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, - }) + Ok(hits > 0) } /// Echo the recorded outcome of an application that already happened. Mutates /// nothing and reports `applied = false`. async fn already_applied( - pool: &Pool, + conn: &mut SqliteConnection, profile_id: &str, action_identity: &str, ) -> AppResult { @@ -388,7 +451,7 @@ async fn already_applied( ) .bind(profile_id) .bind(action_identity) - .fetch_optional(pool) + .fetch_optional(&mut *conn) .await? .ok_or_else(|| { AppError::Internal(anyhow::anyhow!( @@ -621,6 +684,39 @@ mod tests { ); } + /// A retry of a request that ALREADY succeeded must replay, not 404. With + /// `DestroyInstance` the source no longer exists by then, so answering the + /// replay has to precede source validation — otherwise a client that lost the + /// response to a successful apply is told its item was never there. + #[tokio::test] + async fn a_replay_survives_the_source_it_destroyed() { + let (_dir, _url, pool) = fixture().await; + let spend = || { + req( + "act-gone", + "single", + SourceConsumption::DestroyInstance, + "player", + ) + }; + let first = consume_item(&pool, "prof-a", "club-a", &spend(), &BumpTraining) + .await + .expect("first"); + assert!(first.applied); + + let replay = consume_item(&pool, "prof-a", "club-a", &spend(), &BumpTraining) + .await + .expect("a retry must replay, not fail on the destroyed source"); + assert!(!replay.applied); + assert!(replay.source_destroyed); + assert_eq!(replay.effect, first.effect); + assert_eq!(training(&pool, "player").await, 1, "effect applied once"); + 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; diff --git a/src/services/economy.rs b/src/services/economy.rs index 4fd4a2c..ee3390c 100644 --- a/src/services/economy.rs +++ b/src/services/economy.rs @@ -283,7 +283,7 @@ pub async fn list_unopened_entitlements(pool: &Pool, club_id: &str) -> AppResult /// DEFERRED `pool.begin()` upgrades to a write only at the first write, where /// SQLite returns SQLITE_BUSY *immediately* (bypassing the busy handler to avoid /// deadlock) — the fresh-DB multi-connection write failure. -async fn finish(conn: &mut SqliteConnection, result: AppResult) -> AppResult { +pub(crate) async fn finish(conn: &mut SqliteConnection, result: AppResult) -> AppResult { match result { Ok(v) => { sqlx::query("COMMIT").execute(&mut *conn).await?; diff --git a/src/services/instance_effect.rs b/src/services/instance_effect.rs new file mode 100644 index 0000000..67f3bc7 --- /dev/null +++ b/src/services/instance_effect.rs @@ -0,0 +1,497 @@ +//! The CLOSED vocabulary of instance mutations Core can execute for a caller +//! that is not in-process. +//! +//! [`crate::services::consume::consume_item`] takes an +//! [`ItemMutation`] — an in-process closure, which cannot cross an HTTP +//! boundary. A game host applying a consumable over HTTP therefore cannot SUPPLY +//! its effect; it can only DESCRIBE one, and Core executes the description. +//! +//! That does not move the game's formula into Core. The caller still owns every +//! number: how many match-contracts a given card grants a given target is the +//! adapter's reversed per-game table, and it arrives here as `amount`. Core owns +//! only what it can prove without knowing the game — the arithmetic, the clamp, +//! the ownership scope, the loan invariant and the transaction. This is the same +//! split quick-sell already uses (the host prices the item, Core moves it). +//! +//! The enum is closed ON PURPOSE. A generic "set field X to value Y" escape +//! hatch would hand the host arbitrary write access to Core state and make every +//! present and future invariant unenforceable; a new effect is a new variant, +//! validated here, reviewed here. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sqlx::SqliteConnection; + +use crate::{ + error::{AppError, AppResult}, + models::card::OwnedCard, + services::consume::{ConsumeContext, ItemMutation, MutationFuture}, +}; + +/// One wire-describable mutation of a target instance. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum InstanceEffect { + /// Add match-contracts to the target, saturating at `cap`. + /// + /// `default_when_unset` is what a target Core tracks no contract for counts + /// as — the caller's pack-fresh starting value (FIFA 17: 7). Core has no such + /// default of its own, which is precisely why `owned_cards.contract_matches` + /// is nullable; see migration 0028. + AddContractMatches { + amount: i64, + cap: i64, + default_when_unset: i64, + }, +} + +impl ItemMutation for InstanceEffect { + fn apply<'c>( + &'c self, + tx: &'c mut SqliteConnection, + ctx: &'c ConsumeContext, + ) -> MutationFuture<'c> { + match self { + InstanceEffect::AddContractMatches { + amount, + cap, + default_when_unset, + } => Box::pin(add_contract_matches( + tx, + ctx, + *amount, + *cap, + *default_when_unset, + )), + } + } +} + +/// The target instance this effect mutates. An effect that writes to an instance +/// has nothing to write to when the application is club-scoped, so that is a +/// refusal rather than a silent no-op that still spends the source. +fn require_target<'a>(ctx: &'a ConsumeContext, effect: &str) -> AppResult<&'a OwnedCard> { + ctx.target + .as_ref() + .ok_or_else(|| AppError::BadRequest(format!("effect '{effect}' requires a target item"))) +} + +async fn add_contract_matches( + tx: &mut SqliteConnection, + ctx: &ConsumeContext, + amount: i64, + cap: i64, + default_when_unset: i64, +) -> AppResult { + let target = require_target(ctx, "add_contract_matches")?; + + if amount < 1 { + return Err(AppError::BadRequest(format!( + "add_contract_matches amount must be >= 1, got {amount}" + ))); + } + if cap < 1 { + return Err(AppError::BadRequest(format!( + "add_contract_matches cap must be >= 1, got {cap}" + ))); + } + if default_when_unset < 0 { + return Err(AppError::BadRequest(format!( + "add_contract_matches default_when_unset must be >= 0, got {default_when_unset}" + ))); + } + // A loan item is borrowed for a fixed number of matches + // (`loan_matches_remaining`); topping up its contract would pretend to extend + // something Core does not own. Core models loans, so the invariant is Core's. + if target.is_loan { + return Err(AppError::BadRequest(format!( + "contracts cannot be applied to a loan item ('{}')", + target.id + ))); + } + + // Read-modify-write INSIDE the caller's transaction, deliberately re-reading + // rather than trusting the snapshot in `ctx`: the read and the write then sit + // in one contiguous critical section under the same write lock, so two + // concurrent applies serialise instead of both computing from one `before` + // and losing an update. + let before = sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(contract_matches, ?) FROM owned_cards WHERE id = ? AND club_id = ?", + ) + .bind(default_when_unset) + .bind(&target.id) + .bind(&ctx.club_id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| AppError::NotFound(format!("target item '{}' not found", target.id)))?; + + // Saturating, because `before + amount` is caller-supplied arithmetic and an + // overflow must not panic a request thread; the clamp makes the sum's exact + // magnitude irrelevant anyway. + let after = before.saturating_add(amount).min(cap); + + let updated = + sqlx::query("UPDATE owned_cards SET contract_matches = ? WHERE id = ? AND club_id = ?") + .bind(after) + .bind(&target.id) + .bind(&ctx.club_id) + .execute(&mut *tx) + .await? + .rows_affected(); + if updated != 1 { + return Err(AppError::Conflict(format!( + "target item '{}' changed under us", + target.id + ))); + } + + // `granted` is what the caller's table awarded, NOT `after - before`: the cap + // can swallow part of it, and the two numbers answer different questions + // (what the card was worth vs. what the instance now holds). + Ok(json!({ + "kind": "add_contract_matches", + "granted": amount, + "before": before, + "after": after, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + use crate::models::card::ContentKind; + use crate::services::consume::{ + consume_item, ConsumeRequest, ConsumeTarget, SourceConsumption, + }; + + const TS: &str = "2026-01-01T00:00:00Z"; + + /// One club holding contract consumables and three player targets: one Core + /// tracks no contract for, one part-way through its contract, and one on loan. + async fn fixture() -> (tempfile::TempDir, 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"); + + sqlx::query("INSERT INTO profiles (id, username, created_at, updated_at) VALUES (?,?,?,?)") + .bind("prof") + .bind("prof") + .bind(TS) + .bind(TS) + .execute(&pool) + .await + .expect("profile"); + sqlx::query( + "INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) \ + VALUES ('club', 'prof', 'club', 0, ?, ?)", + ) + .bind(TS) + .bind(TS) + .execute(&pool) + .await + .expect("club"); + + for (id, kind, is_loan, contract) in [ + ("card-1", ContentKind::Consumable, 0, None), + ("card-2", ContentKind::Consumable, 0, None), + ("card-3", ContentKind::Consumable, 0, None), + ("fresh", ContentKind::Player, 0, None), + ("used", ContentKind::Player, 0, Some(90i64)), + ("loaned", ContentKind::Player, 1, None), + ] { + sqlx::query( + "INSERT INTO owned_cards \ + (id, club_id, card_id, is_loan, acquired_at, content_kind, contract_matches) \ + VALUES (?, 'club', ?, ?, ?, ?, ?)", + ) + .bind(id) + .bind(format!("def-{id}")) + .bind(is_loan) + .bind(TS) + .bind(kind.as_str()) + .bind(contract) + .execute(&pool) + .await + .expect("owned card"); + } + (dir, pool) + } + + fn apply_request<'a>( + identity: &'a str, + source: &'a str, + target: &'a str, + ) -> ConsumeRequest<'a> { + ConsumeRequest { + action_identity: identity, + source_owned_card_id: source, + expected_source_kind: ContentKind::Consumable, + consumption: SourceConsumption::DestroyInstance, + target: ConsumeTarget::OwnedCard { + owned_card_id: target, + expected_kind: ContentKind::Player, + }, + } + } + + fn grant(amount: i64) -> InstanceEffect { + InstanceEffect::AddContractMatches { + amount, + cap: 99, + default_when_unset: 7, + } + } + + async fn contract_of(pool: &db::Pool, id: &str) -> Option { + sqlx::query_scalar::<_, Option>( + "SELECT contract_matches FROM owned_cards WHERE id = ?", + ) + .bind(id) + .fetch_one(pool) + .await + .expect("read contract") + } + + /// A target Core tracks no contract for counts as the CALLER's pack-fresh + /// default, not as zero — the whole reason the column is nullable. + #[tokio::test] + async fn an_unset_target_starts_from_the_callers_default() { + let (_dir, pool) = fixture().await; + let out = consume_item( + &pool, + "prof", + "club", + &apply_request("act-1", "card-1", "fresh"), + &grant(15), + ) + .await + .expect("apply"); + + assert!(out.applied); + assert!(out.source_destroyed); + assert_eq!( + out.effect, + json!({ "kind": "add_contract_matches", "granted": 15, "before": 7, "after": 22 }) + ); + assert_eq!(contract_of(&pool, "fresh").await, Some(22)); + } + + /// A stored value is added to, never replaced by the default. + #[tokio::test] + async fn an_existing_value_is_added_to() { + let (_dir, pool) = fixture().await; + let out = consume_item( + &pool, + "prof", + "club", + &apply_request("act-2", "card-1", "used"), + &grant(3), + ) + .await + .expect("apply"); + assert_eq!( + out.effect, + json!({ "kind": "add_contract_matches", "granted": 3, "before": 90, "after": 93 }) + ); + assert_eq!(contract_of(&pool, "used").await, Some(93)); + } + + /// The cap clamps the STORED total but not the REPORTED grant: they answer + /// different questions, and a caller reconciling its own wire response needs + /// the amount its table awarded. + #[tokio::test] + async fn the_cap_clamps_the_total_but_not_the_reported_grant() { + let (_dir, pool) = fixture().await; + let out = consume_item( + &pool, + "prof", + "club", + &apply_request("act-3", "card-1", "used"), + &grant(28), + ) + .await + .expect("apply"); + assert_eq!( + out.effect, + json!({ "kind": "add_contract_matches", "granted": 28, "before": 90, "after": 99 }) + ); + assert_eq!(contract_of(&pool, "used").await, Some(99)); + } + + /// A loan is borrowed for a fixed run of matches; its contract is not Core's + /// to extend. The refusal must also unwind the charge. + #[tokio::test] + async fn a_loan_target_is_refused_and_the_source_survives() { + let (_dir, pool) = fixture().await; + let err = consume_item( + &pool, + "prof", + "club", + &apply_request("act-loan", "card-1", "loaned"), + &grant(15), + ) + .await + .expect_err("a loan cannot take a contract"); + assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}"); + assert_eq!(contract_of(&pool, "loaned").await, None); + let sources = + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards WHERE id = 'card-1'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(sources, 1, "a refused effect must not spend the source"); + } + + /// This effect writes to an instance, so a club-scoped application has + /// nothing to write to — refuse rather than spend the source for nothing. + #[tokio::test] + async fn a_club_scoped_application_is_refused() { + let (_dir, pool) = fixture().await; + let err = consume_item( + &pool, + "prof", + "club", + &ConsumeRequest { + action_identity: "act-club", + source_owned_card_id: "card-1", + expected_source_kind: ContentKind::Consumable, + consumption: SourceConsumption::DestroyInstance, + target: ConsumeTarget::Club, + }, + &grant(15), + ) + .await + .expect_err("no target to contract"); + assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}"); + } + + /// Core validates the caller's numbers instead of trusting them: a zero or + /// negative grant, a zero cap and a negative default are all nonsense, and a + /// nonsense application must not silently succeed as a no-op. + #[tokio::test] + async fn nonsensical_parameters_are_refused() { + let (_dir, pool) = fixture().await; + for (identity, effect) in [ + ( + "bad-amount-0", + InstanceEffect::AddContractMatches { + amount: 0, + cap: 99, + default_when_unset: 7, + }, + ), + ( + "bad-amount-neg", + InstanceEffect::AddContractMatches { + amount: -5, + cap: 99, + default_when_unset: 7, + }, + ), + ( + "bad-cap", + InstanceEffect::AddContractMatches { + amount: 1, + cap: 0, + default_when_unset: 7, + }, + ), + ( + "bad-default", + InstanceEffect::AddContractMatches { + amount: 1, + cap: 99, + default_when_unset: -1, + }, + ), + ] { + let Err(err) = consume_item( + &pool, + "prof", + "club", + &apply_request(identity, "card-1", "fresh"), + &effect, + ) + .await + else { + panic!("{identity} must be refused"); + }; + assert!( + matches!(err, AppError::BadRequest(_)), + "{identity}: got {err:?}" + ); + } + assert_eq!( + contract_of(&pool, "fresh").await, + None, + "a refused application leaves the target untracked" + ); + } + + /// The replay guard covers the effect too: a repeated `action_identity` + /// echoes the recorded outcome, adds no second grant, and spends no second + /// card. + #[tokio::test] + async fn a_replay_neither_grants_nor_charges_twice() { + let (_dir, pool) = fixture().await; + let first = consume_item( + &pool, + "prof", + "club", + &apply_request("act-replay", "card-1", "fresh"), + &grant(15), + ) + .await + .expect("first"); + assert!(first.applied); + + let replay = consume_item( + &pool, + "prof", + "club", + &apply_request("act-replay", "card-1", "fresh"), + &grant(15), + ) + .await + .expect("replay"); + assert!(!replay.applied, "a replay must not re-apply"); + assert_eq!( + replay.effect, first.effect, + "the recorded outcome is echoed" + ); + assert_eq!(contract_of(&pool, "fresh").await, Some(22), "granted once"); + assert_eq!( + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards WHERE id LIKE 'card-%'") + .fetch_one(&pool) + .await + .unwrap(), + 2, + "exactly one consumable was spent" + ); + } + + /// The wire vocabulary is closed: the documented body parses, and an + /// undescribed effect is rejected at the boundary rather than reaching a + /// fallback. + #[test] + fn the_effect_vocabulary_is_closed() { + let parsed: InstanceEffect = serde_json::from_str( + r#"{"kind":"add_contract_matches","amount":15,"cap":99,"default_when_unset":7}"#, + ) + .expect("the documented effect body must parse"); + assert!(matches!( + parsed, + InstanceEffect::AddContractMatches { + amount: 15, + cap: 99, + default_when_unset: 7 + } + )); + assert!( + serde_json::from_str::(r#"{"kind":"set_rating","value":99}"#).is_err(), + "an effect Core does not implement must be refused, never guessed" + ); + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 08a02af..b23694e 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -9,6 +9,7 @@ pub mod event; pub mod fut_champs; pub mod game_ext; pub mod import; +pub mod instance_effect; pub mod inventory; pub mod market; pub mod match_service; diff --git a/tests/integration_test.rs b/tests/integration_test.rs index df83e19..f63e015 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1115,6 +1115,118 @@ async fn test_quick_sell_owned_card() { assert_eq!(coins_after, coins_before + coins_received); } +/// `POST /consumables/apply` end to end, in the exact wire shape a game host +/// sends: destroy the consumable, move the target's contract counter, surface it +/// on `/collection`, and REPLAY (not re-apply) a retried request. +/// +/// Built on its own pool so a consumable instance can be minted directly — the +/// starter packs only yield players, and Core has no route that creates one. +#[tokio::test] +async fn test_apply_contract_consumable_over_http() { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("migrations"); + let app = openfut_core::build_app(pool.clone(), "data") + .await + .expect("app build"); + auth(&app, "ContractApplier").await; + + let (_, packs) = json_get(&app, "/packs").await; + let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap().to_string(); + let (s, _) = json_post( + &app, + &format!("/packs/open/{pack_id}"), + serde_json::json!({}), + ) + .await; + assert_eq!(s, StatusCode::OK); + + let (_, coll) = json_get(&app, "/collection").await; + let target = coll["collection"][0].clone(); + let target_id = target["owned_card_id"].as_str().unwrap().to_string(); + let card_id = target["card"]["id"].as_str().unwrap().to_string(); + assert!( + target["contract_matches"].is_null(), + "a pack-fresh instance must report NULL, not a substituted default" + ); + + // Mint the consumable into the target's own club, reusing a definition the + // content pack already loaded so `/collection` can still project it. + sqlx::query( + "INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at, content_kind) \ + SELECT 'contract-card', club_id, ?, 0, ?, 'consumable' FROM owned_cards WHERE id = ?", + ) + .bind(&card_id) + .bind("2026-01-01T00:00:00Z") + .bind(&target_id) + .execute(&pool) + .await + .expect("mint a consumable"); + + let request = serde_json::json!({ + "action_identity": format!("fifa17:apply:contract-card->{target_id}"), + "source_owned_card_id": "contract-card", + "target_owned_card_id": target_id, + "target_kind": "player", + "effect": { + "kind": "add_contract_matches", + "amount": 15, + "cap": 99, + "default_when_unset": 7, + }, + }); + let (s, applied) = json_post(&app, "/consumables/apply", request.clone()).await; + assert_eq!(s, StatusCode::OK, "{applied}"); + assert_eq!(applied["applied"], serde_json::json!(true)); + assert_eq!(applied["source_destroyed"], serde_json::json!(true)); + assert!(applied["source_quantity_after"].is_null()); + assert_eq!( + applied["target_owned_card_id"], + serde_json::json!(target_id) + ); + assert_eq!( + applied["effect"], + serde_json::json!({ + "kind": "add_contract_matches", "granted": 15, "before": 7, "after": 22 + }) + ); + + let (_, after) = json_get(&app, "/collection").await; + let items = after["collection"].as_array().unwrap(); + let projected = items + .iter() + .find(|c| c["owned_card_id"] == serde_json::json!(target_id)) + .expect("target still owned"); + assert_eq!(projected["contract_matches"], serde_json::json!(22)); + assert!( + !items + .iter() + .any(|c| c["owned_card_id"] == serde_json::json!("contract-card")), + "the consumable must be spent, not merely marked" + ); + + // A retried request replays: no second grant, and no resurrection of the + // source it already destroyed. + let (s, replay) = json_post(&app, "/consumables/apply", request).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(replay["applied"], serde_json::json!(false)); + assert_eq!(replay["effect"], applied["effect"]); + let (_, twice) = json_get(&app, "/collection").await; + let projected = twice["collection"] + .as_array() + .unwrap() + .iter() + .find(|c| c["owned_card_id"] == serde_json::json!(target_id)) + .expect("target still owned") + .clone(); + assert_eq!(projected["contract_matches"], serde_json::json!(22)); +} + #[tokio::test] async fn test_objective_get_by_id() { let app = build_test_app().await; diff --git a/tests/owned_content_migration_test.rs b/tests/owned_content_migration_test.rs index b9fe958..6cafa76 100644 --- a/tests/owned_content_migration_test.rs +++ b/tests/owned_content_migration_test.rs @@ -1,12 +1,18 @@ //! Owned-content model migrations (0025 content_kind/quantity, 0026 -//! club_active_items, 0027 consumable_applications). +//! club_active_items, 0027 consumable_applications, 0028 contract_matches). //! //! 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); +//! stack size and no tracked contract (the band is a pure widening, never a +//! rewrite and never a backfill of someone else's default); //! * every existing kit designation lands in `club_active_items` under its //! generalised slot token, and the old table + trigger are gone. //! +//! 0028 additionally must NOT be a table rebuild: `owned_cards` carries 0026's +//! `clear_club_active_item_before_transfer` trigger, and a DROP/recreate would +//! take it along silently. The trigger assertions below therefore run AFTER the +//! whole band, not just after 0026. +//! //! 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 @@ -205,6 +211,25 @@ async fn kit_assignments_migrate_into_club_active_items() { assert_eq!(kind, ContentKind::Player); assert_eq!(quantity, None); + // 0028: the column exists and every row that pre-dates it reads back NULL. + // NULL is not zero — it means Core tracks no contract for the instance, so a + // backfill here would have invented one game's pack-fresh number for all of + // them. + let contract = sqlx::query_scalar::<_, Option>( + "SELECT contract_matches FROM owned_cards WHERE id = 'spare'", + ) + .fetch_one(&pool) + .await + .expect("0028 must have added contract_matches"); + assert_eq!(contract, None, "a pre-existing row tracks no contract"); + assert!( + sqlx::query("UPDATE owned_cards SET contract_matches = -1 WHERE id = 'spare'") + .execute(&pool) + .await + .is_err(), + "contract_matches CHECK must reject a negative count" + ); + // And the new column constraints are real, not documentation. assert!( sqlx::query("UPDATE owned_cards SET content_kind = 'coach' WHERE id = 'spare'") @@ -300,10 +325,11 @@ async fn migrations_apply_to_a_real_populated_snapshot() { .await .expect("migrations must apply to real populated data"); - let (after, players, stacked) = sqlx::query_as::<_, (i64, i64, i64)>( + let (after, players, stacked, contracted) = sqlx::query_as::<_, (i64, 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) \ + SUM(CASE WHEN quantity IS NOT NULL THEN 1 ELSE 0 END), \ + SUM(CASE WHEN contract_matches IS NOT NULL THEN 1 ELSE 0 END) \ FROM owned_cards", ) .fetch_one(&pool) @@ -312,6 +338,7 @@ async fn migrations_apply_to_a_real_populated_snapshot() { 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_eq!(contracted, 0, "no pre-existing row gains a contract count"); assert!(table_exists(&pool, "club_active_items").await); assert!(!table_exists(&pool, "club_kit_assignments").await);