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

nordisk/monetize

crates/monetize-server/src/backup/tests.rs

//! A backup nobody has restored is not a backup, so the central test is a ROUND
//! TRIP that asserts the ledger folds identically — not that bytes match, which
//! would prove only that a file copy works.

use std::path::PathBuf;

use super::*;
use monetize::testing::{purchase, Answer, Fail, MockCloud, MockPay, MockProduct, NOW};
use monetize::{Signer, Transaction};
use monetize_pay::{Currency, Money, PaidSignup};

use crate::signup::{Recorded, SignupState};

struct Rig {
    dir: PathBuf,
    ledger: Ledger,
    usage: UsageStore,
    signups: SignupStore,
    reconcile: crate::reconcile::ReconcileStore,
    nonpay: crate::nonpay::NonpayJournal,
    signer: Signer,
    pay: MockPay,
    cloud: MockCloud,
    product: MockProduct,
}

impl Rig {
    fn new(name: &str) -> Rig {
        Rig::seeded(name, [0xA5; 32])
    }

    /// A rig with a chosen signing seed, so two rigs can differ by key.
    fn seeded(name: &str, seed: [u8; 32]) -> Rig {
        let dir =
            std::env::temp_dir().join(format!("monetize-backup-{}-{name}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let cloud = MockCloud::new();
        cloud.answer_all(Answer::Provisioned);
        Rig {
            dir,
            ledger: Ledger::in_memory().unwrap(),
            usage: UsageStore::in_memory().unwrap(),
            signups: SignupStore::in_memory().unwrap(),
            reconcile: crate::reconcile::ReconcileStore::in_memory().unwrap(),
            nonpay: crate::nonpay::NonpayJournal::in_memory(),
            // From a KNOWN seed, so the secrets test can assert those exact bytes
            // are absent rather than trusting that a generated key would be.
            signer: Signer::from_bytes(&seed),
            pay: MockPay::new(),
            cloud,
            product: MockProduct::gunnar(),
        }
    }

    fn tx(&self) -> Transaction<'_> {
        Transaction {
            pay: monetize_pay::Vendor::of(&self.pay),
            cloud: &self.cloud,
            product: self.product.served(),
            ledger: &self.ledger,
            signer: &self.signer,
            now_unix_ms: NOW,
        }
    }

    fn stores(&self) -> Stores<'_> {
        Stores {
            ledger: &self.ledger,
            usage: &self.usage,
            signups: &self.signups,
            reconcile: &self.reconcile,
            nonpay: &self.nonpay,
        }
    }

    fn pubkey(&self) -> String {
        self.signer
            .public_key()
            .iter()
            .map(|b| format!("{b:02x}"))
            .collect()
    }

    /// Some real history: three tenants bought, one of them torn down, plus
    /// usage readings — so the export has open sets, closed attempts,
    /// entitlements and a Reclaimed row in it.
    fn with_history(&self) {
        for t in ["alice", "bob", "team/sub"] {
            self.tx().run(&purchase(t)).unwrap();
            let mut meters = std::collections::BTreeMap::new();
            meters.insert("pack_bytes".to_string(), 4096u64);
            meters.insert("repos".to_string(), 2u64);
            self.usage
                .put(
                    "gunnar",
                    t,
                    &monetize_product::Usage {
                        meters,
                        measured_at_unix_ms: NOW,
                    },
                )
                .unwrap();
        }
        self.tx()
            .release_tenant(
                "gunnar",
                &monetize_product::TenantId("bob".into()),
                "tenant removed",
            )
            .unwrap();

        // AND ONE ATTEMPT LEFT OPEN. Without it the export has no `open` set, and
        // a restore that never rebuilt one would look identical — which is
        // exactly what a deliberate red proved about the first version of this
        // fixture. `Unavailable` at capture stalls the attempt with the money
        // still reserved, which is the real shape reconciliation resumes from.
        *self.pay.fail_capture.lock().unwrap() = Some(Fail::Unavailable);
        let stalled = self.tx().run(&purchase("olle"));
        assert!(
            stalled.is_err(),
            "the fixture needs a stalled attempt: {stalled:?}"
        );
        *self.pay.fail_capture.lock().unwrap() = None;
        assert_eq!(
            self.ledger.unfinished().unwrap().len(),
            1,
            "exactly one open attempt"
        );

        // AND THREE PAID SIGNUPS, one in each state. The store is keyed
        // (vendor, session) and holds no ledger reference, so none of the
        // assertions above would notice if it were dropped from the backup
        // entirely — which is what it did until this fixture said so.
        self.signup(
            "cs_created",
            "bob",
            SignupState::Created {
                namespace: "bob".into(),
                username: "bob".into(),
                at_unix_ms: NOW as i64,
            },
        );
        self.signup(
            "cs_refused",
            "Bob!",
            SignupState::Refused {
                reason: "username \"Bob!\" contains '!'".into(),
                at_unix_ms: NOW as i64,
                refusal: crate::signup::Refusal::Name,
                words: None,
                owed: crate::signup::Owed::RenameOrRefund,
            },
        );
        self.signup("cs_pending", "olle", SignupState::Pending);
    }

    /// Record one signup and put it in `state`, the way `signup::settle` does.
    fn signup(&self, session: &str, username: &str, state: SignupState) {
        let s = a_signup(session, username);
        assert_eq!(
            self.signups.record_pending(&s, NOW as i64).unwrap(),
            Recorded::Fresh
        );
        if !state.is_pending() {
            assert!(self.signups.set("mockpay", session, state).unwrap());
        }
    }
}

/// The shape a vendor's verified webhook hands the engine.
fn a_signup(session: &str, username: &str) -> PaidSignup {
    PaidSignup {
        vendor: "mockpay".into(),
        session: session.into(),
        reference: "pi_1".into(),
        email: "bob@example.com".into(),
        username: username.into(),
        product: "gunnar".into(),
        plan: "gunnar/10gb-1m".into(),
        amount: Money {
            minor: 10_000,
            currency: Currency::Sek,
        },
        // No tax registration when this fixture was written: the vendor took the
        // plan price and nothing on top, so gross and net are the same number.
        tax: Money {
            minor: 0,
            currency: Currency::Sek,
        },
        paid_at_unix_ms: NOW as i64,
        customer_country: None,
    }
}

#[test]
fn a_backup_restores_to_a_ledger_that_folds_identically() {
    let a = Rig::new("roundtrip");
    a.with_history();

    let before_rows = a.ledger.export_rows().unwrap();
    let before_open = a.ledger.unfinished().unwrap();
    let before_ents = a.ledger.entitlements("gunnar").unwrap();
    let before_held: Vec<_> = ["alice", "bob", "team/sub"]
        .iter()
        .map(|t| a.ledger.held_resources("gunnar", t).unwrap())
        .collect();
    let before_total = a
        .ledger
        .total_provisioned_minor(monetize_pay::Currency::Eur)
        .unwrap();
    assert!(
        !before_rows.is_empty() && !before_ents.is_empty(),
        "the fixture must have real history"
    );
    assert_eq!(
        before_open.len(),
        1,
        "and an OPEN attempt, or the open-set rebuild is untested"
    );

    let m = export(&a.dir, &a.stores(), &a.pubkey()).unwrap();
    assert_eq!(m.ledger_rows, before_rows.len());
    assert_eq!(m.usage_readings, 3);
    assert_eq!(m.signups, 3);
    assert!(m.contains_no_secrets);

    // A DIFFERENT, EMPTY server — the same signing key, because that is the
    // honest restore (see BackupError::KeyMismatch).
    let b = Rig::new("roundtrip-target");
    let restored = import(&a.dir, &b.stores(), &a.pubkey(), false).unwrap();
    assert_eq!(restored, m);

    // THE ASSERTION THAT MATTERS: everything derived from the ledger agrees.
    assert_eq!(
        b.ledger.export_rows().unwrap(),
        before_rows,
        "the rows came back verbatim, seqs included"
    );
    assert_eq!(
        b.ledger.unfinished().unwrap(),
        before_open,
        "the open set was rebuilt"
    );
    assert_eq!(
        b.ledger.entitlements("gunnar").unwrap(),
        before_ents,
        "the verdicts were rebuilt"
    );
    assert_eq!(
        b.ledger
            .total_provisioned_minor(monetize_pay::Currency::Eur)
            .unwrap(),
        before_total,
        "the spend total is recomputable"
    );
    for (i, t) in ["alice", "bob", "team/sub"].iter().enumerate() {
        assert_eq!(
            b.ledger.held_resources("gunnar", t).unwrap(),
            before_held[i],
            "held resources for {t}"
        );
    }
    // Including the torn-down tenant: its Reclaimed row survived, so a restore
    // does not resurrect iron that was given back.
    assert!(
        b.ledger.held_resources("gunnar", "bob").unwrap().is_empty(),
        "bob was reclaimed before the backup"
    );
    assert_eq!(b.usage.export_all().unwrap(), a.usage.export_all().unwrap());
    assert_eq!(
        b.signups.export_all().unwrap(),
        a.signups.export_all().unwrap(),
        "the signup store came back whole"
    );
}

/// **THE DRILL: a restored signup store still refuses the vendor's retry.**
///
/// The round trip above proves the rows came back. That is not the property
/// that matters here. This store IS the idempotency key of the money path: what
/// a restore has to preserve is the ANSWER `record_pending` gives when the
/// vendor re-delivers a webhook it already delivered — `Recorded::Already`,
/// carrying the outcome the first delivery reached. If a restore lost that (or
/// restored the row with its state reset to PENDING, which a naive
/// re-recording would do), the next Stripe retry would run the sink again and
/// create a second account for one payment, and no assertion about row counts
/// would have noticed.
///
/// So: fulfil a signup, back it up, restore into an EMPTY server, and let the
/// vendor retry against the restored store.
#[test]
fn a_restored_signup_store_still_answers_already_for_a_session_it_had_fulfilled() {
    let a = Rig::new("signup-drill");
    a.with_history();
    export(&a.dir, &a.stores(), &a.pubkey()).unwrap();

    let b = Rig::new("signup-drill-target");
    let m = import(&a.dir, &b.stores(), &a.pubkey(), false).unwrap();
    assert_eq!(m.signups, 3);

    // The vendor retries the fulfilled one, an hour after the restore.
    let later = NOW as i64 + 3_600_000;
    let retry = b
        .signups
        .record_pending(&a_signup("cs_created", "bob"), later)
        .unwrap();
    let Recorded::Already(rec) = retry else {
        panic!("the restored store forgot a fulfilled session: {retry:?}")
    };
    assert_eq!(
        rec.state,
        SignupState::Created { namespace: "bob".into(), username: "bob".into(), at_unix_ms: NOW as i64 },
        "the restore reset a decided signup to pending, which is a second account waiting to happen"
    );
    assert_eq!(
        rec.recorded_at_unix_ms, NOW as i64,
        "the retry must not restamp the original delivery"
    );

    // A refusal is just as final after a restore.
    let Recorded::Already(rec) = b
        .signups
        .record_pending(&a_signup("cs_refused", "Bob!"), later)
        .unwrap()
    else {
        panic!("the refusal did not survive")
    };
    assert!(matches!(rec.state, SignupState::Refused { .. }));

    // And the one that was still PENDING is still pending — it is the only one
    // the sink may be asked about again.
    let Recorded::Already(rec) = b
        .signups
        .record_pending(&a_signup("cs_pending", "olle"), later)
        .unwrap()
    else {
        panic!("the pending row did not survive")
    };
    assert!(rec.state.is_pending());

    // A session NOBODY ever paid for is still fresh: a restore does not invent rows.
    assert_eq!(
        b.signups
            .record_pending(&a_signup("cs_new", "olle"), later)
            .unwrap(),
        Recorded::Fresh
    );

    // The e-mail came back, because a PENDING signup cannot be retried against
    // the sink without it. It is the one personal datum in the backup and the
    // module doc says so out loud.
    assert_eq!(
        b.signups
            .get("mockpay", "cs_pending")
            .unwrap()
            .unwrap()
            .signup
            .email,
        "bob@example.com"
    );
}

/// A `signups.jsonl` short of the manifest is the truncation this module refuses.
#[test]
fn a_short_signups_file_is_refused() {
    let c = Rig::new("short-signups");
    c.with_history();
    export(&c.dir, &c.stores(), &c.pubkey()).unwrap();
    let text = std::fs::read_to_string(c.dir.join(SIGNUPS)).unwrap();
    let mut lines: Vec<&str> = text.lines().collect();
    lines.pop();
    std::fs::write(c.dir.join(SIGNUPS), lines.join("\n") + "\n").unwrap();
    let d = Rig::new("short-signups-target");
    let err = import(&c.dir, &d.stores(), &c.pubkey(), false)
        .expect_err("a short signups file must be refused");
    assert!(
        err.to_string().contains("signups") && err.to_string().contains("manifest says"),
        "{err}"
    );
}

#[test]
fn an_unchanged_export_is_byte_identical_so_a_git_backup_has_nothing_to_commit() {
    let r = Rig::new("determinism");
    r.with_history();
    export(&r.dir, &r.stores(), &r.pubkey()).unwrap();
    let first: Vec<(String, Vec<u8>)> = [MANIFEST, LEDGER, USAGE, SIGNUPS]
        .iter()
        .map(|f| (f.to_string(), std::fs::read(r.dir.join(f)).unwrap()))
        .collect();
    let stamps: Vec<_> = [MANIFEST, LEDGER, USAGE, SIGNUPS]
        .iter()
        .map(|f| {
            std::fs::metadata(r.dir.join(f))
                .unwrap()
                .modified()
                .unwrap()
        })
        .collect();

    std::thread::sleep(std::time::Duration::from_millis(20));
    export(&r.dir, &r.stores(), &r.pubkey()).unwrap();
    let second: Vec<(String, Vec<u8>)> = [MANIFEST, LEDGER, USAGE, SIGNUPS]
        .iter()
        .map(|f| (f.to_string(), std::fs::read(r.dir.join(f)).unwrap()))
        .collect();
    assert_eq!(
        first, second,
        "the same state must export to the same bytes"
    );
    // And the files were not even rewritten, so mtimes are untouched.
    let after: Vec<_> = [MANIFEST, LEDGER, USAGE, SIGNUPS]
        .iter()
        .map(|f| {
            std::fs::metadata(r.dir.join(f))
                .unwrap()
                .modified()
                .unwrap()
        })
        .collect();
    assert_eq!(
        stamps, after,
        "an unchanged export must not touch the files at all"
    );

    // New history DOES change it, or the determinism above would be the useless
    // kind — a function that always writes the same thing.
    r.tx().run(&purchase("olle")).unwrap();
    export(&r.dir, &r.stores(), &r.pubkey()).unwrap();
    assert_ne!(
        std::fs::read(r.dir.join(LEDGER)).unwrap(),
        first[1].1,
        "a new purchase must show up"
    );
}

#[test]
fn refuse_twin_no_secret_reaches_the_backup() {
    let r = Rig::new("secrets");
    r.with_history();
    export(&r.dir, &r.stores(), &r.pubkey()).unwrap();

    // The PRIVATE key bytes, in every encoding a leak would plausibly take.
    let secret: [u8; 32] = [0xA5; 32]; // the seed Rig::new uses
    let hex: String = secret.iter().map(|b| format!("{b:02x}")).collect();
    for f in [MANIFEST, LEDGER, USAGE, SIGNUPS] {
        let bytes = std::fs::read(r.dir.join(f)).unwrap();
        let text = String::from_utf8_lossy(&bytes);
        assert!(!text.contains(&hex), "{f} contains the signing key in hex");
        assert!(
            !bytes.windows(secret.len()).any(|w| w == secret),
            "{f} contains the raw signing key bytes"
        );
    }
    // The PUBLIC half is present on purpose, and is what makes a restore honest.
    let manifest = std::fs::read_to_string(r.dir.join(MANIFEST)).unwrap();
    assert!(
        manifest.contains(&r.pubkey()),
        "the public key belongs in the manifest"
    );
    assert!(
        manifest.contains("NO SECRETS"),
        "the file must say so in its own words"
    );
}

#[test]
fn refuse_twin_a_restore_under_a_different_signing_key_is_refused_by_default() {
    let a = Rig::new("keymismatch");
    a.with_history();
    export(&a.dir, &a.stores(), &a.pubkey()).unwrap();

    let b = Rig::seeded("keymismatch-target", [0x5A; 32]); // a DIFFERENT signer
    assert_ne!(a.pubkey(), b.pubkey());
    let err = import(&a.dir, &b.stores(), &b.pubkey(), false)
        .expect_err("a key mismatch must be refused");
    let msg = err.to_string();
    assert!(
        msg.contains(&a.pubkey()) && msg.contains(&b.pubkey()),
        "both keys must be named: {msg}"
    );
    assert!(
        msg.contains("cannot reproduce") || msg.contains("cannot re-sign"),
        "and the consequence: {msg}"
    );
    assert!(
        b.ledger.export_rows().unwrap().is_empty(),
        "a refused restore must write nothing"
    );

    // The override is a decision, and it works.
    import(&a.dir, &b.stores(), &b.pubkey(), true).expect("the override restores");
    assert!(!b.ledger.export_rows().unwrap().is_empty());
}

#[test]
fn refuse_twin_a_truncated_backup_and_a_restore_over_live_history_are_both_refused() {
    let a = Rig::new("truncated");
    a.with_history();
    export(&a.dir, &a.stores(), &a.pubkey()).unwrap();

    // Lose the last line — the failure most likely to pass unnoticed, because
    // every remaining line still parses.
    let text = std::fs::read_to_string(a.dir.join(LEDGER)).unwrap();
    let mut lines: Vec<&str> = text.lines().collect();
    lines.pop();
    std::fs::write(a.dir.join(LEDGER), lines.join("\n") + "\n").unwrap();

    let b = Rig::new("truncated-target");
    let err =
        import(&a.dir, &b.stores(), &a.pubkey(), false).expect_err("a short file must be refused");
    assert!(
        err.to_string().contains("truncated") || err.to_string().contains("manifest says"),
        "{err}"
    );
    assert!(
        b.ledger.export_rows().unwrap().is_empty(),
        "nothing written"
    );

    // And a restore into a ledger that ALREADY holds rows: interleaving a
    // restore with live history would produce a ledger that was never true.
    let c = Rig::new("occupied");
    c.with_history();
    export(&c.dir, &c.stores(), &c.pubkey()).unwrap();
    let err = import(&c.dir, &c.stores(), &c.pubkey(), false)
        .expect_err("a non-empty ledger must be refused");
    assert!(err.to_string().contains("already holds rows"), "{err}");
}

#[test]
fn a_stale_plans_file_from_before_the_catalogue_went_is_not_carried_forward() {
    let r = Rig::new("stale-plans");
    std::fs::write(r.dir.join("plans.toml"), "[[plan]]\nid = \"free\"\n").unwrap();
    export(&r.dir, &r.stores(), &r.pubkey()).unwrap();
    assert!(
        !r.dir.join("plans.toml").exists(),
        "a backup of today's state must not hand a restore a catalogue nothing reads"
    );
}

/// **THE LOST PAYMENT A RESTORE USED TO MAKE PERMANENT.**
///
/// `crate::reconcile` is the only thing that finds a payment monetize never
/// recorded — the 2026-09-14 incident's shape: the box's clock ran two hours
/// fast, every webhook for a real 125.00 SEK charge was refused, and nothing
/// but a journald line existed. It finds them by asking each vendor from its
/// LAST PASS minus a margin, and from `now − 48 h` when there is no last pass.
///
/// Those stamps live in `reconcile.redb`, and that file was left out of this
/// backup on the argument (the module's own header) that a needs-operator row
/// *"is a fact the vendor will answer again on the next pass after a restore,
/// from a window that trails the restored last-pass stamp"*. A restore does
/// not restore a stamp that is not in the backup. So:
///
///   the disk dies on Monday · the box is rebuilt and restored on Friday ·
///   the first pass has no stamp and asks Stripe from Friday − 48 h ·
///   every session paid Monday to Wednesday whose webhook never landed is
///   older than that and is NEVER ASKED ABOUT AGAIN, by anything.
///
/// The customer paid, has no account, and no row anywhere says so. A window
/// that is too WIDE costs a few idempotent re-reads; one that is too narrow
/// costs the payment. RED before this lane: `last_pass` came back `None`.
#[test]
fn the_vendor_pass_stamps_survive_a_restore_so_no_payment_falls_behind_the_window() {
    use crate::reconcile::{since_for, FIRST_WINDOW_MS};
    const HOUR: i64 = 60 * 60 * 1000;

    let a = Rig::new("reconcile-passes");
    a.with_history();
    // The last pass that finished before the disk died, on Monday.
    let died = NOW as i64;
    a.reconcile.set_last_pass("mockpay", died).unwrap();
    a.reconcile
        .set_last_pass("invoice", died - 6 * HOUR)
        .unwrap();

    let m = export(&a.dir, &a.stores(), &a.pubkey()).unwrap();
    assert_eq!(m.vendor_passes, 2);

    // Friday: an empty, rebuilt box.
    let b = Rig::new("reconcile-passes-target");
    let restored_on = died + 4 * 24 * HOUR;
    assert_eq!(
        b.reconcile.last_pass("mockpay").unwrap(),
        None,
        "the fixture must restore into an EMPTY store"
    );
    import(&a.dir, &b.stores(), &a.pubkey(), false).unwrap();
    assert_eq!(b.reconcile.last_pass("mockpay").unwrap(), Some(died));
    assert_eq!(
        b.reconcile.last_pass("invoice").unwrap(),
        Some(died - 6 * HOUR)
    );

    // THE ASSERTION THAT IS ABOUT MONEY: the first pass after the restore
    // reaches back past the outage, and the old behaviour did not.
    let paid_on_tuesday = died + 24 * HOUR;
    let restored_window = since_for(b.reconcile.last_pass("mockpay").unwrap(), restored_on);
    assert!(
        restored_window <= paid_on_tuesday,
        "the restored window must cover the outage"
    );
    assert!(
        since_for(None, restored_on) > paid_on_tuesday,
        "the fixture's outage must be longer than the first window ({FIRST_WINDOW_MS} ms), or this test proves nothing"
    );

    // **A restore never moves a window FORWARD.** A box that has already run a
    // pass of its own since the restore keeps the earlier of the two stamps:
    // asking too far back is idempotent, asking not far enough loses a payment.
    let c = Rig::new("reconcile-passes-later");
    c.reconcile
        .set_last_pass("mockpay", died + 10 * 24 * HOUR)
        .unwrap();
    import(&a.dir, &c.stores(), &a.pubkey(), false).unwrap();
    assert_eq!(
        c.reconcile.last_pass("mockpay").unwrap(),
        Some(died),
        "the EARLIER stamp wins"
    );
}

/// ★ **A refund survives a restore** (g-refund, the OPEN item of g-refund-verb):
/// the `refunds` table of signups.redb was not in `--export/--import`, so a
/// restored box came up with the refunded signup back on the owed-back queue —
/// an alarm for money already given back, and a second refund one click away.
#[test]
fn a_refund_survives_a_restore_and_the_owed_back_queue_stays_settled() {
    let a = Rig::new("refund-drill");
    a.with_history();
    let done = crate::signup::SignupRefund::Done {
        refund_id: "cs_refused:refund:10000".into(),
        amount: Money {
            minor: 10_000,
            currency: Currency::Sek,
        },
        reason: "name taken".into(),
        requested_at_unix_ms: NOW as i64,
        at_unix_ms: NOW as i64 + 1,
        forced: false,
    };
    a.signups
        .put_refund("mockpay", "cs_refused", &done)
        .unwrap();
    assert!(
        a.signups.owed_back().unwrap().is_empty(),
        "the refunded row is settled before the backup"
    );
    let m = export(&a.dir, &a.stores(), &a.pubkey()).unwrap();
    assert_eq!(m.signup_refunds, 1);

    let b = Rig::new("refund-drill-target");
    import(&a.dir, &b.stores(), &a.pubkey(), false).unwrap();
    assert_eq!(
        b.signups.refund_of("mockpay", "cs_refused").unwrap(),
        Some(done)
    );
    assert!(
        b.signups.owed_back().unwrap().is_empty(),
        "a restore must not put a refunded signup back on the queue"
    );
}

/// ★ **WHAT THE LEDGER HOLDS OUTSIDE ITS STEP ROWS SURVIVES A RESTORE.** A
/// refund's take-back, an operator's comp and a ladder suspension are all
/// written to the entitlements table (the suspension also to the pending
/// pushes) and to no step row; a restore that re-derives entitlements from the
/// `EntitlementPushed` rows alone gives the refunded customer the room back,
/// drops the comp and un-suspends the tenant. Compared field by field.
#[test]
fn entitlements_and_pending_pushes_survive_a_restore_field_by_field() {
    use monetize_product::{EntitlementFact, State, TenantId};
    let a = Rig::new("entitlements");
    a.with_history();

    // A refund, and its take-back: the room is gone from the fact.
    let alice = a
        .ledger
        .entitlement("gunnar", "alice")
        .unwrap()
        .expect("alice bought");
    assert!(!alice.caps.is_empty());
    let currency = a
        .ledger
        .sales()
        .unwrap()
        .into_iter()
        .find(|s| s.reference == alice.plan)
        .expect("alice's sale")
        .price
        .currency;
    a.ledger
        .append(
            NOW + 1,
            monetize::Step::Reversed {
                reference: alice.plan.clone(),
                kind: monetize_pay::ReversalKind::Refunded,
                id: "re_alice".into(),
                amount: Money {
                    minor: 100,
                    currency,
                },
                reason: "goodwill".into(),
                by: "operator".into(),
            },
        )
        .unwrap();
    let taken = a.signer.sign_fact_at(
        EntitlementFact {
            caps: Default::default(),
            source: format!("refund:{}", alice.plan),
            ..alice.clone()
        },
        NOW + 2,
    );
    a.ledger.set_entitlement("gunnar", &taken).unwrap();

    // An operator's comp for a tenant who never bought.
    let comp = a.signer.sign_fact_at(
        EntitlementFact {
            tenant: TenantId("comped".into()),
            plan: "comp: a friend of the house".into(),
            state: State::Paid,
            paid_until_unix_ms: Some(NOW + 90 * 86_400_000),
            caps: [("pack_bytes".to_string(), 1u64 << 30)].into(),
            source: "operator".into(),
            ..alice.clone()
        },
        NOW + 3,
    );
    a.ledger.set_entitlement("gunnar", &comp).unwrap();

    // A ladder suspension the product has not acknowledged yet.
    let sub = a
        .ledger
        .entitlement("gunnar", "team/sub")
        .unwrap()
        .expect("team/sub bought");
    let suspended = a.signer.sign_fact_at(
        EntitlementFact {
            state: State::Suspended,
            source: "poller".into(),
            ..sub.clone()
        },
        NOW + 4,
    );
    let pending = monetize::ledger::PendingPush {
        fact: suspended,
        since_unix_ms: NOW + 4,
        attempts: 2,
        last_error: "gunnar did not answer".into(),
    };
    assert!(a
        .ledger
        .replace_entitlement_pending("gunnar", Some(&sub), &pending)
        .unwrap());

    let before_ents = a.ledger.entitlements("gunnar").unwrap();
    let before_pushes = a.ledger.pending_pushes().unwrap();
    assert_eq!(before_pushes.len(), 1);
    export(&a.dir, &a.stores(), &a.pubkey()).unwrap();

    let b = Rig::new("entitlements-target");
    import(&a.dir, &b.stores(), &a.pubkey(), false).unwrap();
    let after_ents = b.ledger.entitlements("gunnar").unwrap();
    assert_eq!(after_ents.len(), before_ents.len(), "{after_ents:?}");
    for (was, is) in before_ents.iter().zip(&after_ents) {
        let t = &was.tenant.0;
        assert_eq!(is.tenant, was.tenant);
        assert_eq!(is.plan, was.plan, "plan of {t}");
        assert_eq!(is.state, was.state, "state of {t}");
        assert_eq!(
            is.paid_until_unix_ms, was.paid_until_unix_ms,
            "paid_until of {t}"
        );
        assert_eq!(is.caps, was.caps, "caps of {t}");
        assert_eq!(is.source, was.source, "source of {t}");
        assert_eq!(is.issued_unix_ms, was.issued_unix_ms, "issued of {t}");
        assert_eq!(is.signature, was.signature, "signature of {t}");
        assert_eq!(
            is.issued_signature, was.issued_signature,
            "issued signature of {t}"
        );
    }
    assert!(
        b.ledger
            .entitlement("gunnar", "alice")
            .unwrap()
            .unwrap()
            .caps
            .is_empty(),
        "the refunded customer did not get the room back"
    );
    assert_eq!(
        b.ledger.entitlement("gunnar", "comped").unwrap().as_ref(),
        Some(&comp),
        "the comp survived"
    );
    assert_eq!(
        b.ledger
            .entitlement("gunnar", "team/sub")
            .unwrap()
            .unwrap()
            .state,
        State::Suspended,
        "the suspension survived"
    );
    let after_pushes = b.ledger.pending_pushes().unwrap();
    assert_eq!(after_pushes.len(), before_pushes.len());
    for ((wp, was), (ip, is)) in before_pushes.iter().zip(&after_pushes) {
        assert_eq!(ip, wp);
        assert_eq!(is.fact, was.fact);
        assert_eq!(is.since_unix_ms, was.since_unix_ms);
        assert_eq!(is.attempts, was.attempts);
        assert_eq!(is.last_error, was.last_error);
    }
}

/// ★ Review #18: an export that dies mid-file leaves the previous backup's file
/// whole, never half of the new one, and no temp file behind it.
#[test]
fn an_export_that_crashes_mid_file_leaves_the_last_backup_whole() {
    let r = Rig::new("crash-mid-file");
    r.with_history();
    export(&r.dir, &r.stores(), &r.pubkey()).unwrap();
    let before = std::fs::read(r.dir.join(LEDGER)).unwrap();

    r.tx().run(&purchase("olle")).unwrap();
    CRASH_AFTER.with(|c| c.set(Some(10)));
    assert!(export(&r.dir, &r.stores(), &r.pubkey()).is_err());
    assert_eq!(
        std::fs::read(r.dir.join(LEDGER)).unwrap(),
        before,
        "the crash left half a ledger where a whole one was"
    );
    let left: Vec<String> = std::fs::read_dir(&r.dir)
        .unwrap()
        .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
        .filter(|n| n.ends_with(".tmp"))
        .collect();
    assert!(left.is_empty(), "temp files left behind: {left:?}");

    export(&r.dir, &r.stores(), &r.pubkey()).unwrap();
    assert_ne!(
        std::fs::read(r.dir.join(LEDGER)).unwrap(),
        before,
        "the next export writes the new state"
    );
}

/// ★ Review #19: a `nonpay.jsonl` that lost a line is refused like every other
/// short file, never folded in as if it were whole.
#[test]
fn a_truncated_nonpay_file_is_refused_by_the_manifest() {
    let a = Rig::new("nonpay-short");
    a.with_history();
    a.nonpay
        .append(
            1,
            "gunnar",
            "bob",
            100,
            crate::nonpay::Event::Scheduled {
                delete_on_unix_ms: 200,
            },
        )
        .unwrap();
    a.nonpay
        .append(
            2,
            "gunnar",
            "olle",
            100,
            crate::nonpay::Event::Scheduled {
                delete_on_unix_ms: 300,
            },
        )
        .unwrap();
    assert_eq!(
        export(&a.dir, &a.stores(), &a.pubkey())
            .unwrap()
            .nonpay_rows,
        2
    );
    let text = std::fs::read_to_string(a.dir.join(NONPAY)).unwrap();
    std::fs::write(
        a.dir.join(NONPAY),
        text.lines().next().unwrap().to_owned() + "\n",
    )
    .unwrap();

    let b = Rig::new("nonpay-short-target");
    let err = import(&a.dir, &b.stores(), &a.pubkey(), false)
        .expect_err("a short nonpay.jsonl must be refused");
    assert!(
        err.to_string().contains(NONPAY) && err.to_string().contains("manifest says"),
        "{err}"
    );
    assert!(
        b.nonpay.entries().is_empty() && b.ledger.export_rows().unwrap().is_empty(),
        "nothing written"
    );
}

/// ★ Review #19: a restore that is refused LATE (a bad line in the last file)
/// writes nothing. Every file is read and checked before any store is touched.
#[test]
fn a_restore_refused_late_leaves_every_store_empty() {
    let a = Rig::new("late-refusal");
    a.with_history();
    export(&a.dir, &a.stores(), &a.pubkey()).unwrap();
    let passes = a.dir.join(PASSES);
    let mut text = std::fs::read_to_string(&passes).unwrap();
    text.push_str("{not json\n");
    std::fs::write(&passes, text).unwrap();

    let b = Rig::new("late-refusal-target");
    assert!(import(&a.dir, &b.stores(), &a.pubkey(), false).is_err());
    assert!(
        b.ledger.export_rows().unwrap().is_empty(),
        "the ledger was written before the refusal"
    );
    assert!(
        b.usage.export_all().unwrap().is_empty(),
        "usage was written before the refusal"
    );
    assert!(
        b.signups.export_all().unwrap().is_empty(),
        "signups were written before the refusal"
    );
}

/// ★ Review #19: a file edited in place keeps its line count, so only the
/// manifest's hash of it can tell. It is refused.
#[test]
fn a_file_edited_in_place_is_refused_by_its_hash() {
    let a = Rig::new("edited");
    a.with_history();
    export(&a.dir, &a.stores(), &a.pubkey()).unwrap();
    let usage = a.dir.join(USAGE);
    let text = std::fs::read_to_string(&usage).unwrap();
    assert!(text.contains("4096"));
    std::fs::write(&usage, text.replacen("4096", "4097", 1)).unwrap();

    let b = Rig::new("edited-target");
    let err = import(&a.dir, &b.stores(), &a.pubkey(), false)
        .expect_err("an edited file must be refused");
    assert!(err.to_string().contains(USAGE), "{err}");
    assert!(
        b.ledger.export_rows().unwrap().is_empty(),
        "nothing written"
    );
}

/// ★ Review #19 (gap G17): the server backs itself up on a timer. One tick
/// writes a whole backup into the configured `backup_dir`.
#[test]
fn a_scheduled_tick_writes_a_whole_backup_into_backup_dir() {
    let r = Rig::new("scheduled");
    let mut state = crate::state::AppState::in_memory(crate::registry::Registry::new());
    state.backup_dir = Some(r.dir.join("tick"));
    let m = scheduled_export(&state).expect("a tick with a backup_dir writes");
    assert!(r.dir.join("tick").join(MANIFEST).exists());
    assert_eq!(m.ledger_rows, 0);
    state.backup_dir = None;
    assert!(
        scheduled_export(&state).is_err(),
        "no backup_dir is said, not skipped in silence"
    );
}

/// ★ A22-O W11 (F-T-08): **exports into one backup directory never interleave** — the
/// backup timer and an operator's `Ledger.Export` at the same moment. They shared each
/// file's temp name (`<file>.tmp`): one export's rename took the other's half-written temp,
/// or found it gone, and a manifest could name hashes of files the other wrote. One export
/// runs at a time per directory; every one of them succeeds and the last is whole (four
/// at once here, 300 each, so the race is met on every run).
#[test]
fn exports_into_one_directory_at_once_all_succeed_and_the_backup_is_whole() {
    let r = Rig::new("two-at-once");
    r.with_history();
    let failures: Vec<String> = std::thread::scope(|s| {
        let runs: Vec<_> = (0..4u64)
            .map(|who| {
                let r = &r;
                s.spawn(move || {
                    let mut failed = Vec::new();
                    for i in 0..300u64 {
                        // A new reading each time: every export writes its files again.
                        let meters = std::collections::BTreeMap::from([(
                            "pack_bytes".to_string(),
                            4096 + who * 1_000 + i,
                        )]);
                        r.usage
                            .put(
                                "gunnar",
                                "alice",
                                &monetize_product::Usage {
                                    meters,
                                    measured_at_unix_ms: NOW,
                                },
                            )
                            .unwrap();
                        if let Err(e) = export(&r.dir, &r.stores(), &r.pubkey()) {
                            failed.push(e.to_string());
                        }
                    }
                    failed
                })
            })
            .collect();
        runs.into_iter().flat_map(|h| h.join().unwrap()).collect()
    });
    assert!(
        failures.is_empty(),
        "{} export(s) failed while another ran: {:?}",
        failures.len(),
        failures.iter().take(3).collect::<Vec<_>>()
    );
    export(&r.dir, &r.stores(), &r.pubkey()).unwrap();
    let back = Rig::new("two-at-once-restored");
    import(&r.dir, &back.stores(), &r.pubkey(), false).expect("the last backup reads back whole");
    let left: Vec<String> = std::fs::read_dir(&r.dir)
        .unwrap()
        .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
        .filter(|n| n.ends_with(".tmp"))
        .collect();
    assert!(left.is_empty(), "temp files left behind: {left:?}");
}