fix(economy): quick-selling a squadded card failed with a FOREIGN KEY error
Found while implementing sale settlement, and reproduced before fixing:
sell_item(pool, "club", "item-x", 250)
-> Database(SqliteError { code: 787, message: "FOREIGN KEY constraint failed" })
squad_players.owned_card_id is a FK onto owned_cards(id) and db.rs enables
foreign_keys, so DELETEing an item that sits in any lineup is refused outright.
services::economy::sell_item never cleared the lineup, and this is the LIVE FIFA 17
quick-sell path (host economy_store -> econ.sell_item -> POST /economy/sell-item).
Selling a card that happens to be in your squad is ordinary, not an edge case.
sell_item now evicts the item from every lineup first, inside its existing
transaction, so a wrong-owner sale rolls the eviction back with everything else
(asserted, not assumed).
Also folds routes/cards.rs::delete_owned_card into the same authority. It
hand-rolled DELETE + club::add_coins directly on the pool, which had BOTH defects:
no transaction, so a failed credit destroyed the card for nothing, and the same
missing squad eviction. Its response shape is unchanged and the pre-existing route
test still passes.
Tests: selling_a_squadded_item_succeeds_and_frees_the_slot (the repro) and
a_rejected_quick_sell_leaves_the_lineup_intact. Core 55 lib + 123 integration pass,
clippy clean.
Not deployed — production is mid live-test.
This commit is contained in:
+6
-6
@@ -12,6 +12,7 @@ use crate::{
|
|||||||
models::card::OwnedCard,
|
models::card::OwnedCard,
|
||||||
services::{
|
services::{
|
||||||
club as club_svc,
|
club as club_svc,
|
||||||
|
economy as economy_svc,
|
||||||
inventory::{self, OwnedItemQuery, OwnedItemView},
|
inventory::{self, OwnedItemQuery, OwnedItemView},
|
||||||
profile as profile_svc,
|
profile as profile_svc,
|
||||||
},
|
},
|
||||||
@@ -184,12 +185,11 @@ pub async fn delete_owned_card(
|
|||||||
|
|
||||||
let coins = quick_sell_coins(card.overall);
|
let coins = quick_sell_coins(card.overall);
|
||||||
|
|
||||||
sqlx::query("DELETE FROM owned_cards WHERE id = ?")
|
// Delegate to the economy authority rather than hand-rolling DELETE + add_coins:
|
||||||
.bind(&owned_card_id)
|
// that pair ran on the pool with NO transaction (a failed credit left the card
|
||||||
.execute(&state.pool)
|
// destroyed for nothing) and it skipped `squad_players`, whose FK onto
|
||||||
.await?;
|
// `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?;
|
||||||
club_svc::add_coins(&state.pool, &club.id, coins).await?;
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"quick_sold": owned_card_id,
|
"quick_sold": owned_card_id,
|
||||||
|
|||||||
+53
-1
@@ -388,11 +388,20 @@ pub async fn redeem_entitlement(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remove an owned item and credit `price`, atomically. Fail-closed: if the item
|
/// 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<i64> {
|
pub async fn sell_item(pool: &Pool, club_id: &str, item_id: &str, price: i64) -> AppResult<i64> {
|
||||||
let mut conn = pool.acquire().await?;
|
let mut conn = pool.acquire().await?;
|
||||||
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||||
let result = async {
|
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?;
|
remove_item(&mut conn, club_id, item_id).await?;
|
||||||
credit(&mut conn, club_id, price).await
|
credit(&mut conn, club_id, price).await
|
||||||
}
|
}
|
||||||
@@ -1191,6 +1200,49 @@ mod tests {
|
|||||||
assert_eq!(balance(&pool, "club").await.unwrap(), 1250);
|
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]
|
#[tokio::test]
|
||||||
async fn grant_reward_credits() {
|
async fn grant_reward_credits() {
|
||||||
let pool = fixture().await;
|
let pool = fixture().await;
|
||||||
|
|||||||
Reference in New Issue
Block a user