feat(core): dry-run mode for reclassify, and a per-kind tally
CI / Build, lint & test (push) Successful in 3m7s

Production is frozen and sits on schema 19, so the content_kind correction it
needs cannot be applied yet. `--dry-run` runs the real UPDATEs and rolls the
transaction back, so an operator can see exactly what a run would touch before
touching a database they cannot easily restore — the counts are measured, not
predicted.

`updated_by_kind` reports the SHAPE of the change ("consumable 17, staff 3"),
which is the thing worth sanity-checking: a different shape means the source
profile moved and the mapping needs regenerating.

Measured against a copy of prod-core.db: 20 rows would change (17 consumable,
3 staff), 1966 already correct, 0 unmatched, and the copy was verified unchanged
afterwards.
This commit is contained in:
funman300
2026-08-21 21:23:43 +00:00
parent 30fae1a2f9
commit 233df1d99d
3 changed files with 107 additions and 8 deletions
+68
View File
@@ -325,6 +325,7 @@ async fn reclassify_corrects_already_imported_rows_and_is_idempotent() {
let rc = ReclassifyRequest {
game_id: "g_reclass".into(),
dry_run: false,
assignments: vec![
ContentKindAssignment {
card_id: ids[0].clone(),
@@ -388,6 +389,7 @@ async fn reclassify_never_crosses_a_game_boundary() {
&pool,
&ReclassifyRequest {
game_id: "g_a".into(),
dry_run: false,
assignments: vec![ContentKindAssignment {
card_id: ids[0].clone(),
content_kind: ContentKind::Kit,
@@ -409,3 +411,69 @@ async fn reclassify_never_crosses_a_game_boundary() {
.unwrap();
assert_eq!(kinds, vec!["player".to_string()], "game B untouched");
}
/// A dry run must report the SAME counts a real run would, and leave the
/// database byte-for-byte unchanged. It runs the real UPDATEs and rolls back, so
/// the numbers are measured rather than predicted — which is the only reason an
/// operator can trust them before touching a frozen production database.
#[tokio::test]
async fn reclassify_dry_run_reports_the_real_counts_and_commits_nothing() {
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);
apply_profile_import(&pool, &db, &request("g_dry", "fp-dry", owned(&ids), None))
.await
.expect("import");
let kinds = || {
let pool = pool.clone();
async move {
sqlx::query_scalar::<_, String>("SELECT content_kind FROM owned_cards ORDER BY card_id")
.fetch_all(&pool)
.await
.unwrap()
}
};
let before = kinds().await;
let mut rc = ReclassifyRequest {
game_id: "g_dry".into(),
dry_run: true,
assignments: vec![
ContentKindAssignment {
card_id: ids[0].clone(),
content_kind: ContentKind::Staff,
},
ContentKindAssignment {
card_id: ids[1].clone(),
content_kind: ContentKind::Consumable,
},
],
};
let dry = reclassify_owned_content(&pool, &rc).await.expect("dry run");
assert!(dry.dry_run);
assert_eq!(dry.updated, 2);
assert_eq!(
dry.updated_by_kind,
[("consumable".to_string(), 1), ("staff".to_string(), 1)]
.into_iter()
.collect(),
"the per-kind shape is what an operator sanity-checks"
);
assert_eq!(kinds().await, before, "a dry run commits NOTHING");
// The real run then reports exactly what the dry run promised.
rc.dry_run = false;
let real = reclassify_owned_content(&pool, &rc)
.await
.expect("real run");
assert!(!real.dry_run);
assert_eq!(real.updated, dry.updated);
assert_eq!(real.updated_by_kind, dry.updated_by_kind);
assert_ne!(kinds().await, before, "the real run DID commit");
}