economy(fifa17): land Store + Market writer handlers + pack generator (unrouted)
Implements the FIFA17 economy WRITER cluster on top of the landed Core
economy authority + host CoreEconomy client + identity/item-shaper infra.
Handlers are pub, unit-tested, and NOT yet routed: classify() and
ROUTE_AUTHORITY are untouched — the classifier barrier is a later single
coherent flip. No stubs; real Core-backed behavior; fail-closed on CoreError.
Pack generator (adapter fut/pack_content.rs):
generate_pack_contents(&PackDef, &mut impl Rng, &[GeneratedCandidate])
-> Vec<GeneratedCard>. Pure, seeded (deterministic), gold-tier split +
special_chance gate as documented OPENFUT PLACEHOLDER policy (Python
open_pack/_pack_body parity note inline). Fail-closed empty on empty pool.
Store/item writers (host economy_store.rs), matching oracle wire shapes:
- handle_store_buy PUT /store/transaction -> purchase_items (debit+mint N)
-> createPackResponse; cancel/unknown/owned_only -> 200 {}; insufficient
-> 461 {reason,credits}; CoreError -> 503.
- handle_pack_open POST /purchased -> owned_only consumes the unopened
entitlement (redeem_entitlement, consume-once); normal packs debit+mint.
- handle_quick_sell{_path,_body} DELETE .../item/<id> + POST /ut/delete/.../item
-> reverse-resolve wire->Core id (SquadWireResolver) -> sell_item ->
{items:[{id}],totalCredits}; not-owned skipped.
Production OwnedItemLookup = CoreItemLookup over CoreAccess.
Market (host market_store.rs / pile_store.rs / market.rs), synthetic-seller:
- MarketStore over sqlx SQLite (WAL-once + busy_timeout=5s + BEGIN IMMEDIATE
for writes, mirroring openfut-core::db). listings(active/reserved/sold/
cancelled), owner-checked cancel, CAS reserve/complete_sale/rollback.
Typed errors NotFound/Sold/Cancelled/WrongOwner/Conflict.
- PileStore: durable pile/location metadata keyed by Core item id.
- handle_market_{list,query,cancel,buy} + handle_move_items. Buy-now =
reserve (CAS) -> balance precheck (461) -> Core purchase_item (mint+debit)
-> complete_sale; any Core failure rolls the reservation back active.
Two concurrent buyers -> exactly one sale + one debit.
Deps (additive): rand 0.8 (adapter+host), sqlx 0.7 sqlite/runtime-tokio (host).
Tests: adapter +7 (pack_content), host +43 (economy_store 20, market/store 23
incl two_reservers_exactly_one_wins, two_buyers_exactly_one_sale_one_debit,
state_survives_reopen, move_persists_across_reopen). All green; clippy
-D warnings clean; rustfmt clean.
This commit is contained in:
@@ -0,0 +1,608 @@
|
||||
//! 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::time::Duration;
|
||||
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
|
||||
use sqlx::{ConnectOptions, Connection, Row, SqlitePool};
|
||||
|
||||
/// 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,
|
||||
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>,
|
||||
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,
|
||||
}
|
||||
|
||||
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,
|
||||
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
|
||||
)";
|
||||
|
||||
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"),
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
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)?;
|
||||
Ok(MarketStore { pool })
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
start_price: i64,
|
||||
buy_now_price: i64,
|
||||
owner: Option<&str>,
|
||||
) -> 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, \
|
||||
start_price, buy_now_price, owner, state, created_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?)",
|
||||
)
|
||||
.bind(listing_id)
|
||||
.bind(card_id)
|
||||
.bind(core_item_id)
|
||||
.bind(wire_item_id)
|
||||
.bind(start_price)
|
||||
.bind(buy_now_price)
|
||||
.bind(owner)
|
||||
.bind(&created_at)
|
||||
.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,
|
||||
start_price,
|
||||
buy_now_price,
|
||||
owner: owner.map(str::to_string),
|
||||
state: "active".to_string(),
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
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> {
|
||||
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.cas(listing_id, "reserved", "sold").await? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(MarketError::Conflict)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, 900, 2500, 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, 1, 2, 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,
|
||||
900,
|
||||
2500,
|
||||
Some("alice"),
|
||||
)
|
||||
.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),
|
||||
900,
|
||||
2500,
|
||||
Some("alice"),
|
||||
)
|
||||
.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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user