//! 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, } #[derive(Default)] struct StoreFaultInner { any: AtomicBool, armed: Mutex>, } 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, /// The FIFA wire item id of a seller-listed owned item, if any. pub wire_item_id: Option, /// 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, pub start_price: i64, pub buy_now_price: i64, /// Opaque seller identity; `None` for synthetic listings. pub owner: Option, /// `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, /// 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, } /// 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::() .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 { 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 = sqlx::query("PRAGMA table_info(listings)") .fetch_all(&pool) .await .map_err(db)? .iter() .map(|r| r.get::("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, wire_resource_id: Option, 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, ) -> Result { 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 { 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, 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 { let mut conn = self.pool.acquire().await.map_err(db)?; sqlx::query("BEGIN IMMEDIATE") .execute(&mut *conn) .await .map_err(db)?; let outcome: Result = async { let current: Option = sqlx::query("SELECT state FROM listings WHERE listing_id = ?") .bind(listing_id) .fetch_optional(&mut *conn) .await .map_err(db)? .map(|r| r.get::("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 { 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 { 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, 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 { 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 { 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 = 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, item_json: Option<&str>, ) -> Result { 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 { 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 { 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" ); } }