Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbb54eac95 | |||
| 75b183077f | |||
| 0360135322 | |||
| bcc4f5104a | |||
| d32dc6e3ae | |||
| c8269d0df7 | |||
| ee2caa0bb0 |
+26
@@ -172,6 +172,32 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
|||||||
.route("/cards", get(routes::cards::get_cards))
|
.route("/cards", get(routes::cards::get_cards))
|
||||||
.route("/cards/:card_id", get(routes::cards::get_card))
|
.route("/cards/:card_id", get(routes::cards::get_card))
|
||||||
.route("/collection", get(routes::cards::get_collection))
|
.route("/collection", get(routes::cards::get_collection))
|
||||||
|
.route("/economy/balance", get(routes::economy::get_balance))
|
||||||
|
.route(
|
||||||
|
"/economy/entitlements",
|
||||||
|
get(routes::economy::get_entitlements),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/economy/purchase-entitlement",
|
||||||
|
post(routes::economy::post_purchase_entitlement),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/economy/redeem-entitlement",
|
||||||
|
post(routes::economy::post_redeem_entitlement),
|
||||||
|
)
|
||||||
|
.route("/economy/sell-item", post(routes::economy::post_sell_item))
|
||||||
|
.route(
|
||||||
|
"/economy/grant-reward",
|
||||||
|
post(routes::economy::post_grant_reward),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/economy/purchase-item",
|
||||||
|
post(routes::economy::post_purchase_item),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/economy/purchase-items",
|
||||||
|
post(routes::economy::post_purchase_items),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/collection/:owned_card_id",
|
"/collection/:owned_card_id",
|
||||||
delete(routes::cards::delete_owned_card),
|
delete(routes::cards::delete_owned_card),
|
||||||
|
|||||||
@@ -1,24 +1,40 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use sqlx::{
|
use sqlx::{
|
||||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
|
||||||
SqlitePool,
|
ConnectOptions, Connection, SqlitePool,
|
||||||
};
|
};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
use std::time::Duration;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
pub type Pool = SqlitePool;
|
pub type Pool = SqlitePool;
|
||||||
|
|
||||||
pub async fn init_pool(database_url: &str, max_connections: u32) -> Result<Pool> {
|
pub async fn init_pool(database_url: &str, max_connections: u32) -> Result<Pool> {
|
||||||
info!("Connecting to database: {}", database_url);
|
info!("Connecting to database: {}", database_url);
|
||||||
let opts = SqliteConnectOptions::from_str(database_url)?.create_if_missing(true);
|
// Per-connection options so EVERY pooled connection gets them: WAL for
|
||||||
|
// reader/writer concurrency, foreign keys on, and a busy_timeout so a
|
||||||
|
// transient SQLITE_BUSY under concurrent access waits-and-retries.
|
||||||
|
let opts = SqliteConnectOptions::from_str(database_url)?
|
||||||
|
.create_if_missing(true)
|
||||||
|
.journal_mode(SqliteJournalMode::Wal)
|
||||||
|
.foreign_keys(true)
|
||||||
|
.busy_timeout(Duration::from_secs(5));
|
||||||
|
// Establish WAL on the file via ONE connection BEFORE the pool opens.
|
||||||
|
// Switching a fresh DB to WAL is a one-time file-level change; letting
|
||||||
|
// several pooled connections do it concurrently at warm-up races that
|
||||||
|
// switch and can surface a spurious lock. Serialize it here so every
|
||||||
|
// pooled connection thereafter only re-asserts an already-WAL file.
|
||||||
|
{
|
||||||
|
let mut conn = opts.clone().connect().await?;
|
||||||
|
sqlx::query("PRAGMA journal_mode=WAL")
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await?;
|
||||||
|
conn.close().await?;
|
||||||
|
}
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
.max_connections(max_connections)
|
.max_connections(max_connections)
|
||||||
.connect_with(opts)
|
.connect_with(opts)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::query("PRAGMA journal_mode=WAL")
|
|
||||||
.execute(&pool)
|
|
||||||
.await?;
|
|
||||||
sqlx::query("PRAGMA foreign_keys=ON").execute(&pool).await?;
|
|
||||||
Ok(pool)
|
Ok(pool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
//! Generic economy HTTP boundary.
|
||||||
|
//!
|
||||||
|
//! Exposes [`crate::services::economy`] over the same game-scoped active-profile
|
||||||
|
//! resolution every other Core route uses ([`GameId`] header → active profile →
|
||||||
|
//! club). The caller (a game host) never supplies a club id; Core maps the game
|
||||||
|
//! to its authoritative club, so there is no cross-club economy access. Every
|
||||||
|
//! op is a single durable SQLite transaction in the service layer.
|
||||||
|
//!
|
||||||
|
//! This surface is deliberately game-neutral: no currency names, pack ids, or
|
||||||
|
//! wire semantics — those live in the game host/adapter.
|
||||||
|
|
||||||
|
use axum::{extract::State, Json};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::AppState,
|
||||||
|
error::AppResult,
|
||||||
|
extractors::GameId,
|
||||||
|
services::{club as club_svc, economy, economy::GrantedItem, profile as profile_svc},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Resolve the game-scoped active profile's club id.
|
||||||
|
async fn resolve_club(state: &AppState, game: &GameId) -> AppResult<String> {
|
||||||
|
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?;
|
||||||
|
Ok(club.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct BalanceResponse {
|
||||||
|
pub balance: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /economy/balance` — the club's currency balance.
|
||||||
|
pub async fn get_balance(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
) -> AppResult<Json<BalanceResponse>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
let balance = economy::balance(&state.pool, &club).await?;
|
||||||
|
Ok(Json(BalanceResponse { balance }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /economy/entitlements` — the club's unconsumed entitlements.
|
||||||
|
pub async fn get_entitlements(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
) -> AppResult<Json<Vec<economy::Entitlement>>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
Ok(Json(
|
||||||
|
economy::list_unopened_entitlements(&state.pool, &club).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PurchaseEntitlementRequest {
|
||||||
|
pub cost: i64,
|
||||||
|
pub definition_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/purchase-entitlement` — atomic debit + grant.
|
||||||
|
pub async fn post_purchase_entitlement(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<PurchaseEntitlementRequest>,
|
||||||
|
) -> AppResult<Json<economy::PurchaseReceipt>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
Ok(Json(
|
||||||
|
economy::purchase_entitlement(&state.pool, &club, req.cost, &req.definition_id).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RedeemEntitlementRequest {
|
||||||
|
pub entitlement_id: String,
|
||||||
|
pub items: Vec<GrantedItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct RedeemEntitlementResponse {
|
||||||
|
pub definition_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/redeem-entitlement` — atomic consume-once + add items.
|
||||||
|
pub async fn post_redeem_entitlement(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<RedeemEntitlementRequest>,
|
||||||
|
) -> AppResult<Json<RedeemEntitlementResponse>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
let definition_id =
|
||||||
|
economy::redeem_entitlement(&state.pool, &club, &req.entitlement_id, &req.items).await?;
|
||||||
|
Ok(Json(RedeemEntitlementResponse { definition_id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SellItemRequest {
|
||||||
|
pub item_id: String,
|
||||||
|
pub price: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/sell-item` — atomic remove + credit.
|
||||||
|
pub async fn post_sell_item(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<SellItemRequest>,
|
||||||
|
) -> AppResult<Json<BalanceResponse>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
let balance = economy::sell_item(&state.pool, &club, &req.item_id, req.price).await?;
|
||||||
|
Ok(Json(BalanceResponse { balance }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct GrantRewardRequest {
|
||||||
|
pub amount: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/grant-reward` — atomic credit.
|
||||||
|
pub async fn post_grant_reward(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<GrantRewardRequest>,
|
||||||
|
) -> AppResult<Json<BalanceResponse>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
let balance = economy::grant_reward(&state.pool, &club, req.amount).await?;
|
||||||
|
Ok(Json(BalanceResponse { balance }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PurchaseItemRequest {
|
||||||
|
pub cost: i64,
|
||||||
|
pub item_id: String,
|
||||||
|
pub card_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/purchase-item` — atomic debit + mint item.
|
||||||
|
pub async fn post_purchase_item(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<PurchaseItemRequest>,
|
||||||
|
) -> AppResult<Json<BalanceResponse>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
let balance =
|
||||||
|
economy::purchase_item(&state.pool, &club, req.cost, &req.item_id, &req.card_id).await?;
|
||||||
|
Ok(Json(BalanceResponse { balance }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PurchaseItemsRequest {
|
||||||
|
pub cost: i64,
|
||||||
|
pub items: Vec<GrantedItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /economy/purchase-items` — atomic debit + mint several items.
|
||||||
|
pub async fn post_purchase_items(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
game: GameId,
|
||||||
|
Json(req): Json<PurchaseItemsRequest>,
|
||||||
|
) -> AppResult<Json<BalanceResponse>> {
|
||||||
|
let club = resolve_club(&state, &game).await?;
|
||||||
|
let balance = economy::purchase_items(&state.pool, &club, req.cost, &req.items).await?;
|
||||||
|
Ok(Json(BalanceResponse { balance }))
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ pub mod cards;
|
|||||||
pub mod club;
|
pub mod club;
|
||||||
pub mod division;
|
pub mod division;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
|
pub mod economy;
|
||||||
pub mod fut_champs;
|
pub mod fut_champs;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
|||||||
@@ -0,0 +1,610 @@
|
|||||||
|
//! Generic, game-agnostic economy authority.
|
||||||
|
//!
|
||||||
|
//! Exposes atomic, fail-closed economy operations over Core's existing durable
|
||||||
|
//! tables — it does **not** introduce a parallel persistence stack:
|
||||||
|
//!
|
||||||
|
//! * currency ledger -> `clubs.coins`
|
||||||
|
//! * owned inventory -> `owned_cards`
|
||||||
|
//! * entitlements -> `packs` (opaque `definition_id` + consume-once `opened`)
|
||||||
|
//!
|
||||||
|
//! Every compound operation (purchase, redeem, sell) runs inside a single SQLite
|
||||||
|
//! transaction, so a partial failure leaves no balance or inventory drift — the
|
||||||
|
//! pool-scoped helpers in [`crate::services::club`] cannot offer that guarantee
|
||||||
|
//! because their read/modify/write spans multiple pool round-trips.
|
||||||
|
//!
|
||||||
|
//! This module is deliberately game-neutral: currency names, entitlement/pack
|
||||||
|
//! ids, and per-save item-id sequences are per-game concerns that live in the
|
||||||
|
//! adapter which drives these primitives, never here.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::Pool,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
};
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::SqliteConnection;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// An instance to place into a club's inventory when an entitlement is redeemed.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GrantedItem {
|
||||||
|
/// Caller-minted opaque instance id. The adapter owns the id scheme; Core
|
||||||
|
/// treats it as an opaque unique key.
|
||||||
|
pub item_id: String,
|
||||||
|
/// Definition reference this instance resolves against.
|
||||||
|
pub card_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of a purchase: the post-debit balance and the new entitlement id.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PurchaseReceipt {
|
||||||
|
pub balance: i64,
|
||||||
|
pub entitlement_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- transaction-scoped primitives -------------------------------------------
|
||||||
|
// Each takes a live connection (a transaction, reborrowed) so callers can compose
|
||||||
|
// several into one atomic unit. They never commit; the composed public op does.
|
||||||
|
|
||||||
|
async fn read_balance(conn: &mut SqliteConnection, club_id: &str) -> AppResult<i64> {
|
||||||
|
sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("club not found: {club_id}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn debit(conn: &mut SqliteConnection, club_id: &str, amount: i64) -> AppResult<i64> {
|
||||||
|
if amount < 0 {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"debit amount must be non-negative".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let balance = read_balance(conn, club_id).await?;
|
||||||
|
if balance < amount {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"insufficient balance: have {balance}, need {amount}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
sqlx::query("UPDATE clubs SET coins = coins - ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(amount)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(club_id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(balance - amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn credit(conn: &mut SqliteConnection, club_id: &str, amount: i64) -> AppResult<i64> {
|
||||||
|
if amount < 0 {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"credit amount must be non-negative".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let balance = read_balance(conn, club_id).await?;
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
sqlx::query("UPDATE clubs SET coins = coins + ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(amount)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(club_id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(balance + amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn grant_entitlement(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
club_id: &str,
|
||||||
|
definition_id: &str,
|
||||||
|
) -> AppResult<String> {
|
||||||
|
// Ensure the club exists so we never orphan an entitlement.
|
||||||
|
read_balance(conn, club_id).await?;
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, 0, ?)",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(club_id)
|
||||||
|
.bind(definition_id)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume an unopened entitlement exactly once, returning its definition ref.
|
||||||
|
async fn consume_entitlement(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
club_id: &str,
|
||||||
|
entitlement_id: &str,
|
||||||
|
) -> AppResult<String> {
|
||||||
|
let row = sqlx::query_as::<_, (String, i64)>(
|
||||||
|
"SELECT definition_id, opened FROM packs WHERE id = ? AND club_id = ?",
|
||||||
|
)
|
||||||
|
.bind(entitlement_id)
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
let (definition_id, opened) =
|
||||||
|
row.ok_or_else(|| AppError::NotFound(format!("entitlement not found: {entitlement_id}")))?;
|
||||||
|
if opened != 0 {
|
||||||
|
return Err(AppError::Conflict(format!(
|
||||||
|
"entitlement already consumed: {entitlement_id}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
sqlx::query("UPDATE packs SET opened = 1 WHERE id = ? AND club_id = ?")
|
||||||
|
.bind(entitlement_id)
|
||||||
|
.bind(club_id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(definition_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_item(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
club_id: &str,
|
||||||
|
item_id: &str,
|
||||||
|
card_id: &str,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, loan_matches_remaining, acquired_at) \
|
||||||
|
VALUES (?, ?, ?, 0, NULL, ?)",
|
||||||
|
)
|
||||||
|
.bind(item_id)
|
||||||
|
.bind(club_id)
|
||||||
|
.bind(card_id)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_item(
|
||||||
|
conn: &mut SqliteConnection,
|
||||||
|
club_id: &str,
|
||||||
|
item_id: &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(club_id)
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("item not owned by club: {item_id}")))?;
|
||||||
|
sqlx::query("DELETE FROM owned_cards WHERE id = ? AND club_id = ?")
|
||||||
|
.bind(item_id)
|
||||||
|
.bind(club_id)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
Ok(card_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- composed atomic operations ----------------------------------------------
|
||||||
|
|
||||||
|
/// Read a club's current currency balance.
|
||||||
|
pub async fn balance(pool: &Pool, club_id: &str) -> AppResult<i64> {
|
||||||
|
sqlx::query_scalar::<_, i64>("SELECT coins FROM clubs WHERE id = ?")
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("club not found: {club_id}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One unopened entitlement a club owns.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Entitlement {
|
||||||
|
pub id: String,
|
||||||
|
pub definition_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List a club's unconsumed entitlements (opened = 0), oldest first.
|
||||||
|
pub async fn list_unopened_entitlements(pool: &Pool, club_id: &str) -> AppResult<Vec<Entitlement>> {
|
||||||
|
let rows = sqlx::query_as::<_, (String, String)>(
|
||||||
|
"SELECT id, definition_id FROM packs WHERE club_id = ? AND opened = 0 ORDER BY created_at ASC, id ASC",
|
||||||
|
)
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, definition_id)| Entitlement { id, definition_id })
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit on `Ok`, roll back on `Err`. Paired with a `BEGIN IMMEDIATE` opened on
|
||||||
|
/// the same connection, so the write lock is held for the whole op and a
|
||||||
|
/// concurrent writer waits (honoring `busy_timeout`) instead of failing: a
|
||||||
|
/// DEFERRED `pool.begin()` upgrades to a write only at the first write, where
|
||||||
|
/// SQLite returns SQLITE_BUSY *immediately* (bypassing the busy handler to avoid
|
||||||
|
/// deadlock) — the fresh-DB multi-connection write failure.
|
||||||
|
async fn finish<T>(conn: &mut SqliteConnection, result: AppResult<T>) -> AppResult<T> {
|
||||||
|
match result {
|
||||||
|
Ok(v) => {
|
||||||
|
sqlx::query("COMMIT").execute(&mut *conn).await?;
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = sqlx::query("ROLLBACK").execute(&mut *conn).await;
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debit `cost` and grant one entitlement, atomically. Fail-closed: if the club
|
||||||
|
/// cannot afford `cost`, nothing is debited and no entitlement is created.
|
||||||
|
pub async fn purchase_entitlement(
|
||||||
|
pool: &Pool,
|
||||||
|
club_id: &str,
|
||||||
|
cost: i64,
|
||||||
|
definition_id: &str,
|
||||||
|
) -> AppResult<PurchaseReceipt> {
|
||||||
|
let mut conn = pool.acquire().await?;
|
||||||
|
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||||
|
let result = async {
|
||||||
|
let balance = debit(&mut conn, club_id, cost).await?;
|
||||||
|
let entitlement_id = grant_entitlement(&mut conn, club_id, definition_id).await?;
|
||||||
|
Ok(PurchaseReceipt {
|
||||||
|
balance,
|
||||||
|
entitlement_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
finish(&mut conn, result).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debit `cost` and mint one owned item, atomically. Fail-closed: if the club
|
||||||
|
/// cannot afford `cost`, nothing is debited and no item is added. This is the
|
||||||
|
/// "buy a specific item" primitive (a debit paired with an inventory add), for
|
||||||
|
/// synthetic-seller markets where the purchased item is minted rather than
|
||||||
|
/// transferred from another owner. Returns the post-debit balance.
|
||||||
|
pub async fn purchase_item(
|
||||||
|
pool: &Pool,
|
||||||
|
club_id: &str,
|
||||||
|
cost: i64,
|
||||||
|
item_id: &str,
|
||||||
|
card_id: &str,
|
||||||
|
) -> AppResult<i64> {
|
||||||
|
let mut conn = pool.acquire().await?;
|
||||||
|
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||||
|
let result = async {
|
||||||
|
let balance = debit(&mut conn, club_id, cost).await?;
|
||||||
|
add_item(&mut conn, club_id, item_id, card_id).await?;
|
||||||
|
Ok(balance)
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
finish(&mut conn, result).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debit `cost` and mint several owned items, atomically. Fail-closed: if the
|
||||||
|
/// club cannot afford `cost`, nothing is debited and no items are added; if any
|
||||||
|
/// item insert fails the whole purchase rolls back. This is the "buy + open"
|
||||||
|
/// primitive (Store packs that open on purchase): one debit paired with the
|
||||||
|
/// minted pack contents. Returns the post-debit balance.
|
||||||
|
pub async fn purchase_items(
|
||||||
|
pool: &Pool,
|
||||||
|
club_id: &str,
|
||||||
|
cost: i64,
|
||||||
|
items: &[GrantedItem],
|
||||||
|
) -> AppResult<i64> {
|
||||||
|
let mut conn = pool.acquire().await?;
|
||||||
|
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||||
|
let result = async {
|
||||||
|
let balance = debit(&mut conn, club_id, cost).await?;
|
||||||
|
for item in items {
|
||||||
|
add_item(&mut conn, club_id, &item.item_id, &item.card_id).await?;
|
||||||
|
}
|
||||||
|
Ok(balance)
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
finish(&mut conn, result).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume an entitlement once and add its granted items, atomically. If any
|
||||||
|
/// item insert fails (e.g. a colliding instance id) the whole redemption rolls
|
||||||
|
/// back — the entitlement stays unconsumed and no items are persisted.
|
||||||
|
pub async fn redeem_entitlement(
|
||||||
|
pool: &Pool,
|
||||||
|
club_id: &str,
|
||||||
|
entitlement_id: &str,
|
||||||
|
items: &[GrantedItem],
|
||||||
|
) -> AppResult<String> {
|
||||||
|
let mut conn = pool.acquire().await?;
|
||||||
|
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||||
|
let result = async {
|
||||||
|
let definition_id = consume_entitlement(&mut conn, club_id, entitlement_id).await?;
|
||||||
|
for item in items {
|
||||||
|
add_item(&mut conn, club_id, &item.item_id, &item.card_id).await?;
|
||||||
|
}
|
||||||
|
Ok(definition_id)
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
finish(&mut conn, result).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an owned item and credit `price`, atomically. Fail-closed: if the item
|
||||||
|
/// is not owned by the club nothing is credited.
|
||||||
|
pub async fn sell_item(pool: &Pool, club_id: &str, item_id: &str, price: i64) -> AppResult<i64> {
|
||||||
|
let mut conn = pool.acquire().await?;
|
||||||
|
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||||
|
let result = async {
|
||||||
|
remove_item(&mut conn, club_id, item_id).await?;
|
||||||
|
credit(&mut conn, club_id, price).await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
finish(&mut conn, result).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Credit a reward to a club's balance atomically.
|
||||||
|
pub async fn grant_reward(pool: &Pool, club_id: &str, amount: i64) -> AppResult<i64> {
|
||||||
|
let mut conn = pool.acquire().await?;
|
||||||
|
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||||
|
let result = async { credit(&mut conn, club_id, amount).await }.await;
|
||||||
|
finish(&mut conn, result).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const TS: &str = "2026-01-01T00:00:00Z";
|
||||||
|
|
||||||
|
/// In-memory pool with the real schema and one club (1000 coins) owning one
|
||||||
|
/// item (`item-x`). Mirrors the `squad` service test harness.
|
||||||
|
async fn fixture() -> Pool {
|
||||||
|
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.expect("in-memory sqlite");
|
||||||
|
sqlx::migrate!("./migrations")
|
||||||
|
.run(&pool)
|
||||||
|
.await
|
||||||
|
.expect("migrations");
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO profiles (id, username, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind("prof")
|
||||||
|
.bind("prof")
|
||||||
|
.bind(TS)
|
||||||
|
.bind(TS)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("profile");
|
||||||
|
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
|
||||||
|
.bind("club")
|
||||||
|
.bind("prof")
|
||||||
|
.bind("club")
|
||||||
|
.bind(1000i64)
|
||||||
|
.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")
|
||||||
|
.bind("def-x")
|
||||||
|
.bind(TS)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("owned card");
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pack_count(pool: &Pool) -> i64 {
|
||||||
|
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM packs")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn item_count(pool: &Pool, item_id: &str) -> i64 {
|
||||||
|
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM owned_cards WHERE id = ?")
|
||||||
|
.bind(item_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn balance_reads_seeded_value() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
|
||||||
|
assert!(matches!(
|
||||||
|
balance(&pool, "ghost").await,
|
||||||
|
Err(AppError::NotFound(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn purchase_debits_and_grants() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
let receipt = purchase_entitlement(&pool, "club", 300, "def-pack")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(receipt.balance, 700);
|
||||||
|
assert_eq!(balance(&pool, "club").await.unwrap(), 700);
|
||||||
|
assert_eq!(pack_count(&pool).await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn purchase_insufficient_funds_rolls_back() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
let err = purchase_entitlement(&pool, "club", 5000, "def-pack")
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::BadRequest(_)));
|
||||||
|
// Nothing debited, no entitlement created.
|
||||||
|
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
|
||||||
|
assert_eq!(pack_count(&pool).await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn negative_amount_is_rejected() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
assert!(matches!(
|
||||||
|
purchase_entitlement(&pool, "club", -50, "def").await,
|
||||||
|
Err(AppError::BadRequest(_))
|
||||||
|
));
|
||||||
|
// sell with negative price hits the credit guard and rolls back the removal.
|
||||||
|
assert!(matches!(
|
||||||
|
sell_item(&pool, "club", "item-x", -1).await,
|
||||||
|
Err(AppError::BadRequest(_))
|
||||||
|
));
|
||||||
|
assert_eq!(item_count(&pool, "item-x").await, 1);
|
||||||
|
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn redeem_consumes_once_and_adds_items() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
let ent = purchase_entitlement(&pool, "club", 100, "def-pack")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.entitlement_id;
|
||||||
|
let items = vec![GrantedItem {
|
||||||
|
item_id: "item-a".into(),
|
||||||
|
card_id: "def-a".into(),
|
||||||
|
}];
|
||||||
|
let def = redeem_entitlement(&pool, "club", &ent, &items)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(def, "def-pack");
|
||||||
|
assert_eq!(item_count(&pool, "item-a").await, 1);
|
||||||
|
// Second redeem of the same entitlement is rejected; inventory unchanged.
|
||||||
|
let err = redeem_entitlement(&pool, "club", &ent, &items)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::Conflict(_)));
|
||||||
|
assert_eq!(item_count(&pool, "item-a").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn redeem_missing_entitlement_is_not_found() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
assert!(matches!(
|
||||||
|
redeem_entitlement(&pool, "club", "no-such", &[]).await,
|
||||||
|
Err(AppError::NotFound(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn redeem_partial_failure_rolls_back() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
let ent = purchase_entitlement(&pool, "club", 100, "def-pack")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.entitlement_id;
|
||||||
|
// Second item collides with the first instance id -> PK violation mid-loop.
|
||||||
|
let items = vec![
|
||||||
|
GrantedItem {
|
||||||
|
item_id: "dup".into(),
|
||||||
|
card_id: "def-a".into(),
|
||||||
|
},
|
||||||
|
GrantedItem {
|
||||||
|
item_id: "dup".into(),
|
||||||
|
card_id: "def-b".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let err = redeem_entitlement(&pool, "club", &ent, &items)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::Database(_)));
|
||||||
|
// Whole redemption rolled back: entitlement still unconsumed, no items added.
|
||||||
|
assert_eq!(item_count(&pool, "dup").await, 0);
|
||||||
|
let def = redeem_entitlement(
|
||||||
|
&pool,
|
||||||
|
"club",
|
||||||
|
&ent,
|
||||||
|
&[GrantedItem {
|
||||||
|
item_id: "dup".into(),
|
||||||
|
card_id: "def-a".into(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(def, "def-pack");
|
||||||
|
assert_eq!(item_count(&pool, "dup").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sell_removes_and_credits() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
let new_balance = sell_item(&pool, "club", "item-x", 250).await.unwrap();
|
||||||
|
assert_eq!(new_balance, 1250);
|
||||||
|
assert_eq!(item_count(&pool, "item-x").await, 0);
|
||||||
|
// Selling it again fails; balance is unchanged.
|
||||||
|
assert!(matches!(
|
||||||
|
sell_item(&pool, "club", "item-x", 250).await,
|
||||||
|
Err(AppError::NotFound(_))
|
||||||
|
));
|
||||||
|
assert_eq!(balance(&pool, "club").await.unwrap(), 1250);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn grant_reward_credits() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
assert_eq!(grant_reward(&pool, "club", 500).await.unwrap(), 1500);
|
||||||
|
assert_eq!(balance(&pool, "club").await.unwrap(), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn purchase_item_debits_and_mints() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
let bal = purchase_item(&pool, "club", 400, "item-new", "def-new")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(bal, 600);
|
||||||
|
assert_eq!(balance(&pool, "club").await.unwrap(), 600);
|
||||||
|
assert_eq!(item_count(&pool, "item-new").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn purchase_item_insufficient_funds_rolls_back() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
let err = purchase_item(&pool, "club", 9000, "item-new", "def-new")
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::BadRequest(_)));
|
||||||
|
// Nothing debited, no item minted.
|
||||||
|
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
|
||||||
|
assert_eq!(item_count(&pool, "item-new").await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn purchase_items_debits_and_mints_all() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
let items = vec![
|
||||||
|
GrantedItem {
|
||||||
|
item_id: "p-1".into(),
|
||||||
|
card_id: "d-1".into(),
|
||||||
|
},
|
||||||
|
GrantedItem {
|
||||||
|
item_id: "p-2".into(),
|
||||||
|
card_id: "d-2".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let bal = purchase_items(&pool, "club", 700, &items).await.unwrap();
|
||||||
|
assert_eq!(bal, 300);
|
||||||
|
assert_eq!(item_count(&pool, "p-1").await, 1);
|
||||||
|
assert_eq!(item_count(&pool, "p-2").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn purchase_items_insufficient_funds_rolls_back() {
|
||||||
|
let pool = fixture().await;
|
||||||
|
let items = vec![GrantedItem {
|
||||||
|
item_id: "p-1".into(),
|
||||||
|
card_id: "d-1".into(),
|
||||||
|
}];
|
||||||
|
let err = purchase_items(&pool, "club", 9000, &items)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, AppError::BadRequest(_)));
|
||||||
|
assert_eq!(balance(&pool, "club").await.unwrap(), 1000);
|
||||||
|
assert_eq!(item_count(&pool, "p-1").await, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,13 @@ pub struct ImportOwnedCard {
|
|||||||
pub card_id: String,
|
pub card_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ImportEntitlement {
|
||||||
|
/// Opaque definition reference for one unconsumed entitlement (e.g. a pack
|
||||||
|
/// id as text). Core stores it verbatim; it never interprets the value.
|
||||||
|
pub definition_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ImportSlot {
|
pub struct ImportSlot {
|
||||||
pub owned_item_id: String,
|
pub owned_item_id: String,
|
||||||
@@ -93,6 +100,9 @@ pub struct ProfileImportRequest {
|
|||||||
pub owned: Vec<ImportOwnedCard>,
|
pub owned: Vec<ImportOwnedCard>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub squad: Option<ImportSquad>,
|
pub squad: Option<ImportSquad>,
|
||||||
|
/// Unconsumed entitlements to seed (e.g. from a source's unopened packs).
|
||||||
|
#[serde(default)]
|
||||||
|
pub entitlements: Vec<ImportEntitlement>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||||
@@ -244,6 +254,19 @@ pub async fn apply_profile_import(
|
|||||||
.with_context(|| format!("insert owned_card {}", o.owned_item_id))?;
|
.with_context(|| format!("insert owned_card {}", o.owned_item_id))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for e in &req.entitlements {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO packs (id, club_id, definition_id, opened, created_at) VALUES (?, ?, ?, 0, ?)",
|
||||||
|
)
|
||||||
|
.bind(Uuid::new_v4().to_string())
|
||||||
|
.bind(&club_id)
|
||||||
|
.bind(&e.definition_id)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("insert entitlement {}", e.definition_id))?;
|
||||||
|
}
|
||||||
|
|
||||||
let mut squad_slots = 0usize;
|
let mut squad_slots = 0usize;
|
||||||
if let Some(sq) = &req.squad {
|
if let Some(sq) = &req.squad {
|
||||||
let squad_id = Uuid::new_v4().to_string();
|
let squad_id = Uuid::new_v4().to_string();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod card_db;
|
|||||||
pub mod checkin;
|
pub mod checkin;
|
||||||
pub mod club;
|
pub mod club;
|
||||||
pub mod draft;
|
pub mod draft;
|
||||||
|
pub mod economy;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod fut_champs;
|
pub mod fut_champs;
|
||||||
pub mod game_ext;
|
pub mod game_ext;
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//! Reproduction for the fresh-DB multi-connection warm-up write failure.
|
||||||
|
//! Forces several pooled connections to open concurrently on a brand-new DB and
|
||||||
|
//! captures the ACTUAL sqlx/SQLite error (not the service's generic string).
|
||||||
|
|
||||||
|
use openfut_core::db::{init_pool, run_migrations};
|
||||||
|
use openfut_core::services::economy;
|
||||||
|
|
||||||
|
async fn seed_club(pool: &sqlx::SqlitePool) {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO profiles (id, username, created_at, updated_at) VALUES ('p','p','t','t')",
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query("INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) VALUES ('c','p','c',100000,'t','t')")
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
|
||||||
|
async fn fresh_db_multiconn_concurrent_writes() {
|
||||||
|
let base = std::env::temp_dir().join(format!("ofut-cc-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
let iters = 100usize;
|
||||||
|
let mut failures = 0usize;
|
||||||
|
let mut first_err = String::new();
|
||||||
|
for i in 0..iters {
|
||||||
|
let url = format!("sqlite://{}/db{i}.db", base.display());
|
||||||
|
let pool = init_pool(&url, 5).await.expect("init_pool");
|
||||||
|
run_migrations(&pool).await.expect("migrations");
|
||||||
|
seed_club(&pool).await;
|
||||||
|
// Fire concurrent credits to force several connections to warm up at once
|
||||||
|
// on the brand-new DB, then a write — the harness's failing shape.
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for _ in 0..8 {
|
||||||
|
let p = pool.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
economy::grant_reward(&p, "c", 1).await
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for h in handles {
|
||||||
|
match h.await.unwrap() {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
failures += 1;
|
||||||
|
if first_err.is_empty() {
|
||||||
|
first_err = format!("{e:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Serialization correctness: 8 concurrent +1 credits, no lost update.
|
||||||
|
let bal = economy::balance(&pool, "c").await.unwrap();
|
||||||
|
assert_eq!(bal, 100_008, "iter {i}: lost update under concurrency");
|
||||||
|
pool.close().await;
|
||||||
|
}
|
||||||
|
std::fs::remove_dir_all(&base).ok();
|
||||||
|
assert_eq!(
|
||||||
|
failures,
|
||||||
|
0,
|
||||||
|
"{failures}/{} iterations had a write failure; first error: {first_err}",
|
||||||
|
iters * 8
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
use openfut_core::services::card_db::CardDb;
|
use openfut_core::services::card_db::CardDb;
|
||||||
use openfut_core::services::import::{
|
use openfut_core::services::import::{
|
||||||
apply_profile_import, ImportClub, ImportExtension, ImportOwnedCard, ImportProfile, ImportSlot,
|
apply_profile_import, ImportClub, ImportEntitlement, ImportExtension, ImportOwnedCard,
|
||||||
ImportSquad, ProfileImportRequest,
|
ImportProfile, ImportSlot, ImportSquad, ProfileImportRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
async fn fresh_pool() -> sqlx::SqlitePool {
|
async fn fresh_pool() -> sqlx::SqlitePool {
|
||||||
@@ -79,6 +79,7 @@ fn request(
|
|||||||
},
|
},
|
||||||
owned,
|
owned,
|
||||||
squad,
|
squad,
|
||||||
|
entitlements: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +129,33 @@ async fn imports_profile_club_owned_and_squad_in_one_shot() {
|
|||||||
assert_eq!(stored_import_fp, "fp-happy");
|
assert_eq!(stored_import_fp, "fp-happy");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn imports_entitlements_seeds_unopened_packs() {
|
||||||
|
let pool = fresh_pool().await;
|
||||||
|
let db = CardDb::load("data").unwrap();
|
||||||
|
let ids = valid_ids(2);
|
||||||
|
let ow = owned(&ids);
|
||||||
|
let mut req = request("g_ent", "fp-ent", ow, None);
|
||||||
|
req.entitlements = vec![
|
||||||
|
ImportEntitlement {
|
||||||
|
definition_id: "70".into(),
|
||||||
|
},
|
||||||
|
ImportEntitlement {
|
||||||
|
definition_id: "70".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
apply_profile_import(&pool, &db, &req)
|
||||||
|
.await
|
||||||
|
.expect("import");
|
||||||
|
// Two unconsumed entitlements seeded into packs (opened = 0).
|
||||||
|
assert_eq!(count(&pool, "packs").await, 2);
|
||||||
|
let unopened: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM packs WHERE opened = 0")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(unopened, 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn rerun_same_fingerprint_is_idempotent_noop() {
|
async fn rerun_same_fingerprint_is_idempotent_noop() {
|
||||||
let pool = fresh_pool().await;
|
let pool = fresh_pool().await;
|
||||||
|
|||||||
+616
-178
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user