feat(club): persist active home/away kit assignments
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:
funman300
2026-08-21 03:17:40 +00:00
parent 2fb835200f
commit f0550e2ae1
4 changed files with 259 additions and 8 deletions
+2
View File
@@ -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))
+37
View File
@@ -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
View File
@@ -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());
}
}