feat(consume): durable per-instance contract state + HTTP apply route
CI / Build, lint & test (push) Successful in 3m16s
CI / Build, lint & test (push) Successful in 3m16s
`consume_item` was a complete, tested, atomic apply transaction with zero
production callers and no route -- it could not be reached over HTTP because
its effect is an in-process `ItemMutation` trait object and the host is a
separate process on a synchronous JSON boundary.
Closes that gap with a CLOSED, Core-validated effect vocabulary rather than a
pass-through: `InstanceEffect::AddContractMatches { amount, cap,
default_when_unset }`. A generic "apply this field/value" escape hatch would
hand economic authority back to the caller and break the architecture.
The read-modify-write runs INSIDE the caller's transaction
(`min(cap, COALESCE(contract_matches, default) + amount)`) so two concurrent
applies cannot lose an update, and the reported `granted` stays the requested
amount even when the cap clamps the total.
Migration 0028 adds `owned_cards.contract_matches` NULLABLE: NULL means "Core
tracks no contract here", which keeps the pack-fresh default (a FIFA-specific
7) out of Core and leaves every existing row unchanged in meaning. ADD COLUMN,
not a rebuild -- a rebuild would drop 0026's transfer trigger.
Two ordering fixes forced by putting this on the live path:
* consume_item moves from DEFERRED `pool.begin()` to `BEGIN IMMEDIATE`, the
discipline economy.rs documents: three reads precede the first write, which
is exactly the shape that returns SQLITE_BUSY past the busy handler.
* the replay answer now precedes source validation. With DestroyInstance the
first apply deletes the source, so the old order answered a retry with 404
instead of the recorded outcome -- replay semantics were unreachable.
This commit is contained in:
+262
-166
@@ -2,17 +2,24 @@
|
||||
//!
|
||||
//! ONE Core transaction that does, in this order and nothing else:
|
||||
//!
|
||||
//! 1. validate the SOURCE — it exists, belongs to the club, and is the
|
||||
//! 1. answer a REPLAY — if `(profile_id, action_identity)` is already recorded,
|
||||
//! echo that outcome and touch nothing. This precedes every validation on
|
||||
//! purpose: a completed application has already DESTROYED its source, so
|
||||
//! checking the source first would answer "not found" to a retried request
|
||||
//! that in fact succeeded;
|
||||
//! 2. validate the SOURCE — it exists, belongs to the club, and is the
|
||||
//! `ContentKind` the caller expected;
|
||||
//! 2. validate the TARGET — nothing at all (`ConsumeTarget::Club`) or an owned
|
||||
//! 3. validate the TARGET — nothing at all (`ConsumeTarget::Club`) or an owned
|
||||
//! instance that exists, belongs to the club, and is the expected kind;
|
||||
//! 3. write the replay guard — `UNIQUE(profile_id, action_identity)` on
|
||||
//! 4. write the replay guard — `UNIQUE(profile_id, action_identity)` on
|
||||
//! `consumable_applications`, so a duplicate is refused BEFORE anything is
|
||||
//! mutated or consumed (same discipline as `match_completions`);
|
||||
//! 4. apply the caller's mutation to the target;
|
||||
//! 5. consume the source EXACTLY ONCE — destroy the instance, or decrement its
|
||||
//! mutated or consumed (same discipline as `match_completions`). Step 1 is
|
||||
//! a courtesy; THIS is the guarantee, and it holds against a writer on any
|
||||
//! other connection or process;
|
||||
//! 5. apply the caller's mutation to the target;
|
||||
//! 6. consume the source EXACTLY ONCE — destroy the instance, or decrement its
|
||||
//! stack and destroy it at zero;
|
||||
//! 6. commit.
|
||||
//! 7. commit.
|
||||
//!
|
||||
//! Anything failing at any step rolls the whole thing back: the source is never
|
||||
//! spent without the effect landing, and the effect never lands without the
|
||||
@@ -21,9 +28,10 @@
|
||||
//! Core deliberately supplies **no per-category formula**. What a fitness card,
|
||||
//! a contract, a chemistry style or a position modifier actually DOES to its
|
||||
//! target is the calling game adapter's reversed behaviour, passed in as
|
||||
//! [`ItemMutation`]; an unreversed behaviour must not be invented here, and the
|
||||
//! honest stopping point for one is ownership + projection, i.e. not calling
|
||||
//! this function at all.
|
||||
//! [`ItemMutation`] — either in-process, or described over the wire through the
|
||||
//! closed, Core-validated vocabulary in [`crate::services::instance_effect`]. An
|
||||
//! unreversed behaviour must not be invented here, and the honest stopping point
|
||||
//! for one is ownership + projection, i.e. not calling this function at all.
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
@@ -38,6 +46,7 @@ use crate::{
|
||||
db::Pool,
|
||||
error::{AppError, AppResult},
|
||||
models::card::{ContentKind, OwnedCard, OWNED_CARD_SELECT},
|
||||
services::economy,
|
||||
};
|
||||
|
||||
/// What the transaction does to the source instance once the effect is applied.
|
||||
@@ -169,6 +178,16 @@ fn require_kind(card: &OwnedCard, expected: ContentKind, role: &str) -> AppResul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether this call did the work or found the identity already recorded.
|
||||
///
|
||||
/// The replay branch cannot read the recorded outcome while the transaction is
|
||||
/// still open on this connection, so it is reported out of the transaction and
|
||||
/// answered once the connection is free again.
|
||||
enum Applied {
|
||||
Fresh(ConsumeOutcome),
|
||||
Replay,
|
||||
}
|
||||
|
||||
/// Apply one consumable to one target, exactly once. See the module docs.
|
||||
pub async fn consume_item<M: ItemMutation>(
|
||||
pool: &Pool,
|
||||
@@ -197,187 +216,231 @@ pub async fn consume_item<M: ItemMutation>(
|
||||
}
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
// ONE connection with an explicit BEGIN IMMEDIATE, never a DEFERRED
|
||||
// `pool.begin()`: this transaction performs three reads before its first
|
||||
// write, and a deferred transaction only upgrades to a write at that first
|
||||
// write — where SQLite answers SQLITE_BUSY *immediately*, bypassing
|
||||
// `busy_timeout` (the full reasoning is on `economy::finish`). Two clients
|
||||
// applying consumables at once is ordinary traffic, so take the write lock
|
||||
// up front and let a rival wait instead of fail.
|
||||
let mut conn = pool.acquire().await?;
|
||||
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
|
||||
|
||||
// 1. source: owned by this club, and the kind the caller expected.
|
||||
let source = fetch_owned(&mut tx, req.source_owned_card_id, club_id).await?;
|
||||
require_kind(&source, req.expected_source_kind, "source item")?;
|
||||
|
||||
// A source that is fielded in a squad cannot be consumed: `squad_players`
|
||||
// holds a FK onto `owned_cards(id)`, so the DELETE below would fail anyway.
|
||||
// Refuse explicitly instead of surfacing SQLITE_CONSTRAINT, and never
|
||||
// silently evict a lineup as a side effect of spending an item.
|
||||
let fielded =
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM squad_players WHERE owned_card_id = ?")
|
||||
.bind(req.source_owned_card_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if fielded > 0 {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"source item '{}' is fielded in a squad and cannot be consumed",
|
||||
req.source_owned_card_id
|
||||
)));
|
||||
}
|
||||
|
||||
// 2. target.
|
||||
let target = match req.target {
|
||||
ConsumeTarget::OwnedCard {
|
||||
owned_card_id,
|
||||
expected_kind,
|
||||
} => {
|
||||
let card = fetch_owned(&mut tx, owned_card_id, club_id).await?;
|
||||
require_kind(&card, expected_kind, "target item")?;
|
||||
Some(card)
|
||||
let result = async {
|
||||
// 1. a replay is answered before anything is validated: a completed
|
||||
// application has already destroyed (or drawn down) its source, so
|
||||
// validating first would answer NotFound to a retry of a request that
|
||||
// actually succeeded. The write lock is already held, so this read
|
||||
// cannot race the guard INSERT below — which stays as the real
|
||||
// guarantee for a writer on any other connection.
|
||||
if recorded(&mut conn, profile_id, req.action_identity).await? {
|
||||
return Ok(Applied::Replay);
|
||||
}
|
||||
ConsumeTarget::Club => None,
|
||||
};
|
||||
|
||||
// 3. replay guard FIRST — before the mutation and before the consumption, so
|
||||
// a duplicate cannot apply a second effect or spend a second charge. The
|
||||
// recorded `effect` is filled in below, once the mutation has produced it.
|
||||
let application_id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let guard = sqlx::query(
|
||||
"INSERT INTO consumable_applications \
|
||||
(id, profile_id, action_identity, source_owned_card_id, source_card_id, \
|
||||
source_content_kind, source_consumed, source_quantity_after, \
|
||||
target_owned_card_id, effect, applied_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?, '', ?)",
|
||||
)
|
||||
.bind(&application_id)
|
||||
.bind(profile_id)
|
||||
.bind(req.action_identity)
|
||||
.bind(&source.id)
|
||||
.bind(&source.card_id)
|
||||
.bind(source.content_kind.as_str())
|
||||
.bind(target.as_ref().map(|t| t.id.as_str()))
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
match guard {
|
||||
Ok(_) => {}
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
tx.rollback().await?;
|
||||
return already_applied(pool, profile_id, req.action_identity).await;
|
||||
// 2. source: owned by this club, and the kind the caller expected.
|
||||
let source = fetch_owned(&mut conn, req.source_owned_card_id, club_id).await?;
|
||||
require_kind(&source, req.expected_source_kind, "source item")?;
|
||||
|
||||
// A source that is fielded in a squad cannot be consumed: `squad_players`
|
||||
// holds a FK onto `owned_cards(id)`, so the DELETE below would fail anyway.
|
||||
// Refuse explicitly instead of surfacing SQLITE_CONSTRAINT, and never
|
||||
// silently evict a lineup as a side effect of spending an item.
|
||||
let fielded = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM squad_players WHERE owned_card_id = ?",
|
||||
)
|
||||
.bind(req.source_owned_card_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
if fielded > 0 {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"source item '{}' is fielded in a squad and cannot be consumed",
|
||||
req.source_owned_card_id
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
tx.rollback().await?;
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
// 4. the caller's effect on the target, inside this transaction.
|
||||
let ctx = ConsumeContext {
|
||||
profile_id: profile_id.to_string(),
|
||||
club_id: club_id.to_string(),
|
||||
source,
|
||||
target,
|
||||
};
|
||||
let effect = mutation.apply(&mut tx, &ctx).await?;
|
||||
|
||||
// 5. consume the source exactly once. Both paths assert rows_affected == 1,
|
||||
// so a concurrent spend of the same instance (which lost the SQLite write
|
||||
// lock and now sees the row gone / already decremented) fails instead of
|
||||
// granting a second effect.
|
||||
let (source_destroyed, source_quantity_after) = match req.consumption {
|
||||
SourceConsumption::DestroyInstance => {
|
||||
let deleted = sqlx::query("DELETE FROM owned_cards WHERE id = ? AND club_id = ?")
|
||||
.bind(&ctx.source.id)
|
||||
.bind(club_id)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if deleted != 1 {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"source item '{}' was already consumed",
|
||||
ctx.source.id
|
||||
)));
|
||||
// 3. target.
|
||||
let target = match req.target {
|
||||
ConsumeTarget::OwnedCard {
|
||||
owned_card_id,
|
||||
expected_kind,
|
||||
} => {
|
||||
let card = fetch_owned(&mut conn, owned_card_id, club_id).await?;
|
||||
require_kind(&card, expected_kind, "target item")?;
|
||||
Some(card)
|
||||
}
|
||||
(true, None)
|
||||
ConsumeTarget::Club => None,
|
||||
};
|
||||
|
||||
// 4. replay guard — the durable one. It precedes the mutation and the
|
||||
// consumption, so a duplicate that got past step 1 on another
|
||||
// connection still cannot apply a second effect or spend a second
|
||||
// charge. The recorded `effect` is filled in below, once the mutation
|
||||
// has produced it.
|
||||
let application_id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let guard = sqlx::query(
|
||||
"INSERT INTO consumable_applications \
|
||||
(id, profile_id, action_identity, source_owned_card_id, source_card_id, \
|
||||
source_content_kind, source_consumed, source_quantity_after, \
|
||||
target_owned_card_id, effect, applied_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?, '', ?)",
|
||||
)
|
||||
.bind(&application_id)
|
||||
.bind(profile_id)
|
||||
.bind(req.action_identity)
|
||||
.bind(&source.id)
|
||||
.bind(&source.card_id)
|
||||
.bind(source.content_kind.as_str())
|
||||
.bind(target.as_ref().map(|t| t.id.as_str()))
|
||||
.bind(&now)
|
||||
.execute(&mut *conn)
|
||||
.await;
|
||||
match guard {
|
||||
Ok(_) => {}
|
||||
// A collision writes nothing, so this transaction has nothing to undo
|
||||
// and simply ends; the recorded outcome is read back afterwards.
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => return Ok(Applied::Replay),
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
SourceConsumption::DecrementStack { amount } => {
|
||||
let Some(have) = ctx.source.quantity else {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"source item '{}' carries no stack size; it can only be destroyed",
|
||||
ctx.source.id
|
||||
)));
|
||||
};
|
||||
if have < amount {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"source item '{}' holds {have}, cannot consume {amount}",
|
||||
ctx.source.id
|
||||
)));
|
||||
}
|
||||
let remaining = have - amount;
|
||||
if remaining == 0 {
|
||||
let deleted = sqlx::query(
|
||||
"DELETE FROM owned_cards WHERE id = ? AND club_id = ? AND quantity = ?",
|
||||
)
|
||||
.bind(&ctx.source.id)
|
||||
.bind(club_id)
|
||||
.bind(have)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
// 5. the caller's effect on the target, inside this transaction.
|
||||
let ctx = ConsumeContext {
|
||||
profile_id: profile_id.to_string(),
|
||||
club_id: club_id.to_string(),
|
||||
source,
|
||||
target,
|
||||
};
|
||||
let effect = mutation.apply(&mut conn, &ctx).await?;
|
||||
|
||||
// 6. consume the source exactly once. Both paths assert rows_affected == 1,
|
||||
// so a concurrent spend of the same instance (which lost the SQLite write
|
||||
// lock and now sees the row gone / already decremented) fails instead of
|
||||
// granting a second effect.
|
||||
let (source_destroyed, source_quantity_after) = match req.consumption {
|
||||
SourceConsumption::DestroyInstance => {
|
||||
let deleted = sqlx::query("DELETE FROM owned_cards WHERE id = ? AND club_id = ?")
|
||||
.bind(&ctx.source.id)
|
||||
.bind(club_id)
|
||||
.execute(&mut *conn)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if deleted != 1 {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"source item '{}' changed under us",
|
||||
"source item '{}' was already consumed",
|
||||
ctx.source.id
|
||||
)));
|
||||
}
|
||||
(true, None)
|
||||
} else {
|
||||
let updated = sqlx::query(
|
||||
"UPDATE owned_cards SET quantity = ? WHERE id = ? AND club_id = ? \
|
||||
AND quantity = ?",
|
||||
)
|
||||
.bind(remaining)
|
||||
.bind(&ctx.source.id)
|
||||
.bind(club_id)
|
||||
.bind(have)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if updated != 1 {
|
||||
}
|
||||
SourceConsumption::DecrementStack { amount } => {
|
||||
let Some(have) = ctx.source.quantity else {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"source item '{}' carries no stack size; it can only be destroyed",
|
||||
ctx.source.id
|
||||
)));
|
||||
};
|
||||
if have < amount {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"source item '{}' changed under us",
|
||||
"source item '{}' holds {have}, cannot consume {amount}",
|
||||
ctx.source.id
|
||||
)));
|
||||
}
|
||||
(false, Some(remaining))
|
||||
let remaining = have - amount;
|
||||
if remaining == 0 {
|
||||
let deleted = sqlx::query(
|
||||
"DELETE FROM owned_cards WHERE id = ? AND club_id = ? AND quantity = ?",
|
||||
)
|
||||
.bind(&ctx.source.id)
|
||||
.bind(club_id)
|
||||
.bind(have)
|
||||
.execute(&mut *conn)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if deleted != 1 {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"source item '{}' changed under us",
|
||||
ctx.source.id
|
||||
)));
|
||||
}
|
||||
(true, None)
|
||||
} else {
|
||||
let updated = sqlx::query(
|
||||
"UPDATE owned_cards SET quantity = ? WHERE id = ? AND club_id = ? \
|
||||
AND quantity = ?",
|
||||
)
|
||||
.bind(remaining)
|
||||
.bind(&ctx.source.id)
|
||||
.bind(club_id)
|
||||
.bind(have)
|
||||
.execute(&mut *conn)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if updated != 1 {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"source item '{}' changed under us",
|
||||
ctx.source.id
|
||||
)));
|
||||
}
|
||||
(false, Some(remaining))
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
let effect_text = serde_json::to_string(&effect)?;
|
||||
sqlx::query(
|
||||
"UPDATE consumable_applications \
|
||||
SET effect = ?, source_consumed = ?, source_quantity_after = ? WHERE id = ?",
|
||||
let effect_text = serde_json::to_string(&effect)?;
|
||||
sqlx::query(
|
||||
"UPDATE consumable_applications \
|
||||
SET effect = ?, source_consumed = ?, source_quantity_after = ? WHERE id = ?",
|
||||
)
|
||||
.bind(&effect_text)
|
||||
.bind(i64::from(source_destroyed))
|
||||
.bind(source_quantity_after)
|
||||
.bind(&application_id)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Ok(Applied::Fresh(ConsumeOutcome {
|
||||
applied: true,
|
||||
action_identity: req.action_identity.to_string(),
|
||||
source_owned_card_id: ctx.source.id.clone(),
|
||||
source_destroyed,
|
||||
source_quantity_after,
|
||||
target_owned_card_id: ctx.target.as_ref().map(|t| t.id.clone()),
|
||||
effect,
|
||||
}))
|
||||
}
|
||||
.await;
|
||||
|
||||
match economy::finish(&mut conn, result).await? {
|
||||
Applied::Fresh(outcome) => Ok(outcome),
|
||||
// Read the recorded outcome on the SAME connection: acquiring a second one
|
||||
// while still holding this one deadlocks a pool saturated with racing
|
||||
// appliers, which is exactly the case a replay shows up in.
|
||||
Applied::Replay => already_applied(&mut conn, profile_id, req.action_identity).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Has this identity already been applied? Read inside the transaction, under
|
||||
/// the write lock, so the answer cannot go stale before the guard INSERT.
|
||||
async fn recorded(
|
||||
conn: &mut SqliteConnection,
|
||||
profile_id: &str,
|
||||
action_identity: &str,
|
||||
) -> AppResult<bool> {
|
||||
let hits = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM consumable_applications \
|
||||
WHERE profile_id = ? AND action_identity = ?",
|
||||
)
|
||||
.bind(&effect_text)
|
||||
.bind(i64::from(source_destroyed))
|
||||
.bind(source_quantity_after)
|
||||
.bind(&application_id)
|
||||
.execute(&mut *tx)
|
||||
.bind(profile_id)
|
||||
.bind(action_identity)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(ConsumeOutcome {
|
||||
applied: true,
|
||||
action_identity: req.action_identity.to_string(),
|
||||
source_owned_card_id: ctx.source.id.clone(),
|
||||
source_destroyed,
|
||||
source_quantity_after,
|
||||
target_owned_card_id: ctx.target.as_ref().map(|t| t.id.clone()),
|
||||
effect,
|
||||
})
|
||||
Ok(hits > 0)
|
||||
}
|
||||
|
||||
/// Echo the recorded outcome of an application that already happened. Mutates
|
||||
/// nothing and reports `applied = false`.
|
||||
async fn already_applied(
|
||||
pool: &Pool,
|
||||
conn: &mut SqliteConnection,
|
||||
profile_id: &str,
|
||||
action_identity: &str,
|
||||
) -> AppResult<ConsumeOutcome> {
|
||||
@@ -388,7 +451,7 @@ async fn already_applied(
|
||||
)
|
||||
.bind(profile_id)
|
||||
.bind(action_identity)
|
||||
.fetch_optional(pool)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::Internal(anyhow::anyhow!(
|
||||
@@ -621,6 +684,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A retry of a request that ALREADY succeeded must replay, not 404. With
|
||||
/// `DestroyInstance` the source no longer exists by then, so answering the
|
||||
/// replay has to precede source validation — otherwise a client that lost the
|
||||
/// response to a successful apply is told its item was never there.
|
||||
#[tokio::test]
|
||||
async fn a_replay_survives_the_source_it_destroyed() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
let spend = || {
|
||||
req(
|
||||
"act-gone",
|
||||
"single",
|
||||
SourceConsumption::DestroyInstance,
|
||||
"player",
|
||||
)
|
||||
};
|
||||
let first = consume_item(&pool, "prof-a", "club-a", &spend(), &BumpTraining)
|
||||
.await
|
||||
.expect("first");
|
||||
assert!(first.applied);
|
||||
|
||||
let replay = consume_item(&pool, "prof-a", "club-a", &spend(), &BumpTraining)
|
||||
.await
|
||||
.expect("a retry must replay, not fail on the destroyed source");
|
||||
assert!(!replay.applied);
|
||||
assert!(replay.source_destroyed);
|
||||
assert_eq!(replay.effect, first.effect);
|
||||
assert_eq!(training(&pool, "player").await, 1, "effect applied once");
|
||||
assert_eq!(
|
||||
count(&pool, "SELECT COUNT(*) FROM consumable_applications").await,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inline_closure_effect_is_accepted() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
|
||||
Reference in New Issue
Block a user