feat(core): reclassify the content_kind of already-imported owned rows
CI / Build, lint & test (push) Failing after 1m57s
CI / Build, lint & test (push) Failing after 1m57s
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.
This commit is contained in:
+19
@@ -57,6 +57,25 @@ async fn main() -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `openfut-core reclassify <request.json>` — 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 <request.json>")?;
|
||||||
|
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);
|
info!("OpenFUT Core starting on {}", cfg.listen_addr);
|
||||||
|
|
||||||
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
|
let pool = db::init_pool(&cfg.database_url, cfg.max_connections).await?;
|
||||||
|
|||||||
@@ -371,3 +371,92 @@ pub async fn apply_profile_import(
|
|||||||
squad_slots,
|
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<ContentKindAssignment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<ReclassifyOutcome> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -287,3 +287,119 @@ async fn empty_owned_fails() {
|
|||||||
.expect_err("empty owned must fail");
|
.expect_err("empty owned must fail");
|
||||||
assert!(format!("{err:#}").contains("zero owned cards"), "{err:#}");
|
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<String> = 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");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user