From c896545cf075cafff8d0e9a7a34caefc300a1e98 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 21 Aug 2026 19:55:39 +0000 Subject: [PATCH] feat(core): reclassify the content_kind of already-imported owned rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A profile import is once-only — the same fingerprint no-ops and a different one is refused — so a taxonomy correction cannot arrive by re-importing. Every club imported before content_kind existed still records its coaches, kits and consumables as players, because Core defaults an unstated row to `player`. Core stays generic: the caller supplies card_id -> kind, since only the game adapter can map its own taxonomy. One transaction, idempotent, scoped to a single game so a shared database cannot be reclassified across games, and an assignment nobody owns is reported rather than invented. --- src/main.rs | 19 ++++++ src/services/import.rs | 89 +++++++++++++++++++++++++++ tests/import_service_test.rs | 116 +++++++++++++++++++++++++++++++++++ 3 files changed, 224 insertions(+) diff --git a/src/main.rs b/src/main.rs index 63cae9c..fabc562 100644 --- a/src/main.rs +++ b/src/main.rs @@ -57,6 +57,25 @@ async fn main() -> Result<()> { return Ok(()); } + // `openfut-core reclassify ` — correct the content_kind of + // already-imported owned rows. A profile import is once-only, so a taxonomy + // fix cannot arrive by re-importing; the adapter supplies card_id -> kind + // because only it can map its own taxonomy. Idempotent. + if std::env::args().nth(1).as_deref() == Some("reclassify") { + let path = std::env::args() + .nth(2) + .context("usage: openfut-core reclassify ")?; + let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?; + db::run_migrations(&pool).await?; + let raw = std::fs::read_to_string(&path) + .with_context(|| format!("read reclassify request {path}"))?; + let req: openfut_core::services::import::ReclassifyRequest = + serde_json::from_str(&raw).context("parse reclassify request JSON")?; + let outcome = openfut_core::services::import::reclassify_owned_content(&pool, &req).await?; + println!("{}", serde_json::to_string_pretty(&outcome)?); + return Ok(()); + } + info!("OpenFUT Core starting on {}", cfg.listen_addr); let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?; diff --git a/src/services/import.rs b/src/services/import.rs index cb83b15..8bfcf83 100644 --- a/src/services/import.rs +++ b/src/services/import.rs @@ -371,3 +371,92 @@ pub async fn apply_profile_import( squad_slots, }) } + +/// One adapter-supplied classification: "every owned row of this definition is +/// really this kind of content". +#[derive(Debug, Clone, Deserialize)] +pub struct ContentKindAssignment { + pub card_id: String, + pub content_kind: ContentKind, +} + +/// Request for [`reclassify_owned_content`]. +#[derive(Debug, Clone, Deserialize)] +pub struct ReclassifyRequest { + pub game_id: String, + pub assignments: Vec, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct ReclassifyOutcome { + /// Rows whose `content_kind` actually changed. + pub updated: usize, + /// Rows already carrying the requested kind (a rerun updates nothing). + pub unchanged: usize, + /// Assignments naming a definition this game owns no copy of. + pub unmatched_definitions: Vec, +} + +/// Correct the `content_kind` of ALREADY-IMPORTED owned rows, in one transaction. +/// +/// A profile import is once-only (same fingerprint no-ops, a different one is +/// refused), so a taxonomy fix cannot arrive by re-importing. Core defaults an +/// unstated row to `player`, which means every pre-taxonomy import durably +/// recorded coaches, kits and consumables as players — wrong in the ownership +/// authority even where a catalog-driven wire looked right. +/// +/// Core stays generic: the caller supplies `card_id -> kind`, because only the +/// game adapter can map its own taxonomy. Idempotent, and scoped to one game's +/// clubs so a shared database cannot be reclassified across games. +pub async fn reclassify_owned_content( + pool: &Pool, + req: &ReclassifyRequest, +) -> Result { + if req.assignments.is_empty() { + bail!("reclassify request has zero assignments"); + } + let mut tx = pool.begin().await?; + let mut updated = 0usize; + let mut unchanged = 0usize; + let mut unmatched = Vec::new(); + for a in &req.assignments { + // Scope by game through the owning club, so the same definition id in + // another game is never touched. + let present: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM owned_cards o JOIN clubs c ON c.id = o.club_id \ + JOIN profiles p ON p.id = c.profile_id \ + WHERE p.game_id = ? AND o.card_id = ?", + ) + .bind(&req.game_id) + .bind(&a.card_id) + .fetch_one(&mut *tx) + .await + .context("count owned rows for definition")?; + if present == 0 { + unmatched.push(a.card_id.clone()); + continue; + } + let changed = sqlx::query( + "UPDATE owned_cards SET content_kind = ? \ + WHERE card_id = ? AND content_kind != ? AND club_id IN \ + (SELECT c.id FROM clubs c JOIN profiles p ON p.id = c.profile_id \ + WHERE p.game_id = ?)", + ) + .bind(a.content_kind.as_str()) + .bind(&a.card_id) + .bind(a.content_kind.as_str()) + .bind(&req.game_id) + .execute(&mut *tx) + .await + .context("update owned content_kind")? + .rows_affected() as usize; + updated += changed; + unchanged += present as usize - changed; + } + tx.commit().await?; + Ok(ReclassifyOutcome { + updated, + unchanged, + unmatched_definitions: unmatched, + }) +} diff --git a/tests/import_service_test.rs b/tests/import_service_test.rs index 905d196..e4ec70c 100644 --- a/tests/import_service_test.rs +++ b/tests/import_service_test.rs @@ -287,3 +287,119 @@ async fn empty_owned_fails() { .expect_err("empty owned must fail"); assert!(format!("{err:#}").contains("zero owned cards"), "{err:#}"); } + +/// A profile import is once-only, so a taxonomy fix cannot arrive by +/// re-importing: the same fingerprint no-ops and a different one is refused. +/// Every pre-taxonomy import therefore left coaches, kits and consumables +/// durably recorded as players — wrong in the ownership authority even where a +/// catalog-driven wire still looked right. +#[tokio::test] +async fn reclassify_corrects_already_imported_rows_and_is_idempotent() { + use openfut_core::services::import::{ + reclassify_owned_content, ContentKindAssignment, ReclassifyRequest, + }; + + let pool = fresh_pool().await; + let db = CardDb::load("data").unwrap(); + let ids = valid_ids(3); + // Imported before the taxonomy existed: everything landed as `player`. + let ow = owned(&ids); + let req = request("g_reclass", "fp-reclass", ow, None); + apply_profile_import(&pool, &db, &req).await.expect("import"); + + let kind_of = |card: String| { + let pool = pool.clone(); + async move { + sqlx::query_scalar::<_, String>("SELECT content_kind FROM owned_cards WHERE card_id = ?") + .bind(card) + .fetch_one(&pool) + .await + .unwrap() + } + }; + assert_eq!(kind_of(ids[0].clone()).await, "player"); + + let rc = ReclassifyRequest { + game_id: "g_reclass".into(), + assignments: vec![ + ContentKindAssignment { + card_id: ids[0].clone(), + content_kind: ContentKind::Staff, + }, + ContentKindAssignment { + card_id: ids[1].clone(), + content_kind: ContentKind::Consumable, + }, + ContentKindAssignment { + card_id: "fifa17_definition_nobody_owns".into(), + content_kind: ContentKind::Kit, + }, + ], + }; + let out = reclassify_owned_content(&pool, &rc).await.expect("reclassify"); + assert_eq!(out.updated, 2); + assert_eq!(out.unchanged, 0); + assert_eq!( + out.unmatched_definitions, + vec!["fifa17_definition_nobody_owns".to_string()], + "an assignment nobody owns is reported, never invented" + ); + assert_eq!(kind_of(ids[0].clone()).await, "staff"); + assert_eq!(kind_of(ids[1].clone()).await, "consumable"); + // Untouched definitions keep their kind. + assert_eq!(kind_of(ids[2].clone()).await, "player"); + + // Rerunning converges: nothing left to change. + let again = reclassify_owned_content(&pool, &rc).await.expect("rerun"); + assert_eq!(again.updated, 0); + assert_eq!(again.unchanged, 2); +} + +/// Reclassification is scoped to one game, so a shared database cannot have +/// another game's identically-named definition rewritten underneath it. +#[tokio::test] +async fn reclassify_never_crosses_a_game_boundary() { + use openfut_core::services::import::{ + reclassify_owned_content, ContentKindAssignment, ReclassifyRequest, + }; + + let pool = fresh_pool().await; + let db = CardDb::load("data").unwrap(); + let ids = valid_ids(2); + apply_profile_import(&pool, &db, &request("g_a", "fp-a", owned(&ids), None)) + .await + .expect("import a"); + // Same definitions, but owned-item ids are globally unique. + let mut b_owned = owned(&ids); + for o in &mut b_owned { + o.owned_item_id = format!("b-{}", o.owned_item_id); + } + apply_profile_import(&pool, &db, &request("g_b", "fp-b", b_owned, None)) + .await + .expect("import b"); + + let out = reclassify_owned_content( + &pool, + &ReclassifyRequest { + game_id: "g_a".into(), + assignments: vec![ContentKindAssignment { + card_id: ids[0].clone(), + content_kind: ContentKind::Kit, + }], + }, + ) + .await + .expect("reclassify"); + assert_eq!(out.updated, 1, "only game A's copy"); + + let kinds: Vec = sqlx::query_scalar( + "SELECT o.content_kind FROM owned_cards o \ + JOIN clubs c ON c.id = o.club_id JOIN profiles p ON p.id = c.profile_id \ + WHERE p.game_id = 'g_b' AND o.card_id = ?", + ) + .bind(&ids[0]) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(kinds, vec!["player".to_string()], "game B untouched"); +}