feat(economy): atomic market sale settlement — transfer ownership, credit seller, destroy fee
Core had no way to settle a real sale. Every "buy" MINTED a new owned_cards row (services/economy.rs::purchase_item), so a sold card existed twice; the seller was never credited; no fee arithmetic existed anywhere in the project; and no /economy/* route could even name a counterparty, since all eight resolve one club from the X-OpenFUT-Game active profile. Adds settle_sale() beside the existing tx-scoped primitives, so it inherits the module's proven atomicity (pool.acquire + BEGIN IMMEDIATE + finish) rather than re-deriving it: debit buyer gross -> evict from squads -> transfer the EXISTING row -> credit seller gross-fee. The fee is simply never credited anywhere, which is what destroys it. Exposed as POST /economy/settle-sale, the one economy route that names clubs explicitly because a sale has two sides; an omitted buyer means a counterparty OUTSIDE the modelled economy, never a silent fallback to the active club (which would settle a club against itself). Ownership moves by UPDATE ... WHERE id = ? AND club_id = ?, an ownership CAS. No INSERT and no DELETE on the two-party path, so duplication is ruled out structurally, not by an assertion. Two bugs found by writing the tests rather than by reading the code: 1. Deriving the seller from CURRENT ownership let two racing buyers BOTH succeed — after the first sale the item belonged to B, so the second call read B as the seller and chain-sold it B -> C. Core has no listing concept and could not notice the replay. The seller is now the caller's EXPECTED owner and every ownership statement is predicated on it, which makes the CAS authoritative about "already sold" independently of caller-side listing state. 2. Debiting before transferring made a replay fail as "insufficient balance" (the buyer had spent the coins on the sale that succeeded), so the ownership guard was shadowed and settling_the_same_sale_twice_pays_once passed WITHOUT exercising the guard it named. Ownership is now judged first; the test asserts NotFound specifically and adds a cheap affordable replay that only ownership can refuse. Squad eviction is mandatory, not cosmetic: squad_players.owned_card_id is a FK and the pool enables foreign_keys, so an Outside sale of a squadded card would fail outright, and a transfer preserves the row id so a stale lineup row would leave the PREVIOUS owner fielding a card they no longer own. Tests: 21 service (canonical 15,000/750/14,250 fixture, conservation, squad eviction, Outside retirement, 8 invalid paths, zero-price, replay, two-buyer race) + 6 route-level over HTTP with two parties. Core 194 pass. Deliberately NOT done: no wire/route output change, no deployment, and nothing yet decides that a player's listing has sold — the seller-facing sold wire state needs live client evidence and must not be guessed.
This commit is contained in:
@@ -198,6 +198,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||
"/economy/purchase-items",
|
||||
post(routes::economy::post_purchase_items),
|
||||
)
|
||||
.route(
|
||||
"/economy/settle-sale",
|
||||
post(routes::economy::post_settle_sale),
|
||||
)
|
||||
.route(
|
||||
"/collection/:owned_card_id",
|
||||
delete(routes::cards::delete_owned_card),
|
||||
|
||||
@@ -161,3 +161,60 @@ pub async fn post_purchase_items(
|
||||
let balance = economy::purchase_items(&state.pool, &club, req.cost, &req.items).await?;
|
||||
Ok(Json(BalanceResponse { balance }))
|
||||
}
|
||||
|
||||
/// `POST /economy/settle-sale` request.
|
||||
///
|
||||
/// This is the ONE economy route that names clubs explicitly, and it has to: a
|
||||
/// market sale has two sides, and the module's active-profile resolution can only
|
||||
/// ever describe one. Both are optional and default to the game-scoped active
|
||||
/// club, so the single-player case stays as terse as every other route:
|
||||
///
|
||||
/// * `seller_club_id` omitted -> the active club is the seller (it listed the
|
||||
/// item), which is the production shape.
|
||||
/// * `buyer_club_id` omitted -> the counterparty is OUTSIDE the modelled
|
||||
/// economy: no balance is debited and the item leaves the inventory. It does
|
||||
/// NOT silently fall back to the active club, because that would settle a sale
|
||||
/// between a club and itself.
|
||||
#[derive(Deserialize)]
|
||||
pub struct SettleSaleRequest {
|
||||
/// The authoritative owned-item instance changing hands.
|
||||
pub item_id: String,
|
||||
/// What the buyer pays. The fee is withheld from this, never added to it.
|
||||
pub gross: i64,
|
||||
/// Withheld from the seller and destroyed. The RATE is a per-game policy the
|
||||
/// caller owns; Core only checks `0 <= fee <= gross`.
|
||||
pub fee: i64,
|
||||
#[serde(default)]
|
||||
pub seller_club_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub buyer_club_id: Option<String>,
|
||||
}
|
||||
|
||||
/// `POST /economy/settle-sale` — atomically debit the buyer, transfer the existing
|
||||
/// item, credit the seller net of the fee, and destroy the fee.
|
||||
pub async fn post_settle_sale(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<SettleSaleRequest>,
|
||||
) -> AppResult<Json<economy::SaleReceipt>> {
|
||||
let seller = match req.seller_club_id {
|
||||
Some(id) => id,
|
||||
None => resolve_club(&state, &game).await?,
|
||||
};
|
||||
let buyer = match req.buyer_club_id.as_deref() {
|
||||
Some(id) => economy::SaleBuyer::Club(id),
|
||||
None => economy::SaleBuyer::Outside,
|
||||
};
|
||||
let receipt = economy::settle_sale(
|
||||
&state.pool,
|
||||
&req.item_id,
|
||||
&seller,
|
||||
buyer,
|
||||
economy::SaleTerms {
|
||||
gross: req.gross,
|
||||
fee: req.fee,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(receipt))
|
||||
}
|
||||
|
||||
@@ -183,6 +183,68 @@ async fn remove_item(
|
||||
Ok(card_id)
|
||||
}
|
||||
|
||||
/// Drop `item_id` out of every lineup that references it.
|
||||
///
|
||||
/// REQUIRED before an item changes owner or leaves the inventory, for two
|
||||
/// independent reasons:
|
||||
///
|
||||
/// * `squad_players.owned_card_id` is a FK onto `owned_cards(id)` and the pool
|
||||
/// enables `foreign_keys`, so DELETEing a squadded item fails outright;
|
||||
/// * on a transfer the row id is preserved, so a stale `squad_players` row
|
||||
/// would leave the PREVIOUS owner fielding a card they no longer own.
|
||||
///
|
||||
/// Every existing reference is invalid the moment ownership moves, so this is
|
||||
/// scoped by item rather than by club. Returns the number of lineup slots freed.
|
||||
async fn evict_from_squads(conn: &mut SqliteConnection, item_id: &str) -> AppResult<u64> {
|
||||
Ok(
|
||||
sqlx::query("DELETE FROM squad_players WHERE owned_card_id = ?")
|
||||
.bind(item_id)
|
||||
.execute(&mut *conn)
|
||||
.await?
|
||||
.rows_affected(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Reassign one existing owned item from `from_club` to `to_club`, returning its
|
||||
/// definition ref.
|
||||
///
|
||||
/// This is a TRANSFER, not a mint: there is no INSERT, so the instance id and its
|
||||
/// upgrade state survive and the inventory row count is unchanged. The
|
||||
/// `club_id = from_club` predicate makes the UPDATE an ownership compare-and-swap
|
||||
/// — if a concurrent settlement moved the item first, `rows_affected` is 0 and
|
||||
/// this is a [`AppError::Conflict`] rather than a second transfer.
|
||||
///
|
||||
/// Callers MUST have run [`evict_from_squads`] first: the row id is preserved, so
|
||||
/// any surviving lineup reference would belong to the previous owner.
|
||||
async fn transfer_item(
|
||||
conn: &mut SqliteConnection,
|
||||
item_id: &str,
|
||||
from_club: &str,
|
||||
to_club: &str,
|
||||
) -> AppResult<String> {
|
||||
let card_id = sqlx::query_scalar::<_, String>(
|
||||
"SELECT card_id FROM owned_cards WHERE id = ? AND club_id = ?",
|
||||
)
|
||||
.bind(item_id)
|
||||
.bind(from_club)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("item not owned by club: {item_id}")))?;
|
||||
let affected = sqlx::query("UPDATE owned_cards SET club_id = ? WHERE id = ? AND club_id = ?")
|
||||
.bind(to_club)
|
||||
.bind(item_id)
|
||||
.bind(from_club)
|
||||
.execute(&mut *conn)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if affected != 1 {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"ownership of {item_id} changed during settlement"
|
||||
)));
|
||||
}
|
||||
Ok(card_id)
|
||||
}
|
||||
|
||||
// ---- composed atomic operations ----------------------------------------------
|
||||
|
||||
/// Read a club's current currency balance.
|
||||
@@ -346,6 +408,155 @@ pub async fn grant_reward(pool: &Pool, club_id: &str, amount: i64) -> AppResult<
|
||||
finish(&mut conn, result).await
|
||||
}
|
||||
|
||||
/// Who acquires the item in a settled sale.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SaleBuyer<'a> {
|
||||
/// A club held by this Core: its balance is debited `gross` and it becomes
|
||||
/// the item's owner. Coins move BETWEEN modelled balances, so the economy
|
||||
/// only loses the fee.
|
||||
Club(&'a str),
|
||||
/// A counterparty outside the modelled economy (e.g. a synthetic market
|
||||
/// buyer, which owns no `clubs` row). Nothing is debited and the item leaves
|
||||
/// the inventory. The seller's proceeds therefore ENTER the economy from
|
||||
/// outside — the accounting invariant differs from [`SaleBuyer::Club`] and
|
||||
/// the caller is responsible for wanting that.
|
||||
Outside,
|
||||
}
|
||||
|
||||
/// Money terms of a sale. `fee` is supplied by the caller, never computed here:
|
||||
/// the rate is a per-game policy constant and Core is game-neutral.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SaleTerms {
|
||||
/// What the buyer pays.
|
||||
pub gross: i64,
|
||||
/// Withheld from the seller and destroyed. `0 <= fee <= gross`.
|
||||
pub fee: i64,
|
||||
}
|
||||
|
||||
/// What a settlement did, for the caller to render and for audit.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SaleReceipt {
|
||||
pub item_id: String,
|
||||
pub card_id: String,
|
||||
pub seller_club_id: String,
|
||||
pub buyer_club_id: Option<String>,
|
||||
pub gross: i64,
|
||||
pub fee: i64,
|
||||
/// `gross - fee`, credited to the seller.
|
||||
pub proceeds: i64,
|
||||
pub seller_balance: i64,
|
||||
/// Post-debit buyer balance; `None` for [`SaleBuyer::Outside`].
|
||||
pub buyer_balance: Option<i64>,
|
||||
/// Lineup slots freed because the item changed hands.
|
||||
pub squad_slots_freed: u64,
|
||||
}
|
||||
|
||||
/// Settle a completed market sale as ONE atomic transaction: debit the buyer,
|
||||
/// move the EXISTING item, credit the seller their proceeds, destroy the fee.
|
||||
///
|
||||
/// `seller_club_id` is the club the CALLER believes owns the item, and every
|
||||
/// ownership statement is predicated on it. Core deliberately does NOT infer the
|
||||
/// seller from current ownership: doing so makes a replayed settlement look like a
|
||||
/// brand-new sale by the item's new owner, and Core has no listing concept with
|
||||
/// which to notice. Pinning the expected seller turns the ownership UPDATE into a
|
||||
/// compare-and-swap that is the authority on "this sale has already happened",
|
||||
/// independent of any caller-side listing state. A caller still cannot credit a
|
||||
/// club that did not own the item, because the credit only follows a matched CAS.
|
||||
///
|
||||
/// Ownership moves by UPDATE — no row is inserted or deleted on the
|
||||
/// [`SaleBuyer::Club`] path, which is what structurally rules out the duplication
|
||||
/// a mint-based "buy" causes.
|
||||
///
|
||||
/// Fail-closed and all-or-nothing. Rejected without touching any balance:
|
||||
/// negative `gross`/`fee`, `fee > gross`, an item the named seller does not own
|
||||
/// (including a replay, where ownership has already moved), a buyer that cannot
|
||||
/// afford `gross`, or a buyer that is already the seller (not a market path — it
|
||||
/// would otherwise credit and debit the same club and destroy the fee for nothing).
|
||||
///
|
||||
/// Ownership is judged BEFORE affordability, so a replay is reported as "not owned"
|
||||
/// rather than as the buyer being broke from the sale that already succeeded.
|
||||
///
|
||||
/// Coin conservation for [`SaleBuyer::Club`]: `gross` leaves the buyer, `gross -
|
||||
/// fee` reaches the seller, and the economy shrinks by exactly `fee`.
|
||||
pub async fn settle_sale(
|
||||
pool: &Pool,
|
||||
item_id: &str,
|
||||
seller_club_id: &str,
|
||||
buyer: SaleBuyer<'_>,
|
||||
terms: SaleTerms,
|
||||
) -> AppResult<SaleReceipt> {
|
||||
let SaleTerms { gross, fee } = terms;
|
||||
if gross < 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"sale gross must be non-negative".into(),
|
||||
));
|
||||
}
|
||||
if fee < 0 {
|
||||
return Err(AppError::BadRequest("sale fee must be non-negative".into()));
|
||||
}
|
||||
if fee > gross {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"sale fee {fee} exceeds gross {gross}"
|
||||
)));
|
||||
}
|
||||
if let SaleBuyer::Club(buyer_club) = buyer {
|
||||
if buyer_club == seller_club_id {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"buyer and seller are the same club: {buyer_club}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let proceeds = gross - fee;
|
||||
let mut conn = pool.acquire().await?;
|
||||
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||
let result = async {
|
||||
let (card_id, buyer_club_id, buyer_balance, squad_slots_freed) = match buyer {
|
||||
SaleBuyer::Club(buyer_club) => {
|
||||
// Ownership is checked FIRST, and the order matters for the REASON a
|
||||
// rejection carries even though it cannot change the final state
|
||||
// (everything here shares one transaction, so any error rolls the
|
||||
// whole thing back either way).
|
||||
//
|
||||
// Debiting first made a replayed settlement fail as "insufficient
|
||||
// balance": after a sale the buyer has already spent the coins, so the
|
||||
// affordability guard fired before the ownership CAS was ever
|
||||
// consulted. That is a misleading answer to "why was this refused",
|
||||
// and a retry test written against it passes without ever exercising
|
||||
// the replay guard it claims to test.
|
||||
let freed = evict_from_squads(&mut conn, item_id).await?;
|
||||
let card_id = transfer_item(&mut conn, item_id, seller_club_id, buyer_club).await?;
|
||||
let buyer_balance = debit(&mut conn, buyer_club, gross).await?;
|
||||
(
|
||||
card_id,
|
||||
Some(buyer_club.to_string()),
|
||||
Some(buyer_balance),
|
||||
freed,
|
||||
)
|
||||
}
|
||||
SaleBuyer::Outside => {
|
||||
let freed = evict_from_squads(&mut conn, item_id).await?;
|
||||
let card_id = remove_item(&mut conn, seller_club_id, item_id).await?;
|
||||
(card_id, None, None, freed)
|
||||
}
|
||||
};
|
||||
let seller_balance = credit(&mut conn, seller_club_id, proceeds).await?;
|
||||
Ok(SaleReceipt {
|
||||
item_id: item_id.to_string(),
|
||||
card_id,
|
||||
seller_club_id: seller_club_id.to_string(),
|
||||
buyer_club_id,
|
||||
gross,
|
||||
fee,
|
||||
proceeds,
|
||||
seller_balance,
|
||||
buyer_balance,
|
||||
squad_slots_freed,
|
||||
})
|
||||
}
|
||||
.await;
|
||||
finish(&mut conn, result).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -409,6 +620,442 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Two-party market fixture. Deliberately NOT the single-club `fixture()`:
|
||||
/// settling a sale where the buyer is also the seller is not a market path and
|
||||
/// would hide every ownership bug this suite exists to catch.
|
||||
///
|
||||
/// Seller `club-a` holds 1_000 coins and owns `item-x`; buyer `club-b` holds
|
||||
/// 20_000 and owns nothing. Profiles are seeded by raw SQL, which sidesteps the
|
||||
/// one-profile-per-game guard in `services::profile`.
|
||||
async fn market_fixture() -> Pool {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("in-memory sqlite");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("migrations");
|
||||
for (profile, club, coins) in [("prof-a", "club-a", 1_000i64), ("prof-b", "club-b", 20_000)]
|
||||
{
|
||||
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 (?, ?, ?, ?, ?, ?)")
|
||||
.bind(club)
|
||||
.bind(profile)
|
||||
.bind(club)
|
||||
.bind(coins)
|
||||
.bind(TS)
|
||||
.bind(TS)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("club");
|
||||
}
|
||||
sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)")
|
||||
.bind("item-x")
|
||||
.bind("club-a")
|
||||
.bind("def-x")
|
||||
.bind(TS)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("owned card");
|
||||
pool
|
||||
}
|
||||
|
||||
async fn owner(pool: &Pool, item_id: &str) -> Option<String> {
|
||||
sqlx::query_scalar::<_, String>("SELECT club_id FROM owned_cards WHERE id = ?")
|
||||
.bind(item_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Total coins across every modelled balance — the quantity a sale between two
|
||||
/// clubs must reduce by EXACTLY the fee.
|
||||
async fn total_coins(pool: &Pool) -> i64 {
|
||||
sqlx::query_scalar::<_, i64>("SELECT COALESCE(SUM(coins), 0) FROM clubs")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Put `item-x` in a squad owned by `club_id`, returning nothing. Used to prove
|
||||
/// a sold card cannot stay in the previous owner's lineup.
|
||||
async fn squad_up(pool: &Pool, club_id: &str, item_id: &str) {
|
||||
sqlx::query("INSERT INTO squads (id, club_id, name, formation, created_at, updated_at) VALUES (?, ?, 'S', '4-4-2', ?, ?)")
|
||||
.bind("squad-1")
|
||||
.bind(club_id)
|
||||
.bind(TS)
|
||||
.bind(TS)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("squad");
|
||||
sqlx::query("INSERT INTO squad_players (id, squad_id, owned_card_id, position_index) VALUES (?, ?, ?, 0)")
|
||||
.bind("sp-1")
|
||||
.bind("squad-1")
|
||||
.bind(item_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("squad player");
|
||||
}
|
||||
|
||||
async fn squad_slot_count(pool: &Pool) -> i64 {
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM squad_players")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
const CANON_GROSS: i64 = 15_000;
|
||||
const CANON_FEE: i64 = 750;
|
||||
|
||||
/// THE canonical settlement: gross 15_000, fee 750, proceeds 14_250.
|
||||
#[tokio::test]
|
||||
async fn settle_sale_transfers_ownership_and_splits_coins() {
|
||||
let pool = market_fixture().await;
|
||||
assert_eq!(total_coins(&pool).await, 21_000);
|
||||
|
||||
let receipt = settle_sale(
|
||||
&pool,
|
||||
"item-x",
|
||||
"club-a",
|
||||
SaleBuyer::Club("club-b"),
|
||||
SaleTerms {
|
||||
gross: CANON_GROSS,
|
||||
fee: CANON_FEE,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("settlement");
|
||||
|
||||
assert_eq!(
|
||||
receipt.seller_club_id, "club-a",
|
||||
"seller derived from owner"
|
||||
);
|
||||
assert_eq!(receipt.buyer_club_id.as_deref(), Some("club-b"));
|
||||
assert_eq!(receipt.card_id, "def-x");
|
||||
assert_eq!(receipt.gross, 15_000);
|
||||
assert_eq!(receipt.fee, 750);
|
||||
assert_eq!(receipt.proceeds, 14_250);
|
||||
assert_eq!(receipt.buyer_balance, Some(5_000), "20_000 - 15_000");
|
||||
assert_eq!(receipt.seller_balance, 15_250, "1_000 + 14_250");
|
||||
|
||||
assert_eq!(balance(&pool, "club-b").await.unwrap(), 5_000);
|
||||
assert_eq!(balance(&pool, "club-a").await.unwrap(), 15_250);
|
||||
assert_eq!(owner(&pool, "item-x").await.as_deref(), Some("club-b"));
|
||||
// The decisive anti-duplication assertion: ONE authoritative instance,
|
||||
// the same id as before. A mint-based buy would make this 2.
|
||||
assert_eq!(item_count(&pool, "item-x").await, 1);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap(),
|
||||
1,
|
||||
"no second row anywhere in the inventory"
|
||||
);
|
||||
}
|
||||
|
||||
/// buyer_debit == seller_credit + fee, and the economy shrinks by exactly the
|
||||
/// fee. This is the invariant that catches a coin created or destroyed by a
|
||||
/// rounding or ordering mistake.
|
||||
#[tokio::test]
|
||||
async fn sale_conserves_coins_minus_the_fee() {
|
||||
let pool = market_fixture().await;
|
||||
let before = total_coins(&pool).await;
|
||||
let receipt = settle_sale(
|
||||
&pool,
|
||||
"item-x",
|
||||
"club-a",
|
||||
SaleBuyer::Club("club-b"),
|
||||
SaleTerms {
|
||||
gross: CANON_GROSS,
|
||||
fee: CANON_FEE,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let after = total_coins(&pool).await;
|
||||
|
||||
assert_eq!(before, 21_000);
|
||||
assert_eq!(after, 20_250);
|
||||
assert_eq!(before - after, receipt.fee, "economy shrinks by the fee");
|
||||
assert_eq!(
|
||||
receipt.gross,
|
||||
receipt.proceeds + receipt.fee,
|
||||
"buyer debit == seller credit + fee"
|
||||
);
|
||||
}
|
||||
|
||||
/// A sold card must leave the previous owner's lineup. Without eviction the
|
||||
/// seller keeps fielding a card the buyer owns (and on the `Outside` path the
|
||||
/// FK makes the delete fail outright).
|
||||
#[tokio::test]
|
||||
async fn sale_evicts_the_item_from_the_sellers_squad() {
|
||||
let pool = market_fixture().await;
|
||||
squad_up(&pool, "club-a", "item-x").await;
|
||||
assert_eq!(squad_slot_count(&pool).await, 1);
|
||||
|
||||
let receipt = settle_sale(
|
||||
&pool,
|
||||
"item-x",
|
||||
"club-a",
|
||||
SaleBuyer::Club("club-b"),
|
||||
SaleTerms {
|
||||
gross: 1_000,
|
||||
fee: 50,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(receipt.squad_slots_freed, 1);
|
||||
assert_eq!(
|
||||
squad_slot_count(&pool).await,
|
||||
0,
|
||||
"stale lineup slot survived"
|
||||
);
|
||||
assert_eq!(owner(&pool, "item-x").await.as_deref(), Some("club-b"));
|
||||
}
|
||||
|
||||
/// Selling to a counterparty outside the modelled economy: the seller is paid
|
||||
/// net and the item leaves the inventory. No club is debited, so the seller's
|
||||
/// proceeds legitimately ENTER the economy.
|
||||
#[tokio::test]
|
||||
async fn settle_sale_to_outside_retires_the_item_and_pays_net() {
|
||||
let pool = market_fixture().await;
|
||||
squad_up(&pool, "club-a", "item-x").await;
|
||||
|
||||
let receipt = settle_sale(
|
||||
&pool,
|
||||
"item-x",
|
||||
"club-a",
|
||||
SaleBuyer::Outside,
|
||||
SaleTerms {
|
||||
gross: CANON_GROSS,
|
||||
fee: CANON_FEE,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(receipt.buyer_club_id, None);
|
||||
assert_eq!(receipt.buyer_balance, None);
|
||||
assert_eq!(receipt.seller_balance, 15_250);
|
||||
assert_eq!(receipt.squad_slots_freed, 1);
|
||||
assert_eq!(item_count(&pool, "item-x").await, 0, "item was retired");
|
||||
assert_eq!(balance(&pool, "club-b").await.unwrap(), 20_000, "untouched");
|
||||
// Buyer coins are not modelled, so total coins RISE by the proceeds here.
|
||||
assert_eq!(total_coins(&pool).await, 21_000 + 14_250);
|
||||
}
|
||||
|
||||
/// Every invalid sale must leave the economy bit-identical.
|
||||
#[tokio::test]
|
||||
async fn invalid_sales_change_nothing() {
|
||||
let terms = SaleTerms {
|
||||
gross: CANON_GROSS,
|
||||
fee: CANON_FEE,
|
||||
};
|
||||
// (label, item, buyer, terms) -> must fail without mutating anything
|
||||
let cases: Vec<(&str, &str, &str, SaleBuyer<'_>, SaleTerms)> = vec![
|
||||
(
|
||||
"buyer cannot afford",
|
||||
"item-x",
|
||||
"club-a",
|
||||
SaleBuyer::Club("club-b"),
|
||||
SaleTerms {
|
||||
gross: 20_001,
|
||||
fee: 0,
|
||||
},
|
||||
),
|
||||
(
|
||||
"buyer is the seller",
|
||||
"item-x",
|
||||
"club-a",
|
||||
SaleBuyer::Club("club-a"),
|
||||
terms,
|
||||
),
|
||||
(
|
||||
"item does not exist",
|
||||
"ghost",
|
||||
"club-a",
|
||||
SaleBuyer::Club("club-b"),
|
||||
terms,
|
||||
),
|
||||
(
|
||||
"buyer club does not exist",
|
||||
"item-x",
|
||||
"club-a",
|
||||
SaleBuyer::Club("ghost"),
|
||||
terms,
|
||||
),
|
||||
(
|
||||
"fee exceeds gross",
|
||||
"item-x",
|
||||
"club-a",
|
||||
SaleBuyer::Club("club-b"),
|
||||
SaleTerms {
|
||||
gross: 100,
|
||||
fee: 101,
|
||||
},
|
||||
),
|
||||
(
|
||||
"negative fee",
|
||||
"item-x",
|
||||
"club-a",
|
||||
SaleBuyer::Club("club-b"),
|
||||
SaleTerms {
|
||||
gross: 100,
|
||||
fee: -1,
|
||||
},
|
||||
),
|
||||
(
|
||||
"negative gross",
|
||||
"item-x",
|
||||
"club-a",
|
||||
SaleBuyer::Club("club-b"),
|
||||
SaleTerms { gross: -1, fee: 0 },
|
||||
),
|
||||
(
|
||||
"outside sale of a missing item",
|
||||
"ghost",
|
||||
"club-a",
|
||||
SaleBuyer::Outside,
|
||||
terms,
|
||||
),
|
||||
];
|
||||
for (label, item, seller, buyer, terms) in cases {
|
||||
let pool = market_fixture().await;
|
||||
let err = settle_sale(&pool, item, seller, buyer, terms).await;
|
||||
assert!(err.is_err(), "{label}: expected rejection");
|
||||
assert_eq!(balance(&pool, "club-a").await.unwrap(), 1_000, "{label}");
|
||||
assert_eq!(balance(&pool, "club-b").await.unwrap(), 20_000, "{label}");
|
||||
assert_eq!(
|
||||
owner(&pool, "item-x").await.as_deref(),
|
||||
Some("club-a"),
|
||||
"{label}: ownership moved on a rejected sale"
|
||||
);
|
||||
assert_eq!(item_count(&pool, "item-x").await, 1, "{label}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A zero-price sale is legal (a free transfer) and pays no fee.
|
||||
#[tokio::test]
|
||||
async fn zero_price_sale_is_a_free_transfer() {
|
||||
let pool = market_fixture().await;
|
||||
let receipt = settle_sale(
|
||||
&pool,
|
||||
"item-x",
|
||||
"club-a",
|
||||
SaleBuyer::Club("club-b"),
|
||||
SaleTerms { gross: 0, fee: 0 },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(receipt.proceeds, 0);
|
||||
assert_eq!(total_coins(&pool).await, 21_000, "no coins moved");
|
||||
assert_eq!(owner(&pool, "item-x").await.as_deref(), Some("club-b"));
|
||||
}
|
||||
|
||||
/// Settling the SAME sale twice must not pay the seller twice, and must be
|
||||
/// refused for the RIGHT reason: the item is no longer the seller's.
|
||||
///
|
||||
/// Asserting only "the second call failed" is not enough, and this test used to
|
||||
/// make exactly that mistake. When the buyer's debit ran first, a replay of the
|
||||
/// canonical sale was rejected because the buyer had already spent the coins —
|
||||
/// the ownership CAS was never reached, so the test passed while proving nothing
|
||||
/// about replay safety. Both a same-price replay and a cheap AFFORDABLE replay
|
||||
/// are checked, and the error must name ownership in each.
|
||||
#[tokio::test]
|
||||
async fn settling_the_same_sale_twice_pays_once() {
|
||||
let pool = market_fixture().await;
|
||||
let terms = SaleTerms {
|
||||
gross: CANON_GROSS,
|
||||
fee: CANON_FEE,
|
||||
};
|
||||
settle_sale(&pool, "item-x", "club-a", SaleBuyer::Club("club-b"), terms)
|
||||
.await
|
||||
.expect("first settlement");
|
||||
|
||||
for (label, replay) in [
|
||||
("same price", terms),
|
||||
// Trivially affordable out of the 5_000 the buyer has left, so ownership
|
||||
// is the ONLY thing that can refuse it.
|
||||
("affordable", SaleTerms { gross: 100, fee: 5 }),
|
||||
] {
|
||||
let again =
|
||||
settle_sale(&pool, "item-x", "club-a", SaleBuyer::Club("club-b"), replay).await;
|
||||
assert!(
|
||||
matches!(again, Err(AppError::NotFound(_))),
|
||||
"{label} replay must be refused on OWNERSHIP, got {again:?}"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(balance(&pool, "club-a").await.unwrap(), 15_250, "paid once");
|
||||
assert_eq!(
|
||||
balance(&pool, "club-b").await.unwrap(),
|
||||
5_000,
|
||||
"debited once"
|
||||
);
|
||||
assert_eq!(item_count(&pool, "item-x").await, 1);
|
||||
assert_eq!(total_coins(&pool).await, 20_250, "fee taken once");
|
||||
}
|
||||
|
||||
/// Two buyers racing the same listing: exactly one wins, and the loser's
|
||||
/// balance is untouched. This is where a market bug becomes a duplication
|
||||
/// exploit, so it is asserted on the authoritative state, not on call counts.
|
||||
#[tokio::test]
|
||||
async fn two_buyers_racing_one_item_settle_once() {
|
||||
let pool = market_fixture().await;
|
||||
sqlx::query("INSERT INTO profiles (id, username, created_at, updated_at) VALUES ('prof-c','prof-c',?,?)")
|
||||
.bind(TS).bind(TS).execute(&pool).await.unwrap();
|
||||
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES ('club-c','prof-c','club-c',20000,?,?)")
|
||||
.bind(TS).bind(TS).execute(&pool).await.unwrap();
|
||||
|
||||
let terms = SaleTerms {
|
||||
gross: CANON_GROSS,
|
||||
fee: CANON_FEE,
|
||||
};
|
||||
let (b, c) = tokio::join!(
|
||||
settle_sale(&pool, "item-x", "club-a", SaleBuyer::Club("club-b"), terms),
|
||||
settle_sale(&pool, "item-x", "club-a", SaleBuyer::Club("club-c"), terms),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
[b.is_ok(), c.is_ok()].iter().filter(|ok| **ok).count(),
|
||||
1,
|
||||
"exactly one buyer may win"
|
||||
);
|
||||
assert_eq!(
|
||||
item_count(&pool, "item-x").await,
|
||||
1,
|
||||
"no duplicate instance"
|
||||
);
|
||||
let winner = owner(&pool, "item-x").await.expect("item still owned");
|
||||
assert!(winner == "club-b" || winner == "club-c");
|
||||
let loser = if winner == "club-b" {
|
||||
"club-c"
|
||||
} else {
|
||||
"club-b"
|
||||
};
|
||||
assert_eq!(
|
||||
balance(&pool, loser).await.unwrap(),
|
||||
20_000,
|
||||
"the losing buyer must be untouched"
|
||||
);
|
||||
assert_eq!(balance(&pool, &winner).await.unwrap(), 5_000);
|
||||
assert_eq!(balance(&pool, "club-a").await.unwrap(), 15_250, "paid once");
|
||||
assert_eq!(total_coins(&pool).await, 40_250, "41_000 - 750 fee, once");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn balance_reads_seeded_value() {
|
||||
let pool = fixture().await;
|
||||
|
||||
@@ -2848,3 +2848,346 @@ async fn test_economy_purchase_items_debits_and_mints_all() {
|
||||
let (_, bal) = json_get(&app, "/economy/balance").await;
|
||||
assert_eq!(bal["balance"], 4200);
|
||||
}
|
||||
|
||||
// ---- transfer-market settlement fixtures -------------------------------------
|
||||
// Two parties, because a club buying its own listing is not a market path and
|
||||
// would hide every ownership bug these tests exist to catch. Each party gets its
|
||||
// OWN game id so that `X-OpenFUT-Game` can address either club's balance through
|
||||
// the normal active-profile resolver.
|
||||
|
||||
/// Seeded `created_at` for the market parties. Must be LATER than the rival row
|
||||
/// dated `2020-01-01` in the omitted-seller test, whose whole point is that the
|
||||
/// earliest row for a game is the active one.
|
||||
const MKT_TS: &str = "2026-01-01T00:00:00Z";
|
||||
const SELLER_GAME: &str = "mkt-a";
|
||||
const BUYER_GAME: &str = "mkt-b";
|
||||
const SELLER_CLUB: &str = "mkt-club-a";
|
||||
const BUYER_CLUB: &str = "mkt-club-b";
|
||||
const MKT_ITEM: &str = "mkt-item-x";
|
||||
const MKT_CARD: &str = "mkt-def-x";
|
||||
/// The canonical sale: 15_000 gross, 750 fee (floored 5%), 14_250 to the seller.
|
||||
const CANON_GROSS: i64 = 15_000;
|
||||
const CANON_FEE: i64 = 750;
|
||||
|
||||
/// Seed one profile + its club directly. `created_at` fixes activation order
|
||||
/// (the active profile for a game is its earliest row).
|
||||
async fn seed_party(
|
||||
pool: &sqlx::SqlitePool,
|
||||
profile_id: &str,
|
||||
game_id: &str,
|
||||
club_id: &str,
|
||||
coins: i64,
|
||||
created_at: &str,
|
||||
) {
|
||||
sqlx::query(
|
||||
"INSERT INTO profiles (id, username, game_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(profile_id)
|
||||
.bind(profile_id)
|
||||
.bind(game_id)
|
||||
.bind(created_at)
|
||||
.bind(created_at)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("profile");
|
||||
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind(club_id)
|
||||
.bind(profile_id)
|
||||
.bind(club_id)
|
||||
.bind(coins)
|
||||
.bind(created_at)
|
||||
.bind(created_at)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("club");
|
||||
}
|
||||
|
||||
async fn seed_owned(pool: &sqlx::SqlitePool, item_id: &str, club_id: &str, card_id: &str) {
|
||||
sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)")
|
||||
.bind(item_id)
|
||||
.bind(club_id)
|
||||
.bind(card_id)
|
||||
.bind(MKT_TS)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("owned card");
|
||||
}
|
||||
|
||||
/// THE canonical fixture: seller 1_000 owning `mkt-item-x`, buyer 20_000.
|
||||
async fn seed_market(pool: &sqlx::SqlitePool) {
|
||||
seed_party(pool, "mkt-prof-a", SELLER_GAME, SELLER_CLUB, 1_000, MKT_TS).await;
|
||||
seed_party(pool, "mkt-prof-b", BUYER_GAME, BUYER_CLUB, 20_000, MKT_TS).await;
|
||||
seed_owned(pool, MKT_ITEM, SELLER_CLUB, MKT_CARD).await;
|
||||
}
|
||||
|
||||
async fn club_coins(pool: &sqlx::SqlitePool, club_id: &str) -> i64 {
|
||||
sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
||||
.bind(club_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn item_owner(pool: &sqlx::SqlitePool, item_id: &str) -> Option<String> {
|
||||
sqlx::query_scalar::<_, String>("SELECT club_id FROM owned_cards WHERE id = ?")
|
||||
.bind(item_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn item_row_count(pool: &sqlx::SqlitePool, item_id: &str) -> i64 {
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards WHERE id = ?")
|
||||
.bind(item_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Total modelled coins — a two-club sale must shrink this by exactly the fee.
|
||||
async fn all_club_coins(pool: &sqlx::SqlitePool) -> i64 {
|
||||
sqlx::query_scalar::<_, i64>("SELECT COALESCE(SUM(coins), 0) FROM clubs")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// `json_get` for a specific game, so each seeded club can be read over HTTP.
|
||||
async fn json_get_as_game(app: &axum::Router, uri: &str, game: &str) -> (StatusCode, Value) {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(uri)
|
||||
.header("x-openfut-game", game)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
(status, serde_json::from_slice(&body).unwrap())
|
||||
}
|
||||
|
||||
fn settle_body(gross: i64, fee: i64, seller: Option<&str>, buyer: Option<&str>) -> Value {
|
||||
let mut body = serde_json::json!({"item_id": MKT_ITEM, "gross": gross, "fee": fee});
|
||||
if let Some(seller) = seller {
|
||||
body["seller_club_id"] = seller.into();
|
||||
}
|
||||
if let Some(buyer) = buyer {
|
||||
body["buyer_club_id"] = buyer.into();
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_economy_settle_sale_route_two_party_sale() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
seed_market(&pool).await;
|
||||
// A third, solvent club: the double-sale attempt below must be stopped by the
|
||||
// ownership CAS, not merely by the first buyer having run out of coins.
|
||||
seed_party(&pool, "mkt-prof-c", "mkt-c", "mkt-club-c", 20_000, MKT_TS).await;
|
||||
assert_eq!(all_club_coins(&pool).await, 41_000);
|
||||
|
||||
let (st, r) = json_post(
|
||||
&app,
|
||||
"/economy/settle-sale",
|
||||
settle_body(CANON_GROSS, CANON_FEE, Some(SELLER_CLUB), Some(BUYER_CLUB)),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{r}");
|
||||
assert_eq!(r["item_id"], MKT_ITEM);
|
||||
assert_eq!(r["card_id"], MKT_CARD);
|
||||
assert_eq!(r["seller_club_id"], SELLER_CLUB);
|
||||
assert_eq!(r["buyer_club_id"], BUYER_CLUB);
|
||||
assert_eq!(r["gross"], CANON_GROSS);
|
||||
assert_eq!(r["fee"], CANON_FEE);
|
||||
assert_eq!(r["proceeds"], 14_250);
|
||||
assert_eq!(r["seller_balance"], 15_250);
|
||||
assert_eq!(r["buyer_balance"], 5_000);
|
||||
|
||||
// Ownership MOVED — one row, new owner. A mint-based "buy" would leave two.
|
||||
assert_eq!(
|
||||
item_owner(&pool, MKT_ITEM).await.as_deref(),
|
||||
Some(BUYER_CLUB)
|
||||
);
|
||||
assert_eq!(item_row_count(&pool, MKT_ITEM).await, 1);
|
||||
|
||||
// Same balances read back over HTTP, each club via its own game header.
|
||||
let (s, seller) = json_get_as_game(&app, "/economy/balance", SELLER_GAME).await;
|
||||
assert_eq!(s, StatusCode::OK, "{seller}");
|
||||
assert_eq!(seller["balance"], 15_250);
|
||||
let (s, buyer) = json_get_as_game(&app, "/economy/balance", BUYER_GAME).await;
|
||||
assert_eq!(s, StatusCode::OK, "{buyer}");
|
||||
assert_eq!(buyer["balance"], 5_000);
|
||||
|
||||
// The economy lost exactly the fee.
|
||||
assert_eq!(all_club_coins(&pool).await, 40_250);
|
||||
|
||||
// Selling the same item AGAIN — to a buyer who can afford it — is rejected:
|
||||
// the named seller no longer owns it, so the transfer's ownership predicate
|
||||
// matches nothing and the whole transaction (including the second buyer's
|
||||
// debit) rolls back.
|
||||
let (st, _) = json_post(
|
||||
&app,
|
||||
"/economy/settle-sale",
|
||||
settle_body(
|
||||
CANON_GROSS,
|
||||
CANON_FEE,
|
||||
Some(SELLER_CLUB),
|
||||
Some("mkt-club-c"),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
assert_eq!(club_coins(&pool, "mkt-club-c").await, 20_000);
|
||||
assert_eq!(all_club_coins(&pool).await, 40_250);
|
||||
assert_eq!(
|
||||
item_owner(&pool, MKT_ITEM).await.as_deref(),
|
||||
Some(BUYER_CLUB)
|
||||
);
|
||||
assert_eq!(item_row_count(&pool, MKT_ITEM).await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_economy_settle_sale_route_omitted_buyer_settles_outside() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
seed_market(&pool).await;
|
||||
|
||||
let (st, r) = json_post(
|
||||
&app,
|
||||
"/economy/settle-sale",
|
||||
settle_body(CANON_GROSS, CANON_FEE, Some(SELLER_CLUB), None),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{r}");
|
||||
// No modelled counterparty: an omitted buyer is OUTSIDE, never the active club.
|
||||
assert!(r["buyer_club_id"].is_null());
|
||||
assert!(r["buyer_balance"].is_null());
|
||||
assert_eq!(r["proceeds"], 14_250);
|
||||
assert_eq!(r["seller_balance"], 15_250);
|
||||
|
||||
// The item left the inventory entirely and no other club was debited.
|
||||
assert_eq!(item_row_count(&pool, MKT_ITEM).await, 0);
|
||||
assert_eq!(club_coins(&pool, SELLER_CLUB).await, 15_250);
|
||||
assert_eq!(club_coins(&pool, BUYER_CLUB).await, 20_000);
|
||||
|
||||
// Replay: the item is gone, so the seller is not credited a second time.
|
||||
let (st, _) = json_post(
|
||||
&app,
|
||||
"/economy/settle-sale",
|
||||
settle_body(CANON_GROSS, CANON_FEE, Some(SELLER_CLUB), None),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
assert_eq!(club_coins(&pool, SELLER_CLUB).await, 15_250);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_economy_settle_sale_route_omitted_seller_uses_active_club() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
// Seeded first (and dated earliest) so a resolution that ignored the game
|
||||
// dimension would pick this club instead of the request's active one.
|
||||
seed_party(
|
||||
&pool,
|
||||
"mkt-prof-b",
|
||||
BUYER_GAME,
|
||||
BUYER_CLUB,
|
||||
20_000,
|
||||
"2020-01-01T00:00:00Z",
|
||||
)
|
||||
.await;
|
||||
// The active club for the default game header (fifa23), holding the item.
|
||||
let session = auth(&app, "econ-settle-active").await;
|
||||
let active_club = session["club"]["id"].as_str().unwrap().to_string();
|
||||
seed_owned(&pool, MKT_ITEM, &active_club, MKT_CARD).await;
|
||||
|
||||
let (st, r) = json_post(
|
||||
&app,
|
||||
"/economy/settle-sale",
|
||||
settle_body(CANON_GROSS, CANON_FEE, None, Some(BUYER_CLUB)),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{r}");
|
||||
assert_eq!(r["seller_club_id"], active_club);
|
||||
assert_eq!(r["buyer_club_id"], BUYER_CLUB);
|
||||
// The active club starts at 5_000 and is credited gross - fee.
|
||||
assert_eq!(r["seller_balance"], 19_250);
|
||||
assert_eq!(r["buyer_balance"], 5_000);
|
||||
assert_eq!(
|
||||
item_owner(&pool, MKT_ITEM).await.as_deref(),
|
||||
Some(BUYER_CLUB)
|
||||
);
|
||||
let (_, bal) = json_get(&app, "/economy/balance").await;
|
||||
assert_eq!(bal["balance"], 19_250);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_economy_settle_sale_route_rejects_fee_above_gross() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
seed_market(&pool).await;
|
||||
|
||||
let (st, _) = json_post(
|
||||
&app,
|
||||
"/economy/settle-sale",
|
||||
settle_body(
|
||||
CANON_GROSS,
|
||||
CANON_GROSS + 1,
|
||||
Some(SELLER_CLUB),
|
||||
Some(BUYER_CLUB),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(club_coins(&pool, SELLER_CLUB).await, 1_000);
|
||||
assert_eq!(club_coins(&pool, BUYER_CLUB).await, 20_000);
|
||||
assert_eq!(
|
||||
item_owner(&pool, MKT_ITEM).await.as_deref(),
|
||||
Some(SELLER_CLUB)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_economy_settle_sale_route_rejects_unaffordable_buyer() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
seed_market(&pool).await;
|
||||
|
||||
let (st, _) = json_post(
|
||||
&app,
|
||||
"/economy/settle-sale",
|
||||
settle_body(20_001, CANON_FEE, Some(SELLER_CLUB), Some(BUYER_CLUB)),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
// Fail-closed: the debit is attempted before ownership moves, and the whole
|
||||
// transaction rolls back.
|
||||
assert_eq!(club_coins(&pool, SELLER_CLUB).await, 1_000);
|
||||
assert_eq!(club_coins(&pool, BUYER_CLUB).await, 20_000);
|
||||
assert_eq!(
|
||||
item_owner(&pool, MKT_ITEM).await.as_deref(),
|
||||
Some(SELLER_CLUB)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_economy_settle_sale_route_rejects_self_dealing() {
|
||||
let (app, pool) = build_test_app_with_pool().await;
|
||||
seed_market(&pool).await;
|
||||
|
||||
let (st, _) = json_post(
|
||||
&app,
|
||||
"/economy/settle-sale",
|
||||
settle_body(CANON_GROSS, CANON_FEE, Some(SELLER_CLUB), Some(SELLER_CLUB)),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
assert_eq!(club_coins(&pool, SELLER_CLUB).await, 1_000);
|
||||
assert_eq!(club_coins(&pool, BUYER_CLUB).await, 20_000);
|
||||
assert_eq!(
|
||||
item_owner(&pool, MKT_ITEM).await.as_deref(),
|
||||
Some(SELLER_CLUB)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user