tests/release_changes.rs
//! Round-trip integration test for the reversible release-change ledger
//! (`release_changes`, task #32) against the **real** Iceberg backend.
//!
//! ## Why this test exists here, and why it is an integration test
//!
//! The ledger's row types + Arrow codec are defined once in the shared
//! `nornir-release-ledger` edda leaf and consumed by both halves of the release
//! seam. The leaf tests its own codec against an in-memory `Warehouse` mock,
//! which recasts NOTHING — it hands back exactly the batch it was given.
//!
//! That leaves a gap only the warehouse OWNER can close: the real
//! `IcebergWarehouse` creates the table from the canonical
//! `iceberg_schema::release_changes()` shape and RECASTS an appended batch to
//! the stored field-id schema on the way in and back out again. So this test
//! drives a full `append → Parquet → scan → decode` cycle through the real
//! backend and asserts every `kind`'s payload survives it byte-for-byte. It is
//! the assertion that the canonical schema and the codec still agree once real
//! Iceberg is in the middle — the exact property that would silently rot if the
//! shape and the codec ever drifted apart again.
//!
//! RED-when-broken: renumber a field ID in the schema, drop a column, or reorder
//! the codec's positional writes and the payload comparisons below fail.
use nornir::warehouse::iceberg::IcebergWarehouse;
use nornir::warehouse::release_changes::{
ChangeKind, ChangeRecorder, append_release_changes, change_sha, latest_release_id,
query_release_changes,
};
#[test]
fn every_change_kind_round_trips_through_real_iceberg() {
let dir = tempfile::tempdir().unwrap();
let wh = IcebergWarehouse::open(dir.path()).unwrap();
let rec = ChangeRecorder::new("run-xyz");
let bump = rec.build(
"skade",
ChangeKind::Bump {
crate_name: "arrow".into(),
old_version: "57".into(),
new_version: "58".into(),
file: "Cargo.toml".into(),
old_file_text: "[dependencies]\narrow = \"57\"\n".into(),
},
);
let strip = rec.build(
"nornir",
ChangeKind::PatchStrip {
file: "Cargo.toml".into(),
removed_block: "[patch.crates-io]\niceberg = { path = \"../fork\" }\n".into(),
},
);
let branch = rec.build(
"nornir",
ChangeKind::Branch {
repo: "nornir".into(),
branch: "release/staging".into(),
prev_head: "deadbeef".into(),
},
);
let publish = rec.build(
"nornir",
ChangeKind::Publish {
crate_name: "nornir".into(),
version: "0.2.0".into(),
registry: "crates.io".into(),
immutable: true,
},
);
append_release_changes(
&wh,
&[bump.clone(), strip.clone(), branch.clone(), publish.clone()],
)
.unwrap();
let rows = query_release_changes(&wh, Some("run-xyz")).unwrap();
assert_eq!(rows.len(), 4, "all four mutating steps landed");
assert_eq!(
rows.iter().map(|r| r.seq).collect::<Vec<_>>(),
vec![0, 1, 2, 3],
"`seq` is the durable ordering key `undo` reverses by"
);
// The whole point of the ledger: the BEFORE-STATE survives a real Iceberg
// round-trip intact, or `undo` cannot restore it.
assert_eq!(rows[0].change, bump.change);
assert_eq!(rows[1].change, strip.change);
assert_eq!(rows[2].change, branch.change);
assert_eq!(rows[3].change, publish.change);
// The wide schema's per-kind optional columns really are per-kind: a
// patch_strip must not come back wearing the bump's crate/version fields.
match &rows[1].change {
ChangeKind::PatchStrip {
file,
removed_block,
} => {
assert_eq!(file, "Cargo.toml");
assert!(removed_block.contains("[patch.crates-io]"));
}
other => panic!("row 1 decoded as the wrong kind: {other:?}"),
}
// `immutable` is the flag that decides yank-vs-undo; it must not be lost to
// the nullable boolean column.
match &rows[3].change {
ChangeKind::Publish {
immutable,
registry,
..
} => {
assert!(
*immutable,
"a crates.io publish stays immutable across the round trip"
);
assert_eq!(registry, "crates.io");
}
other => panic!("row 3 decoded as the wrong kind: {other:?}"),
}
// `change_sha` is the dedup/integrity key: recomputing it from the DECODED
// payload must reproduce the stored value, which it only can if every field
// in the canonical payload survived.
for r in &rows {
assert!(!r.change_sha.is_empty());
assert_eq!(
r.change_sha,
change_sha(&r.change),
"sha recomputes from the decoded payload"
);
}
// Latest-run resolution (what `undo` with no id uses).
assert_eq!(latest_release_id(&wh).unwrap().as_deref(), Some("run-xyz"));
// A second run stays isolated: scoping by release_id filters it out.
let rec2 = ChangeRecorder::new("run-other");
let other = rec2.build(
"znippy",
ChangeKind::Branch {
repo: "znippy".into(),
branch: "release/x".into(),
prev_head: String::new(),
},
);
append_release_changes(&wh, &[other]).unwrap();
assert_eq!(
query_release_changes(&wh, Some("run-xyz")).unwrap().len(),
4
);
assert_eq!(
query_release_changes(&wh, Some("run-other")).unwrap().len(),
1
);
assert_eq!(
query_release_changes(&wh, None).unwrap().len(),
5,
"None reads every run"
);
}