feat(club): persist active home/away kit assignments
CI / Build, lint & test (push) Successful in 2m50s
CI / Build, lint & test (push) Successful in 2m50s
Kits are ownership-backed club items: the owned instance stays in the generic owned_cards inventory and only the two active roles get their own table. This mirrors the squad_managers precedent and keeps every FIFA-specific resourceId/wire concern in the game adapter. * migration 0024: club_kit_assignments(club_id, slot, owned_card_id) with a UNIQUE owned_card_id (one instance cannot hold both roles) and ON DELETE CASCADE from owned_cards so a quick-sell clears the role. * a BEFORE UPDATE OF club_id trigger clears the designation on a market transfer, which moves ownership by UPDATE and so is not covered by the cascade. * set_active_club_kits replaces BOTH slots in one transaction, rejects home == away, and validates each instance against current club ownership, so a half-applied or dangling designation is not representable. * get_active_club_kits revalidates ownership on read, so a stale row can never surface another club's item. * GET/PUT /club/kits expose the pair. Tests cover restart persistence, replace/clear without duplicates, atomic rejection of invalid references, and clearing via delete and transfer.
This commit is contained in:
@@ -172,6 +172,8 @@ pub async fn build(pool: Pool, cfg: Config) -> Result<Router> {
|
||||
// ClubB: squad manager assignment (append-only; own lines).
|
||||
.route("/club/manager", get(routes::club::get_squad_manager))
|
||||
.route("/club/manager", put(routes::club::put_squad_manager))
|
||||
.route("/club/kits", get(routes::club::get_active_kits))
|
||||
.route("/club/kits", put(routes::club::put_active_kits))
|
||||
.route("/cards", get(routes::cards::get_cards))
|
||||
.route("/cards/:card_id", get(routes::cards::get_card))
|
||||
.route("/collection", get(routes::cards::get_collection))
|
||||
|
||||
@@ -163,3 +163,40 @@ pub async fn put_squad_manager(
|
||||
let manager = club_svc::get_squad_manager(&state.pool, &club.id).await?;
|
||||
Ok(Json(json!({ "manager": manager })))
|
||||
}
|
||||
|
||||
/// Return the club's ownership-backed active home/away kit assignments.
|
||||
pub async fn get_active_kits(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
let kits = club_svc::get_active_club_kits(&state.pool, &club.id).await?;
|
||||
Ok(Json(json!({ "home": kits.home, "away": kits.away })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetActiveKitsRequest {
|
||||
pub home_owned_card_id: Option<String>,
|
||||
pub away_owned_card_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Atomically replace both active kit assignments. Core enforces ownership and
|
||||
/// distinct instances; game adapters enforce their own definition taxonomy.
|
||||
pub async fn put_active_kits(
|
||||
State(state): State<AppState>,
|
||||
game: GameId,
|
||||
Json(req): Json<SetActiveKitsRequest>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let profile = profile_svc::get_active_profile(&state.pool, game.as_str()).await?;
|
||||
let club = club_svc::get_club_by_profile(&state.pool, &profile.id).await?;
|
||||
club_svc::set_active_club_kits(
|
||||
&state.pool,
|
||||
&club.id,
|
||||
req.home_owned_card_id.as_deref(),
|
||||
req.away_owned_card_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let kits = club_svc::get_active_club_kits(&state.pool, &club.id).await?;
|
||||
Ok(Json(json!({ "home": kits.home, "away": kits.away })))
|
||||
}
|
||||
|
||||
+197
-8
@@ -240,6 +240,94 @@ pub async fn clear_squad_manager(pool: &Pool, club_id: &str) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ───────────────────────── active club kits ────────────────────────────────
|
||||
|
||||
/// The ownership-backed home and away kit assignments for one club.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ActiveClubKits {
|
||||
pub home: Option<OwnedCard>,
|
||||
pub away: Option<OwnedCard>,
|
||||
}
|
||||
|
||||
async fn get_club_kit_slot(pool: &Pool, club_id: &str, slot: &str) -> AppResult<Option<OwnedCard>> {
|
||||
Ok(sqlx::query_as::<_, OwnedCard>(&format!(
|
||||
"{OWNED_SELECT} WHERE id = ( \
|
||||
SELECT owned_card_id FROM club_kit_assignments WHERE club_id = ? AND slot = ? \
|
||||
) AND club_id = ?"
|
||||
))
|
||||
.bind(club_id)
|
||||
.bind(slot)
|
||||
.bind(club_id)
|
||||
.fetch_optional(pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Read both active kit roles. Each assignment is revalidated against current
|
||||
/// ownership, so a stale/corrupt row never surfaces another club's item.
|
||||
pub async fn get_active_club_kits(pool: &Pool, club_id: &str) -> AppResult<ActiveClubKits> {
|
||||
Ok(ActiveClubKits {
|
||||
home: get_club_kit_slot(pool, club_id, "home").await?,
|
||||
away: get_club_kit_slot(pool, club_id, "away").await?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Atomically replace both active kit roles. Core enforces generic ownership and
|
||||
/// distinct-instance invariants; the game adapter validates that each definition
|
||||
/// is a kit before asking Core to assign it.
|
||||
pub async fn set_active_club_kits(
|
||||
pool: &Pool,
|
||||
club_id: &str,
|
||||
home_owned_card_id: Option<&str>,
|
||||
away_owned_card_id: Option<&str>,
|
||||
) -> AppResult<()> {
|
||||
if home_owned_card_id.is_some() && home_owned_card_id == away_owned_card_id {
|
||||
return Err(AppError::BadRequest(
|
||||
"home and away kits must be different owned items".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
for owned_card_id in [home_owned_card_id, away_owned_card_id]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let owned = sqlx::query_scalar::<_, String>(
|
||||
"SELECT id FROM owned_cards WHERE id = ? AND club_id = ?",
|
||||
)
|
||||
.bind(owned_card_id)
|
||||
.bind(club_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if owned.is_none() {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"owned card '{owned_card_id}' not found"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM club_kit_assignments WHERE club_id = ?")
|
||||
.bind(club_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
for (slot, owned_card_id) in [("home", home_owned_card_id), ("away", away_owned_card_id)] {
|
||||
if let Some(owned_card_id) = owned_card_id {
|
||||
sqlx::query(
|
||||
"INSERT INTO club_kit_assignments \
|
||||
(club_id, slot, owned_card_id, updated_at) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(club_id)
|
||||
.bind(slot)
|
||||
.bind(owned_card_id)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -247,8 +335,7 @@ mod tests {
|
||||
|
||||
const TS: &str = "2026-01-01T00:00:00Z";
|
||||
|
||||
/// A file-backed pool (so a "restart" can reopen the same DB) with two clubs:
|
||||
/// club-a owns `mgr` + `mgr2` + `player`, club-b owns `foreign`.
|
||||
/// A file-backed pool (so a "restart" can reopen the same DB) with two clubs.
|
||||
async fn fixture() -> (tempfile::TempDir, String, db::Pool) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let url = format!("sqlite://{}", dir.path().join("core.db").display());
|
||||
@@ -270,14 +357,17 @@ mod tests {
|
||||
.bind(club).bind(profile).bind(club).bind(1000i64).bind(TS).bind(TS)
|
||||
.execute(&pool).await.expect("club");
|
||||
}
|
||||
for (id, club) in [
|
||||
("mgr", "club-a"),
|
||||
("mgr2", "club-a"),
|
||||
("player", "club-a"),
|
||||
("foreign", "club-b"),
|
||||
for (id, club, definition) in [
|
||||
("mgr", "club-a", "def-mgr"),
|
||||
("mgr2", "club-a", "def-mgr"),
|
||||
("player", "club-a", "def-player"),
|
||||
("kit-home", "club-a", "def-kit-home"),
|
||||
("kit-away", "club-a", "def-kit-away"),
|
||||
("kit-away-2", "club-a", "def-kit-away-2"),
|
||||
("foreign", "club-b", "def-kit-foreign"),
|
||||
] {
|
||||
sqlx::query("INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) VALUES (?, ?, ?, 0, ?)")
|
||||
.bind(id).bind(club).bind("def-mgr").bind(TS)
|
||||
.bind(id).bind(club).bind(definition).bind(TS)
|
||||
.execute(&pool).await.expect("owned card");
|
||||
}
|
||||
// club-a has one squad.
|
||||
@@ -293,6 +383,13 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn kit_rows(pool: &db::Pool) -> i64 {
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM club_kit_assignments")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manager_persists_across_reload_and_restart() {
|
||||
let (dir, url, pool) = fixture().await;
|
||||
@@ -366,4 +463,96 @@ mod tests {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
assert!(get_squad_manager(&pool, "club-a").await.unwrap().is_none());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn kits_persist_across_reload_and_restart() {
|
||||
let (dir, url, pool) = fixture().await;
|
||||
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away"))
|
||||
.await
|
||||
.expect("assign kits");
|
||||
let current = get_active_club_kits(&pool, "club-a").await.unwrap();
|
||||
assert_eq!(
|
||||
current.home.as_ref().map(|item| item.id.as_str()),
|
||||
Some("kit-home")
|
||||
);
|
||||
assert_eq!(
|
||||
current.away.as_ref().map(|item| item.id.as_str()),
|
||||
Some("kit-away")
|
||||
);
|
||||
|
||||
pool.close().await;
|
||||
let reopened = db::init_pool(&url, 5).await.expect("reopen");
|
||||
db::run_migrations(&reopened).await.expect("migrations");
|
||||
let persisted = get_active_club_kits(&reopened, "club-a").await.unwrap();
|
||||
assert_eq!(persisted.home.map(|item| item.id), Some("kit-home".into()));
|
||||
assert_eq!(persisted.away.map(|item| item.id), Some("kit-away".into()));
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kits_replace_clear_and_never_duplicate() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away"))
|
||||
.await
|
||||
.unwrap();
|
||||
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away-2"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(kit_rows(&pool).await, 2);
|
||||
let current = get_active_club_kits(&pool, "club-a").await.unwrap();
|
||||
assert_eq!(current.away.map(|item| item.id), Some("kit-away-2".into()));
|
||||
|
||||
set_active_club_kits(&pool, "club-a", None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(kit_rows(&pool).await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kits_reject_invalid_references_atomically() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(set_active_club_kits(&pool, "club-a", Some("foreign"), None)
|
||||
.await
|
||||
.is_err());
|
||||
assert!(
|
||||
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-home"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let unchanged = get_active_club_kits(&pool, "club-a").await.unwrap();
|
||||
assert_eq!(unchanged.home.map(|item| item.id), Some("kit-home".into()));
|
||||
assert_eq!(unchanged.away.map(|item| item.id), Some("kit-away".into()));
|
||||
assert_eq!(kit_rows(&pool).await, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kit_delete_and_transfer_clear_active_designations() {
|
||||
let (_dir, _url, pool) = fixture().await;
|
||||
set_active_club_kits(&pool, "club-a", Some("kit-home"), Some("kit-away"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query("DELETE FROM owned_cards WHERE id = 'kit-home'")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("quick sell kit");
|
||||
let after_delete = get_active_club_kits(&pool, "club-a").await.unwrap();
|
||||
assert!(after_delete.home.is_none());
|
||||
assert_eq!(
|
||||
after_delete.away.map(|item| item.id),
|
||||
Some("kit-away".into())
|
||||
);
|
||||
|
||||
sqlx::query("UPDATE owned_cards SET club_id = 'club-b' WHERE id = 'kit-away'")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("transfer kit");
|
||||
assert_eq!(kit_rows(&pool).await, 0);
|
||||
let after_transfer = get_active_club_kits(&pool, "club-a").await.unwrap();
|
||||
assert!(after_transfer.home.is_none() && after_transfer.away.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user