Compare commits
1 Commits
90210702c3
...
1df03d4287
| Author | SHA1 | Date | |
|---|---|---|---|
| 1df03d4287 |
@@ -133,6 +133,23 @@ pub struct CompleteMatchRequest {
|
|||||||
/// its own wire). Only a caller using Core's season model opts in.
|
/// its own wire). Only a caller using Core's season model opts in.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub advance_season: bool,
|
pub advance_season: bool,
|
||||||
|
/// Owned-card instances that TOOK THE FIELD in this match, whose one-match
|
||||||
|
/// training effects it consumes.
|
||||||
|
///
|
||||||
|
/// Supplied by the caller rather than derived here, and deliberately so.
|
||||||
|
/// FIFA 17's training rule keys on the player PLAYING, and who played is
|
||||||
|
/// game-specific knowledge Core does not have: its match wire carries no
|
||||||
|
/// lineup at all (LIVE_PROVEN over 36,149 captured requests). Core must also
|
||||||
|
/// not resolve it from the squad at completion time, because the squad at
|
||||||
|
/// end is provably not the squad that started — a captured match began at
|
||||||
|
/// 20:33:20 and the next squad save landed 12 minutes later with no
|
||||||
|
/// `/match/end` in between. The adapter therefore snapshots at kickoff and
|
||||||
|
/// passes the result here.
|
||||||
|
///
|
||||||
|
/// Empty expires nothing, so a caller that cannot identify participants is
|
||||||
|
/// simply inert instead of clearing a whole club.
|
||||||
|
#[serde(default)]
|
||||||
|
pub participants: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Outcome of [`crate::services::match_service::complete_match`].
|
/// Outcome of [`crate::services::match_service::complete_match`].
|
||||||
@@ -158,6 +175,10 @@ pub struct MatchCompletionResult {
|
|||||||
/// Owned card ids removed because their loan expired on this match. Empty
|
/// Owned card ids removed because their loan expired on this match. Empty
|
||||||
/// unless the caller set `expire_loans`, and empty on a replay.
|
/// unless the caller set `expire_loans`, and empty on a replay.
|
||||||
pub expired_loans: Vec<String>,
|
pub expired_loans: Vec<String>,
|
||||||
|
/// Owned card instances whose one-match training effect this match consumed.
|
||||||
|
/// Empty when the caller passed no participants, and empty on a replay —
|
||||||
|
/// the effect is consumed exactly once, by the first completion.
|
||||||
|
pub expired_training: Vec<String>,
|
||||||
/// Present when this match ended a Core season. `None` unless the caller set
|
/// Present when this match ended a Core season. `None` unless the caller set
|
||||||
/// `advance_season`, and `None` on a replay.
|
/// `advance_season`, and `None` on a replay.
|
||||||
pub season_end: Option<crate::models::season::SeasonEndSummary>,
|
pub season_end: Option<crate::models::season::SeasonEndSummary>,
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ use crate::{
|
|||||||
objective::ObjectiveDefinition,
|
objective::ObjectiveDefinition,
|
||||||
profile::{coins_for_level, level_for_xp, pack_for_level, LevelUpEvent},
|
profile::{coins_for_level, level_for_xp, pack_for_level, LevelUpEvent},
|
||||||
},
|
},
|
||||||
services::{achievement, card_db::CardDb, objective, season as season_svc, statistics},
|
services::{
|
||||||
|
achievement, card_db::CardDb, objective, season as season_svc, statistics, training,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use rand::{seq::SliceRandom, Rng};
|
use rand::{seq::SliceRandom, Rng};
|
||||||
use sqlx::{Sqlite, Transaction};
|
use sqlx::{Sqlite, Transaction};
|
||||||
@@ -350,6 +352,7 @@ async fn complete_match_inner(
|
|||||||
let mut level_ups = Vec::new();
|
let mut level_ups = Vec::new();
|
||||||
let mut achievements_unlocked = Vec::new();
|
let mut achievements_unlocked = Vec::new();
|
||||||
let mut expired_loans = Vec::new();
|
let mut expired_loans = Vec::new();
|
||||||
|
let mut expired_training = Vec::new();
|
||||||
let mut season_end = None;
|
let mut season_end = None;
|
||||||
|
|
||||||
// A no-contest is recorded (history + idempotency) but has ZERO economic
|
// A no-contest is recorded (history + idempotency) but has ZERO economic
|
||||||
@@ -454,6 +457,12 @@ async fn complete_match_inner(
|
|||||||
season_end =
|
season_end =
|
||||||
season_svc::record_match_tx(&mut tx, club_id, profile_id, outcome, &now).await?;
|
season_svc::record_match_tx(&mut tx, club_id, profile_id, outcome, &now).await?;
|
||||||
}
|
}
|
||||||
|
// 9. One-match training effects are consumed by the players who took the
|
||||||
|
// field. Inside the same transaction and the same `is_economic`
|
||||||
|
// guard as everything else, so a NoContest voids it exactly as it
|
||||||
|
// voids coins and statistics, and a rollback leaves the boosts intact.
|
||||||
|
expired_training =
|
||||||
|
training::expire_for_instances_tx(&mut tx, club_id, &req.participants).await?;
|
||||||
}
|
}
|
||||||
inject_fault(fault, FaultPoint::BeforeCommit)?;
|
inject_fault(fault, FaultPoint::BeforeCommit)?;
|
||||||
|
|
||||||
@@ -475,6 +484,7 @@ async fn complete_match_inner(
|
|||||||
level_ups,
|
level_ups,
|
||||||
achievements_unlocked,
|
achievements_unlocked,
|
||||||
expired_loans,
|
expired_loans,
|
||||||
|
expired_training,
|
||||||
season_end,
|
season_end,
|
||||||
match_record,
|
match_record,
|
||||||
})
|
})
|
||||||
@@ -525,6 +535,7 @@ async fn already_completed(
|
|||||||
objectives_updated: vec![],
|
objectives_updated: vec![],
|
||||||
level_ups: vec![],
|
level_ups: vec![],
|
||||||
expired_loans: vec![],
|
expired_loans: vec![],
|
||||||
|
expired_training: vec![],
|
||||||
season_end: None,
|
season_end: None,
|
||||||
achievements_unlocked: vec![],
|
achievements_unlocked: vec![],
|
||||||
match_record,
|
match_record,
|
||||||
@@ -664,6 +675,7 @@ mod match_completion_tests {
|
|||||||
goal_positions: None,
|
goal_positions: None,
|
||||||
expire_loans: false,
|
expire_loans: false,
|
||||||
advance_season: false,
|
advance_season: false,
|
||||||
|
participants: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -950,6 +962,60 @@ mod match_completion_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Training expiry must be atomic with the match, in BOTH directions.
|
||||||
|
///
|
||||||
|
/// `BeforeCommit` is the discriminating fault: it fires AFTER the training
|
||||||
|
/// delete has already run inside the transaction. If the boost were removed
|
||||||
|
/// outside the transaction — or the transaction did not actually cover it —
|
||||||
|
/// the row would be gone here while the match itself rolled back, which is
|
||||||
|
/// exactly the split-brain state (match rejected, training consumed) that
|
||||||
|
/// must not exist.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_rolled_back_match_leaves_training_intact() {
|
||||||
|
let fx = new_fixture().await;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) \
|
||||||
|
VALUES ('inst', ?, 'card', 0, 't')",
|
||||||
|
)
|
||||||
|
.bind(CLUB)
|
||||||
|
.execute(&fx.pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO owned_card_training \
|
||||||
|
(owned_card_id, attribute_index, amount, source_card_id, applied_at) \
|
||||||
|
VALUES ('inst', 4, 15, 'fifa17_5003012', 't')",
|
||||||
|
)
|
||||||
|
.execute(&fx.pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut r = req("m", MatchResultKind::Win, 3, 1);
|
||||||
|
r.participants = vec!["inst".into()];
|
||||||
|
|
||||||
|
let failed = complete_match_inner(
|
||||||
|
&fx.pool,
|
||||||
|
PROFILE,
|
||||||
|
CLUB,
|
||||||
|
&r,
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
Some(FaultPoint::BeforeCommit),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(failed.is_err(), "the injected fault must fail the match");
|
||||||
|
assert_eq!(
|
||||||
|
count(&fx.pool, "owned_card_training").await,
|
||||||
|
1,
|
||||||
|
"a rolled-back match must NOT consume the boost"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the clean retry consumes it exactly once.
|
||||||
|
let ok = complete(&fx.pool, &r).await.unwrap();
|
||||||
|
assert_eq!(ok.expired_training, vec!["inst".to_string()]);
|
||||||
|
assert_eq!(count(&fx.pool, "owned_card_training").await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
fn obj(id: &str, metric: ObjectiveMetric, target: i64) -> ObjectiveDefinition {
|
fn obj(id: &str, metric: ObjectiveMetric, target: i64) -> ObjectiveDefinition {
|
||||||
ObjectiveDefinition {
|
ObjectiveDefinition {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
|
|||||||
@@ -74,6 +74,50 @@ pub async fn load_for_club(
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Consume the training effects of the instances that took the field, inside a
|
||||||
|
/// caller-supplied transaction. Returns the instances actually cleared.
|
||||||
|
///
|
||||||
|
/// FIFA 17 training is a ONE-MATCH effect: it "is reflected in the following
|
||||||
|
/// match and expires after this", and a card applied to someone who stays on the
|
||||||
|
/// bench or in the reserves "will continue to benefit from the training effect
|
||||||
|
/// until he plays" (DOCUMENTED — fifauteam's contemporaneous FIFA 17 guide).
|
||||||
|
/// So the trigger is the PLAYER PLAYING, not the match merely completing, and
|
||||||
|
/// the caller must pass the instances that played — never a whole club.
|
||||||
|
///
|
||||||
|
/// `club_id` is not redundant with the ids: it scopes the delete so a caller
|
||||||
|
/// cannot expire another club's effects by guessing an instance id.
|
||||||
|
///
|
||||||
|
/// Idempotent by construction. Deleting an already-absent row is a no-op, so a
|
||||||
|
/// replayed match cannot "expire twice"; combined with the caller's
|
||||||
|
/// `match_completions` uniqueness guard, the mutation happens exactly once and a
|
||||||
|
/// replay is a silent no-op rather than a second effect.
|
||||||
|
pub async fn expire_for_instances_tx(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
|
club_id: &str,
|
||||||
|
instance_ids: &[String],
|
||||||
|
) -> AppResult<Vec<String>> {
|
||||||
|
let mut expired = Vec::new();
|
||||||
|
for id in instance_ids {
|
||||||
|
// DELETE .. RETURNING so the report is what the database actually
|
||||||
|
// removed, not what we hoped it would: an id that carried no training,
|
||||||
|
// or belongs to another club, simply does not appear.
|
||||||
|
let hit: Option<(String,)> = sqlx::query_as(
|
||||||
|
"DELETE FROM owned_card_training \
|
||||||
|
WHERE owned_card_id = ? \
|
||||||
|
AND owned_card_id IN (SELECT id FROM owned_cards WHERE club_id = ?) \
|
||||||
|
RETURNING owned_card_id",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(club_id)
|
||||||
|
.fetch_optional(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
if let Some((got,)) = hit {
|
||||||
|
expired.push(got);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(expired)
|
||||||
|
}
|
||||||
|
|
||||||
/// The definition's six attributes in canonical slot order.
|
/// The definition's six attributes in canonical slot order.
|
||||||
///
|
///
|
||||||
/// THIS ORDER IS THE CONTRACT that `attribute_index` indexes. It is
|
/// THIS ORDER IS THE CONTRACT that `attribute_index` indexes. It is
|
||||||
|
|||||||
@@ -3675,3 +3675,242 @@ async fn test_collection_reports_owned_rows_it_cannot_project() {
|
|||||||
.collect();
|
.collect();
|
||||||
assert!(!ids.contains(&"ghost"));
|
assert!(!ids.contains(&"ghost"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────── One-match training expiry (lifecycle row 12) ────────────
|
||||||
|
//
|
||||||
|
// FIFA 17 training is a ONE-MATCH effect that is consumed by the player PLAYING,
|
||||||
|
// not by the match merely completing: a card on someone who stays on the bench
|
||||||
|
// "will continue to benefit from the training effect until he plays"
|
||||||
|
// (DOCUMENTED). Core therefore expires exactly the instances the caller says
|
||||||
|
// took the field, and nothing else.
|
||||||
|
|
||||||
|
/// Seed a club with two owned instances, both carrying a training effect.
|
||||||
|
/// Returns `(club_id, played_id, benched_id)`.
|
||||||
|
async fn seed_two_trained(
|
||||||
|
app: &axum::Router,
|
||||||
|
pool: &sqlx::SqlitePool,
|
||||||
|
who: &str,
|
||||||
|
) -> (String, String, String) {
|
||||||
|
auth(app, who).await;
|
||||||
|
let club_id: String = sqlx::query_scalar("SELECT id FROM clubs LIMIT 1")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("club exists after auth");
|
||||||
|
for id in ["played", "benched"] {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) \
|
||||||
|
VALUES (?, ?, 'card_raregold_001', 0, '2026-01-01T00:00:00Z')",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(&club_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO owned_card_training \
|
||||||
|
(owned_card_id, attribute_index, amount, source_card_id, applied_at) \
|
||||||
|
VALUES (?, 4, 15, 'fifa17_5003012', '2026-01-01T00:00:00Z')",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
(club_id, "played".to_string(), "benched".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn training_rows(pool: &sqlx::SqlitePool) -> Vec<String> {
|
||||||
|
sqlx::query_scalar("SELECT owned_card_id FROM owned_card_training ORDER BY owned_card_id")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The core of the documented rule: only the players who took the field lose
|
||||||
|
/// their boost. Expiring the whole squad — or the whole club — would clear the
|
||||||
|
/// benched player the rule explicitly protects.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_match_expires_training_only_for_the_players_who_played() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
let (_club, played, benched) = seed_two_trained(&app, &pool, "ExpiryScope").await;
|
||||||
|
|
||||||
|
let (status, body) = json_post(
|
||||||
|
&app,
|
||||||
|
"/matches/complete",
|
||||||
|
serde_json::json!({
|
||||||
|
"match_identity": "expiry-scope-1", "result": "win",
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles",
|
||||||
|
"participants": [played]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
|
assert_eq!(body["expired_training"], serde_json::json!(["played"]));
|
||||||
|
assert_eq!(
|
||||||
|
training_rows(&pool).await,
|
||||||
|
vec![benched],
|
||||||
|
"the benched player must keep his boost"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A caller that cannot identify participants must be INERT, never a club wipe.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_match_with_no_participants_expires_nothing() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
seed_two_trained(&app, &pool, "ExpiryNone").await;
|
||||||
|
|
||||||
|
let (status, body) = json_post(
|
||||||
|
&app,
|
||||||
|
"/matches/complete",
|
||||||
|
serde_json::json!({
|
||||||
|
"match_identity": "expiry-none-1", "result": "win",
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
|
assert_eq!(body["expired_training"], serde_json::json!([]));
|
||||||
|
assert_eq!(training_rows(&pool).await, vec!["benched", "played"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replay safety. The economic guard already stops double rewards; the training
|
||||||
|
/// mutation must ride the SAME canonical identity so a resubmitted completion
|
||||||
|
/// cannot consume a second, freshly-applied boost.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_replayed_completion_does_not_expire_training_twice() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
let (_club, played, _benched) = seed_two_trained(&app, &pool, "ExpiryReplay").await;
|
||||||
|
|
||||||
|
let submit = || {
|
||||||
|
json_post(
|
||||||
|
&app,
|
||||||
|
"/matches/complete",
|
||||||
|
serde_json::json!({
|
||||||
|
"match_identity": "expiry-replay-1", "result": "win",
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles",
|
||||||
|
"participants": [played]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_, first) = submit().await;
|
||||||
|
assert_eq!(first["applied"], true);
|
||||||
|
assert_eq!(first["expired_training"], serde_json::json!(["played"]));
|
||||||
|
|
||||||
|
// Re-apply a boost to the same instance, then replay the SAME match.
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO owned_card_training \
|
||||||
|
(owned_card_id, attribute_index, amount, source_card_id, applied_at) \
|
||||||
|
VALUES ('played', 4, 15, 'fifa17_5003012', '2026-01-02T00:00:00Z')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (_, second) = submit().await;
|
||||||
|
assert_eq!(second["applied"], false, "replay must not re-apply");
|
||||||
|
assert_eq!(
|
||||||
|
second["expired_training"],
|
||||||
|
serde_json::json!([]),
|
||||||
|
"a replay reports no mutation"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
training_rows(&pool).await.contains(&"played".to_string()),
|
||||||
|
"the replay must NOT consume the newly applied boost"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NoContest` is a voided match: it grants no coins, XP or statistics, so it
|
||||||
|
/// must not consume a one-match effect either. Core's `is_economic` guard is the
|
||||||
|
/// single place that decides this, and training now sits inside it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_no_contest_match_does_not_expire_training() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
let (_club, played, _benched) = seed_two_trained(&app, &pool, "ExpiryVoid").await;
|
||||||
|
|
||||||
|
let (status, body) = json_post(
|
||||||
|
&app,
|
||||||
|
"/matches/complete",
|
||||||
|
serde_json::json!({
|
||||||
|
"match_identity": "expiry-void-1", "result": "no_contest",
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 0, "goals_against": 0, "mode": "squad_battles",
|
||||||
|
"participants": [played]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
|
assert_eq!(body["expired_training"], serde_json::json!([]));
|
||||||
|
assert_eq!(
|
||||||
|
training_rows(&pool).await,
|
||||||
|
vec!["benched", "played"],
|
||||||
|
"a voided match consumes nothing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An id belonging to somebody else's club must not be expirable by guessing it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn training_expiry_is_scoped_to_the_completing_club() {
|
||||||
|
let (app, pool) = build_test_app_with_pool().await;
|
||||||
|
seed_two_trained(&app, &pool, "ExpiryScoped").await;
|
||||||
|
|
||||||
|
// A genuinely separate club, built properly so the FKs hold — the point of
|
||||||
|
// the test is club scoping, not a dangling row.
|
||||||
|
//
|
||||||
|
// created_at is deliberately in the FUTURE: `get_active_profile` selects
|
||||||
|
// `WHERE game_id = ? ORDER BY created_at ASC LIMIT 1`, and `game_id`
|
||||||
|
// defaults to 'fifa23' (migration 0016), so a rival dated earlier than the
|
||||||
|
// authed profile would silently BECOME the active profile and this test
|
||||||
|
// would assert the opposite of what it means.
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO profiles (id, username, created_at, updated_at) \
|
||||||
|
VALUES ('other-profile', 'Rival', '2099-01-01T00:00:00Z', '2099-01-01T00:00:00Z')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO clubs (id, profile_id, name, created_at, updated_at) \
|
||||||
|
VALUES ('other-club', 'other-profile', 'Rival FC', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) \
|
||||||
|
VALUES ('foreign', 'other-club', 'card_raregold_001', 0, '2026-01-01T00:00:00Z')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO owned_card_training \
|
||||||
|
(owned_card_id, attribute_index, amount, source_card_id, applied_at) \
|
||||||
|
VALUES ('foreign', 4, 15, 'fifa17_5003012', '2026-01-01T00:00:00Z')",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (status, body) = json_post(
|
||||||
|
&app,
|
||||||
|
"/matches/complete",
|
||||||
|
serde_json::json!({
|
||||||
|
"match_identity": "expiry-scoped-1", "result": "win",
|
||||||
|
"squad_id": "dummy", "opponent_name": "Bot",
|
||||||
|
"goals_for": 1, "goals_against": 0, "mode": "squad_battles",
|
||||||
|
"participants": ["foreign"]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
|
assert_eq!(
|
||||||
|
body["expired_training"],
|
||||||
|
serde_json::json!([]),
|
||||||
|
"another club's effect must not be reachable"
|
||||||
|
);
|
||||||
|
assert!(training_rows(&pool).await.contains(&"foreign".to_string()));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user