diff --git a/src/routes/cards.rs b/src/routes/cards.rs index df68dbc..d67b82b 100644 --- a/src/routes/cards.rs +++ b/src/routes/cards.rs @@ -12,6 +12,7 @@ use crate::{ models::card::OwnedCard, services::{ club as club_svc, + economy as economy_svc, inventory::{self, OwnedItemQuery, OwnedItemView}, profile as profile_svc, }, @@ -184,12 +185,11 @@ pub async fn delete_owned_card( let coins = quick_sell_coins(card.overall); - sqlx::query("DELETE FROM owned_cards WHERE id = ?") - .bind(&owned_card_id) - .execute(&state.pool) - .await?; - - club_svc::add_coins(&state.pool, &club.id, coins).await?; + // Delegate to the economy authority rather than hand-rolling DELETE + add_coins: + // that pair ran on the pool with NO transaction (a failed credit left the card + // destroyed for nothing) and it skipped `squad_players`, whose FK onto + // `owned_cards(id)` made quick-selling a squadded card fail with SQLite 787. + economy_svc::sell_item(&state.pool, &club.id, &owned_card_id, coins).await?; Ok(Json(json!({ "quick_sold": owned_card_id, diff --git a/src/services/economy.rs b/src/services/economy.rs index a4a7bb7..4fd4a2c 100644 --- a/src/services/economy.rs +++ b/src/services/economy.rs @@ -388,11 +388,20 @@ pub async fn redeem_entitlement( } /// Remove an owned item and credit `price`, atomically. Fail-closed: if the item -/// is not owned by the club nothing is credited. +/// is not owned by the club nothing is credited and no lineup is disturbed. +/// +/// The item is dropped from any lineup first. `squad_players.owned_card_id` is a FK +/// onto `owned_cards(id)` and the pool enables `foreign_keys`, so without this a +/// quick sell of a squadded card fails with SQLite error 787 instead of selling it +/// — and selling a card that happens to be in your squad is ordinary, not an edge +/// case. pub async fn sell_item(pool: &Pool, club_id: &str, item_id: &str, price: i64) -> AppResult { let mut conn = pool.acquire().await?; sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?; let result = async { + // Safe to free slots before the ownership check in `remove_item`: both share + // this transaction, so a wrong-owner sale rolls the eviction back with it. + evict_from_squads(&mut conn, item_id).await?; remove_item(&mut conn, club_id, item_id).await?; credit(&mut conn, club_id, price).await } @@ -1191,6 +1200,49 @@ mod tests { assert_eq!(balance(&pool, "club").await.unwrap(), 1250); } + /// Quick-selling a card that is IN A SQUAD must work. + /// + /// `squad_players.owned_card_id` is a FK onto `owned_cards(id)` and the pool + /// enables `foreign_keys`, so deleting a squadded item fails outright. This is + /// the live FIFA 17 quick-sell path (`econ.sell_item` -> `POST + /// /economy/sell-item`), and a player selling a card that is in their lineup is + /// completely ordinary — it is not an edge case. + #[tokio::test] + async fn selling_a_squadded_item_succeeds_and_frees_the_slot() { + let pool = fixture().await; + squad_up(&pool, "club", "item-x").await; + assert_eq!(squad_slot_count(&pool).await, 1); + + let new_balance = sell_item(&pool, "club", "item-x", 250) + .await + .expect("quick sell of a squadded card"); + assert_eq!(new_balance, 1250); + assert_eq!(item_count(&pool, "item-x").await, 0); + assert_eq!( + squad_slot_count(&pool).await, + 0, + "the lineup slot must be freed, not left dangling" + ); + } + + /// A rejected quick sell must not strip the real owner's lineup. Eviction runs + /// before the ownership check, so this proves the shared transaction actually + /// rolls it back rather than leaving a half-applied squad change. + #[tokio::test] + async fn a_rejected_quick_sell_leaves_the_lineup_intact() { + let pool = fixture().await; + squad_up(&pool, "club", "item-x").await; + + assert!(matches!( + sell_item(&pool, "someone-else", "item-x", 250).await, + Err(AppError::NotFound(_)) + )); + + assert_eq!(squad_slot_count(&pool).await, 1, "lineup was disturbed"); + assert_eq!(item_count(&pool, "item-x").await, 1); + assert_eq!(balance(&pool, "club").await.unwrap(), 1000); + } + #[tokio::test] async fn grant_reward_credits() { let pool = fixture().await;