Files
OpenFUT/openfut-utas-host/src/market_store.rs
T
2026-08-18 18:26:35 +00:00

1051 lines
39 KiB
Rust

//! Durable FIFA 17 transfer-market **listing** store.
//!
//! This is host-owned FIFA policy state, NOT generic Core inventory. Core stays
//! the sole authority for coins and item ownership; the market layer owns only
//! the durable *listing* lifecycle (who is selling what, at what price, and in
//! which state). It is backed by its own SQLite file so it survives restart.
//!
//! ## Concurrency model (load-bearing)
//!
//! The pool is opened exactly like [`openfut_core::db::init_pool`]: WAL is
//! established once on the file before the pool opens, every pooled connection
//! carries `foreign_keys=ON` and a 5s `busy_timeout`, and **every write runs
//! inside a `BEGIN IMMEDIATE` transaction**. Immediate transactions take the
//! write lock up front, so the reserve compare-and-swap is genuinely atomic
//! across connections — the exact class of bug (deferred transactions racing a
//! read-then-write) that was just fixed in Core. Two buyers reserving the same
//! active listing therefore resolve to exactly one winner.
//!
//! ## State machine
//!
//! ```text
//! active ──reserve──▶ reserved ──complete_sale──▶ sold
//! │ │
//! │ └──rollback_reservation──▶ active
//! └──cancel──▶ cancelled
//! ```
//!
//! `reserved` is a real state (a listing being paid for), so it is part of the
//! `CHECK` constraint even though it is a transient intermediate — omitting it
//! would make [`MarketStore::reserve_listing`] fail the constraint.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use parking_lot::Mutex;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use sqlx::{ConnectOptions, Connection, Row, SqlitePool};
/// Test-only durable-store fault injector, shared (cheap `Arc` clone) between a
/// store and the failure-injection tests. It is **inert in production**: nothing
/// arms it, so each guarded op reads one relaxed atomic and behaves exactly as
/// before. The economy failure tests use it to force a durable-store write to
/// fail at a chosen point (market complete-sale / reserve, pile write) — the
/// only way to exercise those recovery paths, since the stores are concrete
/// types wired straight into the handlers (no trait seam to substitute).
#[derive(Clone, Default)]
pub struct StoreFault {
inner: Arc<StoreFaultInner>,
}
#[derive(Default)]
struct StoreFaultInner {
any: AtomicBool,
armed: Mutex<HashMap<&'static str, u32>>,
}
impl StoreFault {
/// Arm `op` to fail its next `times` invocations, then heal automatically.
pub fn arm(&self, op: &'static str, times: u32) {
self.inner.armed.lock().insert(op, times);
self.inner.any.store(true, Ordering::SeqCst);
}
/// Consume one armed unit for `op`, returning whether it should fail now.
/// Fast path (unarmed): a single relaxed atomic load, no lock taken.
pub fn tripped(&self, op: &'static str) -> bool {
if !self.inner.any.load(Ordering::Relaxed) {
return false;
}
let mut armed = self.inner.armed.lock();
let fire = match armed.get_mut(op) {
Some(n) if *n > 0 => {
*n -= 1;
true
}
_ => false,
};
if armed.values().all(|&n| n == 0) {
self.inner.any.store(false, Ordering::SeqCst);
}
fire
}
}
/// Typed failure of a listing operation. `Db` wraps an infrastructure error
/// (transport/encoding); everything else is a modelled lifecycle outcome.
#[derive(Debug)]
pub enum MarketError {
/// No listing with that id exists.
NotFound,
/// The listing has already been sold.
Sold,
/// The listing has already been cancelled.
Cancelled,
/// The caller is not the owner of the listing.
WrongOwner,
/// The listing was not in the state the transition required (e.g. a
/// reserved listing asked to cancel, or a duplicate id on insert).
Conflict,
/// SQLite / transport failure.
Db(String),
}
impl std::fmt::Display for MarketError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MarketError::NotFound => write!(f, "listing not found"),
MarketError::Sold => write!(f, "listing already sold"),
MarketError::Cancelled => write!(f, "listing already cancelled"),
MarketError::WrongOwner => write!(f, "listing owned by another seller"),
MarketError::Conflict => write!(f, "listing state conflict"),
MarketError::Db(e) => write!(f, "market store db error: {e}"),
}
}
}
impl std::error::Error for MarketError {}
fn db(e: sqlx::Error) -> MarketError {
MarketError::Db(e.to_string())
}
/// One transfer-market listing row.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Listing {
pub listing_id: String,
/// Authoritative Core content/card id — what a synthetic buy MINTS. Distinct
/// from the FIFA wire `resourceId` (see `wire_resource_id`).
pub card_id: String,
/// Set for a seller-listed owned item; `None` for a synthetic-seller
/// listing (the buy path mints a fresh Core item instead of transferring).
pub core_item_id: Option<String>,
/// The FIFA wire item id of a seller-listed owned item, if any.
pub wire_item_id: Option<i64>,
/// The FIFA wire `resourceId` (versioned) the client listed, echoed back in
/// the auction record. Never used to mint — the mint uses `card_id`.
pub wire_resource_id: Option<i64>,
pub start_price: i64,
pub buy_now_price: i64,
/// Opaque seller identity; `None` for synthetic listings.
pub owner: Option<String>,
/// `active` | `reserved` | `sold` | `cancelled`.
pub state: String,
/// Creation time, unix-epoch milliseconds as a string (sortable).
pub created_at: String,
/// The FIFA card object (`itemData`) as shaped at listing time, serialized.
/// A listing is a SNAPSHOT: the auction record must carry the full card the
/// client can render (rating/position/attributes/rareflag/assetId), not a
/// stub — a stub leaves the Transfer List with an unrenderable row. `None`
/// only for rows written before this column existed (renders as a stub).
pub item_json: Option<String>,
/// Listing duration in SECONDS, as sent by the client in the `ISStart` body
/// (`duration`). With `created_at` this is the whole auction clock: FIFA 17
/// renders a live countdown from `expires` and expects it to reach 0, so a
/// listing has to know when it ends. `None` for rows written before this
/// column existed, which fall back to the default duration.
pub duration_secs: Option<i64>,
}
/// FIFA 17 auction durations, in seconds: 3600, 10800, 21600, 43200, 86400,
/// 259200. One hour is the shortest, and the fallback when a client body omits it
/// or a pre-column row is read.
pub const DEFAULT_DURATION_SECS: i64 = 3600;
/// Seconds since the unix epoch.
pub fn now_secs() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
impl Listing {
/// SECONDS REMAINING on this auction at `now` (unix seconds), clamped at 0 —
/// the wire semantics of `expires`, which is never an absolute epoch.
///
/// A closed/sold/cancelled listing reads 0: there is no time left on an
/// auction that has already ended.
pub fn expires_in_secs(&self, now: i64) -> i64 {
if self.state != "active" {
return 0;
}
let created_secs = self
.created_at
.parse::<i64>()
.map(|ms| ms / 1000)
.unwrap_or(now);
let duration = self.duration_secs.unwrap_or(DEFAULT_DURATION_SECS);
(created_secs + duration - now).max(0)
}
}
const CREATE_LISTINGS: &str = "CREATE TABLE IF NOT EXISTS listings (
listing_id TEXT PRIMARY KEY,
card_id TEXT NOT NULL,
core_item_id TEXT,
wire_item_id INTEGER,
wire_resource_id INTEGER,
start_price INTEGER NOT NULL,
buy_now_price INTEGER NOT NULL,
owner TEXT,
state TEXT NOT NULL CHECK (state IN ('active','reserved','sold','cancelled')),
created_at TEXT NOT NULL,
item_json TEXT,
duration_secs INTEGER,
-- Seller acknowledgement of a SOLD row, separate from the sale itself. Declared
-- here so a fresh store never needs the ALTER path below; the additive
-- migration exists only for stores created before this column.
cleared_at TEXT
)";
fn now_millis() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
.to_string()
}
fn row_to_listing(row: &sqlx::sqlite::SqliteRow) -> Listing {
Listing {
listing_id: row.get("listing_id"),
card_id: row.get("card_id"),
core_item_id: row.get("core_item_id"),
wire_item_id: row.get("wire_item_id"),
wire_resource_id: row.get("wire_resource_id"),
start_price: row.get("start_price"),
buy_now_price: row.get("buy_now_price"),
owner: row.get("owner"),
state: row.get("state"),
created_at: row.get("created_at"),
item_json: row.get("item_json"),
duration_secs: row.get("duration_secs"),
}
}
/// Durable listing store over an sqlx SQLite pool. Cheap to clone (the pool is
/// an `Arc` internally), so the same store can be shared across tasks.
#[derive(Clone)]
pub struct MarketStore {
pool: SqlitePool,
fault: StoreFault,
}
impl MarketStore {
/// Open (creating if missing) the market DB at `path`, mirroring Core's
/// `init_pool`: establish WAL once on the file, then open a multi-connection
/// pool where every connection carries foreign_keys + a busy_timeout.
pub async fn open(path: &str) -> Result<Self, MarketError> {
let opts = SqliteConnectOptions::new()
.filename(path)
.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, so
// pooled connections only ever re-assert an already-WAL file (see Core).
{
let mut conn = opts.clone().connect().await.map_err(db)?;
sqlx::query("PRAGMA journal_mode=WAL")
.execute(&mut conn)
.await
.map_err(db)?;
conn.close().await.map_err(db)?;
}
let pool = SqlitePoolOptions::new()
.max_connections(8)
.connect_with(opts)
.await
.map_err(db)?;
sqlx::query(CREATE_LISTINGS)
.execute(&pool)
.await
.map_err(db)?;
// Additive migration: `item_json` was added after the first stores shipped,
// and `CREATE TABLE IF NOT EXISTS` will not add a column to an existing
// file. Add it when absent so an existing market DB keeps working (old
// rows read back `None` and render the stub card).
let existing: Vec<String> = sqlx::query("PRAGMA table_info(listings)")
.fetch_all(&pool)
.await
.map_err(db)?
.iter()
.map(|r| r.get::<String, _>("name"))
.collect();
for (col, decl) in [
("item_json", "TEXT"),
("duration_secs", "INTEGER"),
// A SOLD listing is not the end of the seller's involvement: FIFA 17 has
// a bulk `DELETE …/trade/sold` verb (builder 0x1801647c0, request name
// RemoveAllSoldFromTradePile), which only makes sense if sold rows
// PERSIST in the seller's pile until cleared. `cleared_at` records that
// acknowledgement separately from the sale itself, so clearing a row can
// never be mistaken for re-settling it.
("cleared_at", "TEXT"),
] {
if !existing.iter().any(|c| c == col) {
sqlx::query(&format!("ALTER TABLE listings ADD COLUMN {col} {decl}"))
.execute(&pool)
.await
.map_err(db)?;
}
}
Ok(MarketStore {
pool,
fault: StoreFault::default(),
})
}
/// A shared handle to this store's test-only fault switch (inert unless a
/// test arms it). Production never calls it.
pub fn fault(&self) -> StoreFault {
self.fault.clone()
}
/// Insert a new `active` listing. `listing_id` is the numeric-string trade id
/// the client keys the auction on (the caller allocates it). Duplicate id ->
/// [`MarketError::Conflict`].
#[allow(clippy::too_many_arguments)]
pub async fn create_listing(
&self,
listing_id: &str,
card_id: &str,
core_item_id: Option<&str>,
wire_item_id: Option<i64>,
wire_resource_id: Option<i64>,
start_price: i64,
buy_now_price: i64,
owner: Option<&str>,
// The shaped FIFA card snapshot (`itemData`) for the auction record.
item_json: Option<&str>,
// Listing duration in seconds from the client's `ISStart` body; `None`
// falls back to [`DEFAULT_DURATION_SECS`].
duration_secs: Option<i64>,
) -> Result<Listing, MarketError> {
let created_at = now_millis();
let mut conn = self.pool.acquire().await.map_err(db)?;
sqlx::query("BEGIN IMMEDIATE")
.execute(&mut *conn)
.await
.map_err(db)?;
let res = sqlx::query(
"INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, \
wire_resource_id, start_price, buy_now_price, owner, state, created_at, item_json, \
duration_secs) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)",
)
.bind(listing_id)
.bind(card_id)
.bind(core_item_id)
.bind(wire_item_id)
.bind(wire_resource_id)
.bind(start_price)
.bind(buy_now_price)
.bind(owner)
.bind(&created_at)
.bind(item_json)
.bind(duration_secs)
.execute(&mut *conn)
.await;
match res {
Ok(_) => {
sqlx::query("COMMIT")
.execute(&mut *conn)
.await
.map_err(db)?;
Ok(Listing {
listing_id: listing_id.to_string(),
card_id: card_id.to_string(),
core_item_id: core_item_id.map(str::to_string),
wire_item_id,
wire_resource_id,
start_price,
buy_now_price,
owner: owner.map(str::to_string),
state: "active".to_string(),
created_at,
item_json: item_json.map(str::to_string),
duration_secs,
})
}
Err(e) => {
let _ = sqlx::query("ROLLBACK").execute(&mut *conn).await;
// A PK clash is a caller-level conflict, not an infra failure.
if matches!(&e, sqlx::Error::Database(dbe) if dbe.is_unique_violation()) {
Err(MarketError::Conflict)
} else {
Err(db(e))
}
}
}
}
/// Fetch one listing, or [`MarketError::NotFound`].
pub async fn get_listing(&self, listing_id: &str) -> Result<Listing, MarketError> {
let row = sqlx::query("SELECT * FROM listings WHERE listing_id = ?")
.bind(listing_id)
.fetch_optional(&self.pool)
.await
.map_err(db)?;
row.as_ref()
.map(row_to_listing)
.ok_or(MarketError::NotFound)
}
/// All listings in `state`, oldest first.
pub async fn query_listings(&self, state: &str) -> Result<Vec<Listing>, MarketError> {
let rows = sqlx::query("SELECT * FROM listings WHERE state = ? ORDER BY created_at ASC")
.bind(state)
.fetch_all(&self.pool)
.await
.map_err(db)?;
Ok(rows.iter().map(row_to_listing).collect())
}
/// Atomic compare-and-swap of a single listing's state inside a
/// `BEGIN IMMEDIATE` transaction. `Ok(true)` = the row was in `from` and is
/// now `to`; `Ok(false)` = the row exists but was not in `from` (lost race /
/// wrong state); `Err(NotFound)` = no such row.
async fn cas(&self, listing_id: &str, from: &str, to: &str) -> Result<bool, MarketError> {
let mut conn = self.pool.acquire().await.map_err(db)?;
sqlx::query("BEGIN IMMEDIATE")
.execute(&mut *conn)
.await
.map_err(db)?;
let outcome: Result<bool, MarketError> = async {
let current: Option<String> =
sqlx::query("SELECT state FROM listings WHERE listing_id = ?")
.bind(listing_id)
.fetch_optional(&mut *conn)
.await
.map_err(db)?
.map(|r| r.get::<String, _>("state"));
match current {
None => Err(MarketError::NotFound),
Some(s) if s == from => {
sqlx::query("UPDATE listings SET state = ? WHERE listing_id = ? AND state = ?")
.bind(to)
.bind(listing_id)
.bind(from)
.execute(&mut *conn)
.await
.map_err(db)?;
Ok(true)
}
Some(_) => Ok(false),
}
}
.await;
match &outcome {
Ok(_) => {
sqlx::query("COMMIT")
.execute(&mut *conn)
.await
.map_err(db)?;
}
Err(_) => {
let _ = sqlx::query("ROLLBACK").execute(&mut *conn).await;
}
}
outcome
}
/// Reserve an `active` listing (`active -> reserved`). Returns whether this
/// caller won the reservation. Exactly one of two concurrent callers wins.
pub async fn reserve_listing(&self, listing_id: &str) -> Result<bool, MarketError> {
if self.fault.tripped("reserve") {
return Err(MarketError::Db("injected reserve fault".into()));
}
self.cas(listing_id, "active", "reserved").await
}
/// Finalise a won reservation (`reserved -> sold`). A listing not in
/// `reserved` is a [`MarketError::Conflict`].
pub async fn complete_sale(&self, listing_id: &str) -> Result<(), MarketError> {
if self.fault.tripped("complete_sale") {
return Err(MarketError::Db("injected complete_sale fault".into()));
}
if self.cas(listing_id, "reserved", "sold").await? {
Ok(())
} else {
Err(MarketError::Conflict)
}
}
/// Mark a live listing SOLD in one step (`active | reserved -> sold`), for a
/// sale driven by a counterparty rather than by this client's own buy-now.
/// Returns whether this call was the one that sold it, so a replay is visible
/// to the caller instead of silently settling twice.
pub async fn mark_sold(&self, listing_id: &str) -> Result<bool, MarketError> {
let affected = sqlx::query(
"UPDATE listings SET state = 'sold' \
WHERE listing_id = ? AND state IN ('active', 'reserved')",
)
.bind(listing_id)
.execute(&self.pool)
.await
.map_err(db)?
.rows_affected();
Ok(affected == 1)
}
/// Sold listings the seller has NOT yet cleared, newest first.
///
/// Separate from [`Self::query_listings`] because "sold" and "still shown to
/// the seller" are different facts: a sold row stays in the pile until the
/// client acknowledges it via the bulk clear verb.
pub async fn uncleared_sold(&self) -> Result<Vec<Listing>, MarketError> {
let rows = sqlx::query(
"SELECT * FROM listings WHERE state = 'sold' AND cleared_at IS NULL \
ORDER BY created_at DESC",
)
.fetch_all(&self.pool)
.await
.map_err(db)?;
Ok(rows.iter().map(row_to_listing).collect())
}
/// Acknowledge every uncleared sold listing (the bulk `DELETE …/trade/sold`).
/// Returns how many rows were cleared.
///
/// This is PRESENTATION ONLY. It records that the seller has seen the sale; it
/// moves no coins and no ownership, because settlement already happened when
/// the sale completed. Clearing must never be able to pay anyone twice.
pub async fn clear_sold(&self) -> Result<u64, MarketError> {
Ok(sqlx::query(
"UPDATE listings SET cleared_at = ? \
WHERE state = 'sold' AND cleared_at IS NULL",
)
.bind(now_millis())
.execute(&self.pool)
.await
.map_err(db)?
.rows_affected())
}
/// Acknowledge ONE sold listing by id (the per-id `DELETE …/trade/{id}` form,
/// if the client turns out to use it for sold rows). Same presentation-only
/// contract as [`Self::clear_sold`].
pub async fn clear_sold_one(&self, listing_id: &str) -> Result<u64, MarketError> {
Ok(sqlx::query(
"UPDATE listings SET cleared_at = ? \
WHERE listing_id = ? AND state = 'sold' AND cleared_at IS NULL",
)
.bind(now_millis())
.bind(listing_id)
.execute(&self.pool)
.await
.map_err(db)?
.rows_affected())
}
/// Undo a reservation on a downstream failure (`reserved -> active`), so the
/// listing becomes buyable again. Not in `reserved` -> [`MarketError::Conflict`].
pub async fn rollback_reservation(&self, listing_id: &str) -> Result<(), MarketError> {
if self.cas(listing_id, "reserved", "active").await? {
Ok(())
} else {
Err(MarketError::Conflict)
}
}
/// Cancel an `active` listing once (`active -> cancelled`). If `owner` is
/// supplied it must match the listing's owner. Returns typed errors for
/// every non-active state so a double cancel is observable.
pub async fn cancel_listing(
&self,
listing_id: &str,
owner: Option<&str>,
) -> Result<(), MarketError> {
let mut conn = self.pool.acquire().await.map_err(db)?;
sqlx::query("BEGIN IMMEDIATE")
.execute(&mut *conn)
.await
.map_err(db)?;
let outcome: Result<(), MarketError> = async {
let row = sqlx::query("SELECT state, owner FROM listings WHERE listing_id = ?")
.bind(listing_id)
.fetch_optional(&mut *conn)
.await
.map_err(db)?;
let row = row.ok_or(MarketError::NotFound)?;
let state: String = row.get("state");
let stored_owner: Option<String> = row.get("owner");
if let Some(want) = owner {
if stored_owner.as_deref() != Some(want) {
return Err(MarketError::WrongOwner);
}
}
match state.as_str() {
"active" => {
sqlx::query(
"UPDATE listings SET state = 'cancelled' \
WHERE listing_id = ? AND state = 'active'",
)
.bind(listing_id)
.execute(&mut *conn)
.await
.map_err(db)?;
Ok(())
}
"sold" => Err(MarketError::Sold),
"cancelled" => Err(MarketError::Cancelled),
_ => Err(MarketError::Conflict),
}
}
.await;
match &outcome {
Ok(_) => {
sqlx::query("COMMIT")
.execute(&mut *conn)
.await
.map_err(db)?;
}
Err(_) => {
let _ = sqlx::query("ROLLBACK").execute(&mut *conn).await;
}
}
outcome
}
/// RE-LIST an existing auction row for the same item: reset the clock to now
/// and take the new prices/duration.
///
/// FIFA 17 relists by sending a fresh `ISStart` POST for an item that already
/// has a listing row, so the primary-key conflict is EXPECTED and means
/// "relist", not "error". Treating that conflict as success is how a relist
/// silently did nothing: the client was acked while the stale, already-expired
/// row kept its old `created_at` and stayed expired.
///
/// Only an `active` (including aged-out) or `cancelled` row may be relisted. A
/// `sold` or `reserved` row is NEVER resurrected — the card is gone or in
/// flight, and re-opening that auction would sell a card twice.
pub async fn relist_listing(
&self,
listing_id: &str,
start_price: i64,
buy_now_price: i64,
duration_secs: Option<i64>,
item_json: Option<&str>,
) -> Result<Listing, MarketError> {
let created_at = now_millis();
let affected = sqlx::query(
"UPDATE listings SET state = 'active', created_at = ?, start_price = ?, \
buy_now_price = ?, duration_secs = ?, item_json = COALESCE(?, item_json) \
WHERE listing_id = ? AND state IN ('active', 'cancelled')",
)
.bind(&created_at)
.bind(start_price)
.bind(buy_now_price)
.bind(duration_secs)
.bind(item_json)
.bind(listing_id)
.execute(&self.pool)
.await
.map_err(db)?
.rows_affected();
if affected == 0 {
// Either no such row, or it is sold/reserved and must not be revived.
return Err(match self.get_listing(listing_id).await {
Ok(l) if l.state == "sold" => MarketError::Sold,
Ok(_) => MarketError::Conflict,
Err(e) => e,
});
}
self.get_listing(listing_id).await
}
/// Cancel any `active` listing held by a Core owned item, returning how many
/// rows were cancelled (0 when the item has no live auction).
///
/// This is the RETURN-TO-CLUB transition: the client sends a pile move for an
/// expired transfer-list item, and the auction that put it there has to end with
/// it. Without this the pile says `club` while the listing row stays `active`,
/// so the card is still filtered out of `/club` AND still rendered in the
/// Transfer List — the item appears not to move at all.
///
/// Deliberately scoped to `active`: a `reserved` row is mid-sale and a `sold`
/// row is already gone, and cancelling either would let a card be both sold and
/// returned.
pub async fn cancel_active_for_core_item(
&self,
core_item_id: &str,
) -> Result<u64, MarketError> {
Ok(sqlx::query(
"UPDATE listings SET state = 'cancelled' \
WHERE core_item_id = ? AND state = 'active'",
)
.bind(core_item_id)
.execute(&self.pool)
.await
.map_err(db)?
.rows_affected())
}
pub async fn has_active_for_core_item(&self, core_item_id: &str) -> Result<bool, MarketError> {
sqlx::query_scalar(
"SELECT EXISTS( \
SELECT 1 FROM listings WHERE core_item_id = ? AND state = 'active' \
)",
)
.bind(core_item_id)
.fetch_one(&self.pool)
.await
.map_err(db)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
/// A unique temp DB path that deletes its file (and WAL/SHM sidecars) on
/// drop. Holding the guard keeps the file alive across store reopens.
struct TempDb(String);
impl TempDb {
fn new() -> Self {
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::SeqCst);
let path =
std::env::temp_dir().join(format!("ofut-market-{}-{n}.db", std::process::id()));
TempDb(path.to_string_lossy().into_owned())
}
fn path(&self) -> &str {
&self.0
}
}
impl Drop for TempDb {
fn drop(&mut self) {
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{}{suffix}", self.0));
}
}
}
#[tokio::test]
async fn relist_resets_the_clock_and_takes_the_new_prices() {
// FIFA 17 relists by re-sending ISStart for an item that already has a row,
// so the PK conflict is the relist path. Before this existed the conflict was
// acked as success and the stale expired row kept its old created_at, so the
// card never came back to the market.
let (store, _d) = temp_store().await;
let first = seed(&store, "900000001").await;
// Age it out by rewriting created_at to well past its default duration.
let stale = (now_secs() - DEFAULT_DURATION_SECS - 600) * 1000;
sqlx::query("UPDATE listings SET created_at = ? WHERE listing_id = ?")
.bind(stale.to_string())
.bind("900000001")
.execute(&store.pool)
.await
.unwrap();
let expired = store.get_listing("900000001").await.unwrap();
assert_eq!(
expired.expires_in_secs(now_secs()),
0,
"precondition: the listing has run out"
);
let relisted = store
.relist_listing("900000001", 250, 5000, Some(10_800), None)
.await
.unwrap();
assert_eq!(relisted.state, "active");
assert_eq!(relisted.start_price, 250, "new start price applied");
assert_eq!(relisted.buy_now_price, 5000, "new buy-now applied");
assert_eq!(relisted.duration_secs, Some(10_800));
assert!(
relisted.expires_in_secs(now_secs()) > 0,
"the clock actually restarted"
);
assert_ne!(
relisted.created_at, expired.created_at,
"created_at moved forward"
);
// The snapshot is preserved when the relist does not supply a new one.
assert_eq!(relisted.item_json, first.item_json);
}
#[tokio::test]
async fn relist_never_revives_a_sold_or_reserved_auction() {
// Re-opening a sold auction would sell the same card twice.
let (store, _d) = temp_store().await;
seed(&store, "900000001").await;
assert!(store.reserve_listing("900000001").await.unwrap());
assert!(
matches!(
store.relist_listing("900000001", 1, 2, None, None).await,
Err(MarketError::Conflict)
),
"a reserved (in-flight) auction is not relistable"
);
store.complete_sale("900000001").await.unwrap();
assert!(
matches!(
store.relist_listing("900000001", 1, 2, None, None).await,
Err(MarketError::Sold)
),
"a sold auction is never resurrected"
);
assert_eq!(store.get_listing("900000001").await.unwrap().state, "sold");
// A cancelled listing IS relistable (the card came back to the pile).
seed(&store, "900000002").await;
store.cancel_listing("900000002", None).await.unwrap();
let back = store
.relist_listing("900000002", 300, 900, None, None)
.await
.unwrap();
assert_eq!(back.state, "active");
assert_eq!(back.start_price, 300);
}
#[tokio::test]
async fn relist_of_a_missing_row_is_not_found() {
let (store, _d) = temp_store().await;
assert!(matches!(
store.relist_listing("900000999", 1, 2, None, None).await,
Err(MarketError::NotFound)
));
}
async fn temp_store() -> (MarketStore, TempDb) {
let db = TempDb::new();
let store = MarketStore::open(db.path()).await.unwrap();
(store, db)
}
async fn seed(store: &MarketStore, id: &str) -> Listing {
store
.create_listing(
id,
"card_pl_001",
None,
None,
None,
900,
2500,
None,
None,
None,
)
.await
.unwrap()
}
#[tokio::test]
async fn create_get_query_roundtrip() {
let (store, _d) = temp_store().await;
let created = seed(&store, "900000001").await;
assert_eq!(created.state, "active");
assert_eq!(created.buy_now_price, 2500);
let got = store.get_listing("900000001").await.unwrap();
assert_eq!(got, created);
assert!(matches!(
store.get_listing("nope").await,
Err(MarketError::NotFound)
));
let active = store.query_listings("active").await.unwrap();
assert_eq!(active.len(), 1);
assert!(store.query_listings("sold").await.unwrap().is_empty());
}
#[tokio::test]
async fn duplicate_id_is_conflict() {
let (store, _d) = temp_store().await;
seed(&store, "900000001").await;
assert!(matches!(
store
.create_listing(
"900000001",
"card_pl_002",
None,
None,
None,
1,
2,
None,
None,
None
)
.await,
Err(MarketError::Conflict)
));
}
#[tokio::test]
async fn reserve_complete_lifecycle() {
let (store, _d) = temp_store().await;
seed(&store, "900000001").await;
assert!(store.reserve_listing("900000001").await.unwrap());
// Second reserve of a now-reserved listing loses.
assert!(!store.reserve_listing("900000001").await.unwrap());
store.complete_sale("900000001").await.unwrap();
assert_eq!(store.get_listing("900000001").await.unwrap().state, "sold");
// Completing again (not reserved) is a conflict.
assert!(matches!(
store.complete_sale("900000001").await,
Err(MarketError::Conflict)
));
}
#[tokio::test]
async fn rollback_restores_active() {
let (store, _d) = temp_store().await;
seed(&store, "900000001").await;
assert!(store.reserve_listing("900000001").await.unwrap());
store.rollback_reservation("900000001").await.unwrap();
assert_eq!(
store.get_listing("900000001").await.unwrap().state,
"active"
);
// Buyable again after rollback.
assert!(store.reserve_listing("900000001").await.unwrap());
}
#[tokio::test]
async fn cancel_once_then_errors() {
let (store, _d) = temp_store().await;
seed(&store, "900000001").await;
store.cancel_listing("900000001", None).await.unwrap();
assert_eq!(
store.get_listing("900000001").await.unwrap().state,
"cancelled"
);
assert!(matches!(
store.cancel_listing("900000001", None).await,
Err(MarketError::Cancelled)
));
}
#[tokio::test]
async fn cancel_checks_owner() {
let (store, _d) = temp_store().await;
store
.create_listing(
"900000001",
"card_pl_001",
None,
None,
None,
900,
2500,
Some("alice"),
None,
None,
)
.await
.unwrap();
assert!(matches!(
store.cancel_listing("900000001", Some("mallory")).await,
Err(MarketError::WrongOwner)
));
store
.cancel_listing("900000001", Some("alice"))
.await
.unwrap();
assert_eq!(
store.get_listing("900000001").await.unwrap().state,
"cancelled"
);
}
#[tokio::test]
async fn cannot_reserve_sold_or_cancelled() {
let (store, _d) = temp_store().await;
seed(&store, "sold_one").await;
store.reserve_listing("sold_one").await.unwrap();
store.complete_sale("sold_one").await.unwrap();
assert!(!store.reserve_listing("sold_one").await.unwrap());
seed(&store, "cancel_one").await;
store.cancel_listing("cancel_one", None).await.unwrap();
assert!(!store.reserve_listing("cancel_one").await.unwrap());
}
/// Two tasks reserve the SAME active listing concurrently -> exactly one wins.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn two_reservers_exactly_one_wins() {
let (store, _d) = temp_store().await;
seed(&store, "900000001").await;
let store = Arc::new(store);
let a = {
let s = store.clone();
tokio::spawn(async move { s.reserve_listing("900000001").await.unwrap() })
};
let b = {
let s = store.clone();
tokio::spawn(async move { s.reserve_listing("900000001").await.unwrap() })
};
let (ra, rb) = (a.await.unwrap(), b.await.unwrap());
assert_ne!(ra, rb, "exactly one reserver must win");
assert!(ra || rb, "one reserver must win");
assert_eq!(
store.get_listing("900000001").await.unwrap().state,
"reserved"
);
}
/// State survives closing and reopening the store file (durable, not in-memory).
#[tokio::test]
async fn state_survives_reopen() {
let db = TempDb::new();
let path = db.path();
{
let store = MarketStore::open(path).await.unwrap();
store
.create_listing(
"900000001",
"card_pl_001",
Some("core-7"),
Some(100004617),
Some(169193),
900,
2500,
Some("alice"),
Some(r#"{"rating":84}"#),
None,
)
.await
.unwrap();
store
.cancel_listing("900000001", Some("alice"))
.await
.unwrap();
// pool dropped at end of scope
}
let reopened = MarketStore::open(path).await.unwrap();
let got = reopened.get_listing("900000001").await.unwrap();
assert_eq!(got.state, "cancelled");
assert_eq!(got.core_item_id.as_deref(), Some("core-7"));
assert_eq!(got.wire_item_id, Some(100004617));
assert_eq!(got.owner.as_deref(), Some("alice"));
assert_eq!(
got.item_json.as_deref(),
Some(r#"{"rating":84}"#),
"card snapshot survives reopen"
);
}
}