feat(core): reclassify the content_kind of already-imported owned rows
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:
funman300
2026-08-21 19:55:39 +00:00
parent 36bc594924
commit c896545cf0
3 changed files with 224 additions and 0 deletions
+116
View File
@@ -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<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");
}