Compare commits
4 Commits
fbb54eac95
...
271c3639ed
| Author | SHA1 | Date | |
|---|---|---|---|
| 271c3639ed | |||
| 637a21eac1 | |||
| 31ab4a683e | |||
| 68d10658c7 |
@@ -37,3 +37,4 @@ axum-macros = "0.4"
|
|||||||
axum-test = "14"
|
axum-test = "14"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tower = { version = "0.5", features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Issue 1: sbc_submissions was created (0001_initial.sql) without a club_id column,
|
||||||
|
-- but the MY CLUB milestone query (routes/club.rs get_milestones) counts
|
||||||
|
-- SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1
|
||||||
|
-- so SQLite errored on the unknown column and the error was swallowed by
|
||||||
|
-- `.unwrap_or(0)` -> the `sbcs_completed` milestone always read 0. Add the column
|
||||||
|
-- and backfill it from the profile's club so historical submissions count.
|
||||||
|
ALTER TABLE sbc_submissions ADD COLUMN club_id TEXT;
|
||||||
|
|
||||||
|
UPDATE sbc_submissions
|
||||||
|
SET club_id = (SELECT c.id FROM clubs c WHERE c.profile_id = sbc_submissions.profile_id)
|
||||||
|
WHERE club_id IS NULL;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Durable replay and non-repeatable-completion guards for atomic SBC submissions.
|
||||||
|
-- Existing successful rows are treated as non-repeatable; if historical data already
|
||||||
|
-- violates that invariant the migration fails rather than silently discarding history.
|
||||||
|
ALTER TABLE sbc_submissions ADD COLUMN repeatable INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_sbc_nonrepeatable_completion
|
||||||
|
ON sbc_submissions(profile_id, sbc_id)
|
||||||
|
WHERE passed = 1 AND repeatable = 0;
|
||||||
|
|
||||||
|
-- submitted_card_ids is stored in canonical sorted order by the writer. This rejects
|
||||||
|
-- stale retries of the same card set even for explicitly repeatable challenges.
|
||||||
|
CREATE UNIQUE INDEX idx_sbc_submission_replay
|
||||||
|
ON sbc_submissions(profile_id, sbc_id, submitted_card_ids)
|
||||||
|
WHERE passed = 1;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Core owns durable per-profile working squads for SBC challenges. The FIFA17
|
||||||
|
-- adapter maps its numeric challenge id to the opaque generic sbc_id.
|
||||||
|
CREATE TABLE sbc_challenge_squads (
|
||||||
|
profile_id TEXT NOT NULL REFERENCES profiles(id),
|
||||||
|
sbc_id TEXT NOT NULL,
|
||||||
|
owned_card_ids TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (profile_id, sbc_id)
|
||||||
|
);
|
||||||
@@ -198,6 +198,10 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
"/economy/purchase-items",
|
"/economy/purchase-items",
|
||||||
post(routes::economy::post_purchase_items),
|
post(routes::economy::post_purchase_items),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/economy/settle-sale",
|
||||||
|
post(routes::economy::post_settle_sale),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/collection/:owned_card_id",
|
"/collection/:owned_card_id",
|
||||||
delete(routes::cards::delete_owned_card),
|
delete(routes::cards::delete_owned_card),
|
||||||
@@ -247,8 +251,13 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/matches/opponent", get(routes::matches::get_opponent))
|
.route("/matches/opponent", get(routes::matches::get_opponent))
|
||||||
.route("/matches/result", post(routes::matches::post_match_result))
|
.route("/matches/result", post(routes::matches::post_match_result))
|
||||||
.route("/sbc", get(routes::sbc::get_sbcs))
|
.route("/sbc", get(routes::sbc::get_sbcs))
|
||||||
|
.route("/sbc/status", get(routes::sbc::get_sbc_status))
|
||||||
.route("/sbc/submit", post(routes::sbc::post_sbc_submit))
|
.route("/sbc/submit", post(routes::sbc::post_sbc_submit))
|
||||||
.route("/sbc/:sbc_id", get(routes::sbc::get_sbc))
|
.route("/sbc/:sbc_id", get(routes::sbc::get_sbc))
|
||||||
|
.route(
|
||||||
|
"/sbc/:sbc_id/squad",
|
||||||
|
get(routes::sbc::get_sbc_squad).put(routes::sbc::put_sbc_squad),
|
||||||
|
)
|
||||||
.route("/market", get(routes::market::get_market))
|
.route("/market", get(routes::market::get_market))
|
||||||
.route("/market/buy", post(routes::market::post_market_buy))
|
.route("/market/buy", post(routes::market::post_market_buy))
|
||||||
.route("/market/sell", post(routes::market::post_market_sell))
|
.route("/market/sell", post(routes::market::post_market_sell))
|
||||||
|
|||||||
+14
-2
@@ -33,16 +33,17 @@ pub struct SbcReward {
|
|||||||
pub pack_id: Option<String>,
|
pub pack_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DB record of a completed submission
|
/// DB record of a completed submission.
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct SbcSubmission {
|
pub struct SbcSubmission {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub profile_id: String,
|
pub profile_id: String,
|
||||||
|
pub club_id: Option<String>,
|
||||||
pub sbc_id: String,
|
pub sbc_id: String,
|
||||||
pub submitted_card_ids: String,
|
pub submitted_card_ids: String,
|
||||||
pub passed: bool,
|
pub passed: bool,
|
||||||
pub submitted_at: String,
|
pub submitted_at: String,
|
||||||
|
pub repeatable: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -51,6 +52,17 @@ pub struct SubmitSbcRequest {
|
|||||||
pub owned_card_ids: Vec<String>,
|
pub owned_card_ids: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SaveSbcSquadRequest {
|
||||||
|
pub owned_card_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SbcSquadState {
|
||||||
|
pub sbc_id: String,
|
||||||
|
pub owned_card_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct SbcResult {
|
pub struct SbcResult {
|
||||||
pub passed: bool,
|
pub passed: bool,
|
||||||
|
|||||||
+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,
|
||||||
|
|||||||
@@ -161,3 +161,60 @@ pub async fn post_purchase_items(
|
|||||||
let balance = economy::purchase_items(&state.pool, &club, req.cost, &req.items).await?;
|
let balance = economy::purchase_items(&state.pool, &club, req.cost, &req.items).await?;
|
||||||
Ok(Json(BalanceResponse { balance }))
|
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))
|
||||||
|
}
|
||||||
|
|||||||
+52
-12
@@ -8,7 +8,7 @@ use serde_json::{json, Value};
|
|||||||
use crate::{
|
use crate::{
|
||||||
app::AppState,
|
app::AppState,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
models::sbc::{SbcResult, SubmitSbcRequest},
|
models::sbc::{SaveSbcSquadRequest, SbcResult, SubmitSbcRequest},
|
||||||
services::{club as club_svc, profile as profile_svc, sbc as sbc_svc},
|
services::{club as club_svc, profile as profile_svc, sbc as sbc_svc},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,6 +28,49 @@ pub async fn get_sbc(
|
|||||||
Ok(Json(json!({ "sbc": sbc })))
|
Ok(Json(json!({ "sbc": sbc })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_sbc_status(State(state): State<AppState>, game: GameId) -> AppResult<Json<Value>> {
|
||||||
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
|
let completions = sbc_svc::completion_counts(&state.pool, &profile.id).await?;
|
||||||
|
Ok(Json(json!({ "completions": completions })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_sbc_squad(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Path(sbc_id): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
if !state
|
||||||
|
.sbc_defs
|
||||||
|
.iter()
|
||||||
|
.any(|definition| definition.id == sbc_id)
|
||||||
|
{
|
||||||
|
return Err(AppError::NotFound(format!("SBC '{sbc_id}' not found")));
|
||||||
|
}
|
||||||
|
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||||
|
let squad = sbc_svc::load_sbc_squad(&state.pool, &profile.id, &sbc_id).await?;
|
||||||
|
Ok(Json(json!({ "squad": squad })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_sbc_squad(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Path(sbc_id): Path<String>,
|
||||||
|
Json(req): Json<SaveSbcSquadRequest>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
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 squad = sbc_svc::save_sbc_squad(
|
||||||
|
&state.pool,
|
||||||
|
&state.sbc_defs,
|
||||||
|
&profile.id,
|
||||||
|
&club.id,
|
||||||
|
&sbc_id,
|
||||||
|
&req.owned_card_ids,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(json!({ "squad": squad })))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn post_sbc_submit(
|
pub async fn post_sbc_submit(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
game: GameId,
|
game: GameId,
|
||||||
@@ -38,20 +81,17 @@ pub async fn post_sbc_submit(
|
|||||||
|
|
||||||
let result = sbc_svc::submit_sbc(
|
let result = sbc_svc::submit_sbc(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
&state.card_db,
|
sbc_svc::SbcSubmissionContext {
|
||||||
&state.sbc_defs,
|
card_db: &state.card_db,
|
||||||
&state.obj_defs,
|
sbc_defs: &state.sbc_defs,
|
||||||
&profile.id,
|
objective_defs: &state.obj_defs,
|
||||||
&club.id,
|
achievement_defs: &state.achievement_defs,
|
||||||
|
profile_id: &profile.id,
|
||||||
|
club_id: &club.id,
|
||||||
|
},
|
||||||
&req,
|
&req,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if result.passed {
|
|
||||||
let _ = crate::services::achievement::check_and_unlock(
|
|
||||||
&state.pool, &state.achievement_defs, &profile.id, &club.id,
|
|
||||||
).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-10
@@ -44,7 +44,7 @@ pub async fn get_status(pool: &Pool, profile_id: &str) -> AppResult<CheckinStatu
|
|||||||
let last_day = &last_at[..10]; // YYYY-MM-DD
|
let last_day = &last_at[..10]; // YYYY-MM-DD
|
||||||
let available = last_day != today.as_str();
|
let available = last_day != today.as_str();
|
||||||
let next_streak = compute_next_streak(last_streak, &last_at);
|
let next_streak = compute_next_streak(last_streak, &last_at);
|
||||||
let idx = ((next_streak - 1) % 7) as usize;
|
let idx = (next_streak - 1).rem_euclid(7) as usize;
|
||||||
Ok(CheckinStatus {
|
Ok(CheckinStatus {
|
||||||
available,
|
available,
|
||||||
streak_day: if available { next_streak } else { last_streak },
|
streak_day: if available { next_streak } else { last_streak },
|
||||||
@@ -83,19 +83,17 @@ pub async fn claim(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let last_streak = row.as_ref().map(|(s, last_at)| compute_next_streak(*s, last_at)).unwrap_or(1);
|
let last_streak = row.as_ref().map(|(s, last_at)| compute_next_streak(*s, last_at)).unwrap_or(1);
|
||||||
let idx = ((last_streak - 1) % 7) as usize;
|
let idx = (last_streak - 1).rem_euclid(7) as usize;
|
||||||
let coins = STREAK_COINS[idx];
|
let coins = STREAK_COINS[idx];
|
||||||
let pack_def = if idx == 6 { Some(STREAK_7_PACK) } else { None };
|
let pack_def = if idx == 6 { Some(STREAK_7_PACK) } else { None };
|
||||||
|
|
||||||
club::add_coins(pool, club_id, coins).await?;
|
// Atomically claim today's check-in: the INSERT lands only if no row exists for
|
||||||
if let Some(def) = pack_def {
|
// today, so two concurrent claims cannot both pay out (was a check-then-act race).
|
||||||
let _ = pack::grant_pack(pool, club_id, def).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
sqlx::query(
|
let inserted = sqlx::query(
|
||||||
"INSERT INTO daily_checkins (id, profile_id, club_id, streak_day, coins_awarded, pack_granted, checked_in_at) \
|
"INSERT INTO daily_checkins (id, profile_id, club_id, streak_day, coins_awarded, pack_granted, checked_in_at) \
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
SELECT ?, ?, ?, ?, ?, ?, ? \
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM daily_checkins WHERE profile_id = ? AND substr(checked_in_at, 1, 10) = ?)",
|
||||||
)
|
)
|
||||||
.bind(Uuid::new_v4().to_string())
|
.bind(Uuid::new_v4().to_string())
|
||||||
.bind(profile_id)
|
.bind(profile_id)
|
||||||
@@ -104,8 +102,26 @@ pub async fn claim(
|
|||||||
.bind(coins)
|
.bind(coins)
|
||||||
.bind(pack_def)
|
.bind(pack_def)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
|
.bind(profile_id)
|
||||||
|
.bind(&today)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if inserted == 0 {
|
||||||
|
// A concurrent claim already recorded today's check-in — do not pay out again.
|
||||||
|
return Ok(CheckinResult {
|
||||||
|
coins_awarded: 0,
|
||||||
|
pack_awarded: None,
|
||||||
|
new_streak: last_streak,
|
||||||
|
already_claimed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
club::add_coins(pool, club_id, coins).await?;
|
||||||
|
if let Some(def) = pack_def {
|
||||||
|
let _ = pack::grant_pack(pool, club_id, def).await;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(CheckinResult {
|
Ok(CheckinResult {
|
||||||
coins_awarded: coins,
|
coins_awarded: coins,
|
||||||
|
|||||||
+30
-11
@@ -86,24 +86,43 @@ pub async fn add_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn spend_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
|
pub async fn spend_coins(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
|
||||||
|
if amount < 0 {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"cannot spend a negative amount: {amount}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
// Atomic compare-and-swap: the `coins >= ?` guard makes the debit conditional in a
|
||||||
|
// single statement, so two concurrent spends can never both pass a stale balance
|
||||||
|
// check and drive coins negative (the old SELECT-then-UPDATE was a TOCTOU race).
|
||||||
|
let affected = sqlx::query(
|
||||||
|
"UPDATE clubs SET coins = coins - ?, updated_at = ? WHERE id = ? AND coins >= ?",
|
||||||
|
)
|
||||||
|
.bind(amount)
|
||||||
|
.bind(now)
|
||||||
|
.bind(club_id)
|
||||||
|
.bind(amount)
|
||||||
|
.execute(pool)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if affected == 0 {
|
||||||
|
// No row updated: the club is missing, or it could not afford the debit.
|
||||||
|
// Disambiguate so callers keep the NotFound vs BadRequest distinction.
|
||||||
let balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
let balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
.fetch_one(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("club not found: {club_id}")))?;
|
||||||
if balance < amount {
|
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(AppError::BadRequest(format!(
|
||||||
"insufficient coins: have {balance}, need {amount}"
|
"insufficient coins: have {balance}, need {amount}"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Utc::now();
|
let new_balance = sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
||||||
sqlx::query("UPDATE clubs SET coins = coins - ?, updated_at = ? WHERE id = ?")
|
|
||||||
.bind(amount)
|
|
||||||
.bind(now)
|
|
||||||
.bind(club_id)
|
.bind(club_id)
|
||||||
.execute(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
Ok(new_balance)
|
||||||
Ok(balance - amount)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+700
-1
@@ -183,6 +183,68 @@ async fn remove_item(
|
|||||||
Ok(card_id)
|
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 ----------------------------------------------
|
// ---- composed atomic operations ----------------------------------------------
|
||||||
|
|
||||||
/// Read a club's current currency balance.
|
/// Read a club's current currency balance.
|
||||||
@@ -326,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
|
||||||
}
|
}
|
||||||
@@ -346,6 +417,155 @@ pub async fn grant_reward(pool: &Pool, club_id: &str, amount: i64) -> AppResult<
|
|||||||
finish(&mut conn, result).await
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -409,6 +629,442 @@ mod tests {
|
|||||||
.unwrap()
|
.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]
|
#[tokio::test]
|
||||||
async fn balance_reads_seeded_value() {
|
async fn balance_reads_seeded_value() {
|
||||||
let pool = fixture().await;
|
let pool = fixture().await;
|
||||||
@@ -544,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;
|
||||||
|
|||||||
+24
-6
@@ -141,12 +141,23 @@ pub async fn buy_listing(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("listing not found or already sold".into()))?;
|
.ok_or_else(|| AppError::NotFound("listing not found or already sold".into()))?;
|
||||||
|
|
||||||
club::spend_coins(pool, club_id, listing.price).await?;
|
// Atomically claim the listing (flip sold 0->1) before charging, so two concurrent
|
||||||
|
// buyers cannot both mint the same card. If the debit then fails, release the claim.
|
||||||
sqlx::query("UPDATE market_listings SET sold = 1 WHERE id = ?")
|
let claimed = sqlx::query("UPDATE market_listings SET sold = 1 WHERE id = ? AND sold = 0")
|
||||||
.bind(&listing.id)
|
.bind(&listing.id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
if claimed == 0 {
|
||||||
|
return Err(AppError::NotFound("listing not found or already sold".into()));
|
||||||
|
}
|
||||||
|
if let Err(e) = club::spend_coins(pool, club_id, listing.price).await {
|
||||||
|
let _ = sqlx::query("UPDATE market_listings SET sold = 0 WHERE id = ?")
|
||||||
|
.bind(&listing.id)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
let owned_id = Uuid::new_v4().to_string();
|
let owned_id = Uuid::new_v4().to_string();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -206,10 +217,17 @@ pub async fn sell_card(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("owned card not found".into()))?;
|
.ok_or_else(|| AppError::NotFound("owned card not found".into()))?;
|
||||||
|
|
||||||
sqlx::query("DELETE FROM owned_cards WHERE id = ?")
|
// Atomically claim the card: guard the DELETE with the owner + rows_affected so two
|
||||||
|
// concurrent sells of the same card cannot both credit (double payout).
|
||||||
|
let deleted = sqlx::query("DELETE FROM owned_cards WHERE id = ? AND club_id = ?")
|
||||||
.bind(&req.owned_card_id)
|
.bind(&req.owned_card_id)
|
||||||
|
.bind(club_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
if deleted == 0 {
|
||||||
|
return Err(AppError::NotFound("owned card not found".into()));
|
||||||
|
}
|
||||||
|
|
||||||
let coins = (req.price as f64 * 0.4) as i64;
|
let coins = (req.price as f64 * 0.4) as i64;
|
||||||
let new_balance = club::add_coins(pool, club_id, coins).await?;
|
let new_balance = club::add_coins(pool, club_id, coins).await?;
|
||||||
|
|||||||
+12
-2
@@ -71,7 +71,17 @@ pub async fn open_pack(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("pack {pack_id} not found")))?;
|
.ok_or_else(|| AppError::NotFound(format!("pack {pack_id} not found")))?;
|
||||||
|
|
||||||
if pack.opened {
|
// Atomically claim the pack before minting any cards: only one concurrent opener
|
||||||
|
// flips opened 0->1, so a double-open cannot mint the reward twice (duplication).
|
||||||
|
let claimed = sqlx::query(
|
||||||
|
"UPDATE packs SET opened = 1 WHERE id = ? AND club_id = ? AND opened = 0",
|
||||||
|
)
|
||||||
|
.bind(pack_id)
|
||||||
|
.bind(club_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
if claimed == 0 {
|
||||||
return Err(AppError::BadRequest("pack already opened".into()));
|
return Err(AppError::BadRequest("pack already opened".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +138,7 @@ pub async fn open_pack(
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
sqlx::query("UPDATE packs SET opened = 1, opened_cards = ?, opened_at = ? WHERE id = ?")
|
sqlx::query("UPDATE packs SET opened_cards = ?, opened_at = ? WHERE id = ?")
|
||||||
.bind(&card_ids_json)
|
.bind(&card_ids_json)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.bind(pack_id)
|
.bind(pack_id)
|
||||||
|
|||||||
+1170
-47
File diff suppressed because it is too large
Load Diff
+10
-4
@@ -1,6 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
db::Pool,
|
db::Pool,
|
||||||
error::AppResult,
|
error::{AppError, AppResult},
|
||||||
models::season::{Season, SeasonEndSummary, SeasonHistoryEntry, SeasonResult},
|
models::season::{Season, SeasonEndSummary, SeasonHistoryEntry, SeasonResult},
|
||||||
services::{club, pack},
|
services::{club, pack},
|
||||||
};
|
};
|
||||||
@@ -20,7 +20,9 @@ pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Season> {
|
|||||||
.bind(&now)
|
.bind(&now)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(fetch(pool, profile_id).await?.expect("just inserted"))
|
fetch(pool, profile_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("season row missing immediately after insert")))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch(pool: &Pool, profile_id: &str) -> AppResult<Option<Season>> {
|
async fn fetch(pool: &Pool, profile_id: &str) -> AppResult<Option<Season>> {
|
||||||
@@ -66,7 +68,9 @@ pub async fn record_match(
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let season = fetch(pool, profile_id).await?.expect("season must exist");
|
let season = fetch(pool, profile_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("season row missing after record_match update")))?;
|
||||||
|
|
||||||
if !season.is_complete() {
|
if !season.is_complete() {
|
||||||
return Ok((season, None));
|
return Ok((season, None));
|
||||||
@@ -141,7 +145,9 @@ pub async fn record_match(
|
|||||||
pack_awarded: pack_id.map(String::from),
|
pack_awarded: pack_id.map(String::from),
|
||||||
};
|
};
|
||||||
|
|
||||||
let updated = fetch(pool, profile_id).await?.expect("season must exist");
|
let updated = fetch(pool, profile_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("season row missing after season rollover")))?;
|
||||||
Ok((updated, Some(summary)))
|
Ok((updated, Some(summary)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -269,6 +269,56 @@ async fn test_sbc_submit_with_bronze_cards() {
|
|||||||
assert!(result["reward"].is_object());
|
assert!(result["reward"].is_object());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sbc_rejects_duplicate_cards() {
|
||||||
|
// Regression: a single owned card repeated to fill an SBC must be rejected. Before
|
||||||
|
// the dedup guard the same id resolved N times, passed validation, and granted the
|
||||||
|
// reward while only one card was consumed (free-reward exploit).
|
||||||
|
let app = build_test_app().await;
|
||||||
|
auth(&app, "SBCDupePlayer").await;
|
||||||
|
|
||||||
|
let (s, _) = json_post(
|
||||||
|
&app,
|
||||||
|
"/packs/buy",
|
||||||
|
serde_json::json!({ "pack_definition_id": "bronze_pack" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let (_, packs_json) = json_get(&app, "/packs").await;
|
||||||
|
let pack_id = packs_json["packs"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|p| p["definition_id"] == "bronze_pack")
|
||||||
|
.expect("bronze pack in inventory")["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 one_card = coll["collection"].as_array().unwrap()[0]["owned_card_id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let (s, result) = json_post(
|
||||||
|
&app,
|
||||||
|
"/sbc/submit",
|
||||||
|
serde_json::json!({
|
||||||
|
"sbc_id": "sbc_bronze_upgrade",
|
||||||
|
"owned_card_ids": vec![one_card; 11]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::BAD_REQUEST, "duplicate submission must be rejected: {result}");
|
||||||
|
assert!(
|
||||||
|
result["error"].as_str().unwrap_or_default().contains("duplicate"),
|
||||||
|
"expected a duplicate-card error, got: {result}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_settings_read_write() {
|
async fn test_settings_read_write() {
|
||||||
let app = build_test_app().await;
|
let app = build_test_app().await;
|
||||||
@@ -2798,3 +2848,346 @@ async fn test_economy_purchase_items_debits_and_mints_all() {
|
|||||||
let (_, bal) = json_get(&app, "/economy/balance").await;
|
let (_, bal) = json_get(&app, "/economy/balance").await;
|
||||||
assert_eq!(bal["balance"], 4200);
|
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