Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

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"
    );
}