feat(core): dry-run mode for reclassify, and a per-kind tally
CI / Build, lint & test (push) Successful in 3m7s
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:
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user