crates/monetize-cloud/src/sandbox/tests.rs
//! Red-first for the rehearsal cloud. What matters is what it REFUSES and what it
//! REMEMBERS: a cloud that says yes to everything and forgets on restart cannot
//! rehearse the one property this seam exists for — never buying twice.
//!
//! Assertions are on applied output: the resources it recorded, the bytes on
//! disk, the mock's recorded requests. Never on a round-trip.
use super::*;
use crate::cap::{Ceiling, SpendCap};
use crate::ServerRole;
const REF: &str = "gunnar/alice/2026-09";
const REF2: &str = "gunnar/alice/2026-09/increase";
const SRV: &str = "gunnar-se-sto1";
fn tmp(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"monetize-cloud-sandbox-{}-{name}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
dir.join("sandbox-cloud.json")
}
/// 23 cents/GiB/month and two plans, in EUR — the declared fallback.
fn declared() -> DeclaredPrices {
DeclaredPrices {
disk_minor_per_gib_month: 23,
plan_minor_per_month: BTreeMap::from([
("STARTER-2xCPU-4GB".to_string(), 1304),
("STARTER-4xCPU-16GB".to_string(), 3042),
]),
currency: Currency::Eur,
}
}
fn cfg(pool_gib: u64) -> SandboxConfig {
let mut c = SandboxConfig::new();
c.pool_gib = pool_gib;
c.declared = Some(declared());
c
}
fn sandbox(pool_gib: u64) -> Sandbox {
Sandbox::new(cfg(pool_gib))
.unwrap()
.with_clock(|| 1_788_000_000_000)
}
fn disk(gib: u64) -> Resource {
Resource::Disk {
server: ServerId(SRV.into()),
gib,
}
}
fn provisioned(e: &Ensured) -> (ResourceId, Price) {
match e {
Ensured::Provisioned { id, cost } => (id.clone(), *cost),
other => panic!("expected Provisioned, got {other:?}"),
}
}
// ── ensure: marked, priced, idempotent on reference + fingerprint ─────────────
#[test]
fn a_purchase_records_fake_iron_marked_sandbox_at_the_declared_price() {
let sb = sandbox(0);
let out = sb.ensure(REF, &disk(110)).unwrap();
let (id, cost) = provisioned(&out);
assert_eq!(id.provider, "sandbox");
assert_eq!(
id.id, "sandbox:disk:1",
"the kind and the counter are in the id"
);
assert_eq!(
cost,
Price {
minor_per_month: 110 * 23,
currency: Currency::Eur
},
"110 GiB at 23 cents, declared"
);
let rs = sb.resources();
assert_eq!(rs.len(), 1);
let r = &rs[0];
assert_eq!(r.reference, REF, "rule 2: attached to one order");
assert_eq!(r.fingerprint, "disk:gunnar-se-sto1:110");
assert_eq!(r.labels[LABEL_REF], REF);
assert_eq!(r.labels[LABEL_RES], "disk:gunnar-se-sto1:110");
assert_eq!(
r.labels[LABEL_PROVENANCE], "sandbox",
"the mark the console shows"
);
assert_eq!(
r.labels[LABEL_PRICE], "declared",
"and where the price came from"
);
assert_eq!(r.status, ResourceStatus::Active);
assert_eq!(r.created_unix_ms, 1_788_000_000_000);
assert_eq!(
r.size_gib, 110,
"110 is already a multiple of the 10 GiB step"
);
assert_eq!(sb.live_gib(), 110);
}
/// **The double-charge seam.** The same (reference, resource) twice mints ONE
/// resource; a new reference for the same resource mints a second — which is
/// exactly why the transaction computes the delta before it calls this.
#[test]
fn ensure_is_idempotent_on_reference_plus_fingerprint_like_the_label_search() {
let sb = sandbox(0);
let first = sb.ensure(REF, &disk(10)).unwrap();
let again = sb.ensure(REF, &disk(10)).unwrap();
assert_eq!(
first, again,
"a retried attempt gets the same resource back"
);
assert_eq!(
sb.resources().len(),
1,
"one resource for one (reference, resource)"
);
assert_eq!(sb.live_gib(), 10);
// Twin: a different reference IS a different purchase.
let other = sb.ensure(REF2, &disk(10)).unwrap();
assert_ne!(provisioned(&first).0, provisioned(&other).0);
assert_eq!(sb.resources().len(), 2);
assert_eq!(sb.live_gib(), 20, "MEASURED: a re-ensure under a new reference buys again — the transaction must send the delta");
// Twin: the same reference with a different SIZE is a different fingerprint.
let bigger = sb.ensure(REF, &disk(20)).unwrap();
assert_ne!(provisioned(&first).0, provisioned(&bigger).0);
assert_eq!(sb.resources().len(), 3);
}
/// The 110 GiB shape the transaction tests use: 10 held, 90 more ensured under a
/// new reference → 100 live, never 110. The provider's half of the delta rule.
#[test]
fn the_increase_delta_lands_as_ten_plus_ninety_not_one_hundred_and_ten() {
let sb = sandbox(0);
sb.ensure(REF, &disk(10)).unwrap();
sb.ensure(REF2, &disk(90)).unwrap();
assert_eq!(
sb.live_gib(),
100,
"10 + 90: the tenant holds what was asked for"
);
let sizes: Vec<u64> = sb.resources().iter().map(|r| r.size_gib).collect();
assert_eq!(sizes, vec![10, 90]);
// Twin: the naive re-purchase of the whole 100 under the new reference.
let naive = sandbox(0);
naive.ensure(REF, &disk(10)).unwrap();
naive.ensure(REF2, &disk(100)).unwrap();
assert_eq!(
naive.live_gib(),
110,
"MEASURED: selling the bigger plan outright buys the 10 again"
);
}
// ── the pool ─────────────────────────────────────────────────────────────────
#[test]
fn a_declared_pool_answers_on_hand_and_a_bought_surplus_becomes_pool() {
let sb = sandbox(100);
assert_eq!(
sb.ensure(REF, &disk(50)).unwrap(),
Ensured::OnHand {
pool_free_after: 50
}
);
assert_eq!(sb.resources().len(), 0, "OnHand mints nothing");
// 60 > 50 free: buys. 60 is on the 10 GiB step already.
let (_, cost) = provisioned(&sb.ensure(REF2, &disk(60)).unwrap());
assert_eq!(cost.minor_per_month, 60 * 23);
// pool = 100 declared + 60 bought − 110 sold = 50 free.
assert_eq!(
sb.ensure("gunnar/beta/2026-09", &disk(50)).unwrap(),
Ensured::OnHand { pool_free_after: 0 }
);
// The step rounds a purchase up and the surplus is tomorrow's pool: 5 → 10, 5 spare.
let (_, cost) = provisioned(&sb.ensure("gunnar/gamma/2026-09", &disk(5)).unwrap());
assert_eq!(
cost.minor_per_month,
10 * 23,
"priced on the size bought, not the size asked"
);
assert_eq!(
sb.ensure("gunnar/delta/2026-09", &disk(5)).unwrap(),
Ensured::OnHand { pool_free_after: 0 }
);
assert_eq!(sb.live_gib(), 70);
// Twin: no pool, everything is bought.
let none = sandbox(0);
assert!(matches!(
none.ensure(REF, &disk(1)).unwrap(),
Ensured::Provisioned { .. }
));
}
// ── destroy / status ─────────────────────────────────────────────────────────
#[test]
fn destroy_is_idempotent_status_flips_to_gone_and_an_unknown_id_is_an_error_not_gone() {
let sb = sandbox(0);
let (id, _) = provisioned(&sb.ensure(REF, &disk(10)).unwrap());
assert_eq!(sb.status(&id).unwrap(), ResourceStatus::Active);
crate::ask::destroy_one(&sb, &id).unwrap();
assert_eq!(sb.status(&id).unwrap(), ResourceStatus::Gone);
crate::ask::destroy_one(&sb, &id).expect("destroying what is already gone is fine");
assert_eq!(sb.live_gib(), 0);
assert_eq!(sb.resources()[0].destroyed_unix_ms, Some(1_788_000_000_000));
// Gone gives its GiB back: the same pair can be ensured again and mints anew.
let (id2, _) = provisioned(&sb.ensure(REF, &disk(10)).unwrap());
assert_ne!(
id2, id,
"a destroyed resource is not adopted; a new one is minted"
);
let ghost = ResourceId {
provider: PROVIDER.into(),
id: "sandbox:disk:99".into(),
};
assert!(
matches!(sb.status(&ghost), Err(CloudError::UnknownResource(_))),
"never minted ⇒ an error, not Gone"
);
assert!(matches!(
crate::ask::destroy_one(&sb, &ghost),
Err(CloudError::UnknownResource(_))
));
let alien = ResourceId {
provider: "upcloud".into(),
id: "storage/abc".into(),
};
assert!(
matches!(sb.status(&alien), Err(CloudError::UnknownResource(_))),
"another provider's id"
);
assert!(matches!(
crate::ask::destroy_one(&sb, &alien),
Err(CloudError::UnknownResource(_))
));
}
// ── persistence ──────────────────────────────────────────────────────────────
/// The payment sandbox's restart bug, guarded here before it can happen: the
/// process that minted the iron goes away, the next one still owns it.
#[test]
fn the_iron_survives_the_process_that_minted_it() {
let path = tmp("restart");
let mut c = cfg(0);
c.state_path = Some(path.clone());
let id = {
let sb = Sandbox::new(c.clone()).unwrap();
let (id, _) = provisioned(&sb.ensure(REF, &disk(10)).unwrap());
id
// dropped: the in-memory instance is gone
};
// Applied output: the bytes on disk carry the id, the order and the mark.
let bytes = std::fs::read_to_string(&path).unwrap();
assert!(bytes.contains("\"sandbox:disk:1\""), "{bytes}");
assert!(bytes.contains(REF), "{bytes}");
assert!(
bytes.contains("\"monetize_provenance\": \"sandbox\""),
"{bytes}"
);
let again = Sandbox::new(c.clone()).unwrap();
assert_eq!(
again.status(&id).unwrap(),
ResourceStatus::Active,
"a restart does not forget what it owns"
);
// And the idempotency survives too: the same pair is adopted, not minted again.
assert_eq!(
again.ensure(REF, &disk(10)).unwrap(),
Ensured::Provisioned {
id: id.clone(),
cost: Price {
minor_per_month: 230,
currency: Currency::Eur
}
}
);
assert_eq!(again.resources().len(), 1);
crate::ask::destroy_one(&again, &id).expect("destroy still works after a restart");
let third = Sandbox::new(c).unwrap();
assert_eq!(
third.status(&id).unwrap(),
ResourceStatus::Gone,
"and so does the destroy"
);
// Twin: a sandbox at a DIFFERENT path knows nothing — the file is the memory.
let mut elsewhere = cfg(0);
elsewhere.state_path = Some(tmp("restart-elsewhere"));
let stranger = Sandbox::new(elsewhere).unwrap();
assert!(matches!(
stranger.status(&id),
Err(CloudError::UnknownResource(_))
));
// A corrupt file is refused, not guessed over.
std::fs::write(&path, b"{ not json").unwrap();
let mut corrupt = cfg(0);
corrupt.state_path = Some(path);
match Sandbox::new(corrupt) {
Err(CloudError::Unavailable(m)) => assert!(m.contains("does not parse"), "{m}"),
other => panic!("{other:?}"),
}
}
// ── refusals ─────────────────────────────────────────────────────────────────
#[test]
fn refuse_twin_a_zero_in_the_declared_table_or_no_price_source_cannot_start() {
let mut zero = cfg(0);
zero.declared.as_mut().unwrap().disk_minor_per_gib_month = 0;
match Sandbox::new(zero) {
Err(CloudError::Refused(m)) => {
assert!(m.contains("disk at 0"), "{m}");
assert!(m.contains("untestable"), "must say WHY: {m}");
}
other => panic!("{other:?}"),
}
let mut zero_plan = cfg(0);
zero_plan
.declared
.as_mut()
.unwrap()
.plan_minor_per_month
.insert("FREE".into(), 0);
match Sandbox::new(zero_plan) {
Err(CloudError::Refused(m)) => assert!(m.contains("plan FREE at 0"), "{m}"),
other => panic!("{other:?}"),
}
let mut none = cfg(0);
none.declared = None;
match Sandbox::new(none) {
Err(CloudError::Refused(m)) => {
assert!(m.contains("no price source"), "{m}");
assert!(
m.contains("UPCLOUD_TOKEN") && m.contains("disk_minor_per_gib_month"),
"must name both ways out: {m}"
);
}
other => panic!("{other:?}"),
}
let mut blank = cfg(0);
blank.zone = Zone(String::new());
assert!(matches!(Sandbox::new(blank), Err(CloudError::Refused(_))));
// Green twin: the same table with real numbers starts.
assert!(Sandbox::new(cfg(0)).is_ok());
}
#[test]
fn refuse_twin_the_things_a_real_provider_refuses() {
let sb = sandbox(0);
assert!(
matches!(sb.ensure(REF, &disk(0)), Err(CloudError::Refused(_))),
"a disk of nothing"
);
assert!(
matches!(sb.ensure("", &disk(1)), Err(CloudError::Refused(_))),
"no reference to attach it to"
);
assert!(
matches!(
sb.ensure(&"r".repeat(256), &disk(1)),
Err(CloudError::Refused(_))
),
"a reference no label can carry"
);
let unpriced = Resource::Server {
plan: "NO-SUCH-PLAN".into(),
zone: Zone("se-sto1".into()),
role: ServerRole::Replica,
};
assert!(
matches!(sb.ensure(REF, &unpriced), Err(CloudError::Refused(_))),
"a plan the table does not price"
);
assert_eq!(sb.resources().len(), 0, "nothing was minted by a refusal");
assert_eq!(sb.live_gib(), 0);
}
// ── servers and plan changes ─────────────────────────────────────────────────
#[test]
fn a_server_is_priced_by_plan_and_a_plan_change_charges_the_delta_or_is_on_hand() {
let sb = sandbox(0);
let srv = Resource::Server {
plan: "STARTER-2xCPU-4GB".into(),
zone: Zone("se-sto1".into()),
role: ServerRole::Replica,
};
let (sid, cost) = provisioned(&sb.ensure(REF, &srv).unwrap());
assert_eq!(sid.id, "sandbox:server:1");
assert_eq!(cost.minor_per_month, 1304);
// The fake server already runs its plan: asking for the same plan is OnHand.
let same = Resource::Compute {
server: ServerId(sid.id.clone()),
plan: "STARTER-2xCPU-4GB".into(),
};
assert_eq!(
sb.ensure(REF2, &same).unwrap(),
Ensured::OnHand { pool_free_after: 0 }
);
// Up a step: the delta, and only the delta.
let up = Resource::Compute {
server: ServerId(sid.id.clone()),
plan: "STARTER-4xCPU-16GB".into(),
};
assert_eq!(
crate::Cloud::price(&sb, &crate::cloud::Priced::Resource(&up))
.unwrap()
.minor_per_month,
3042 - 1304
);
let (cid, cost) = provisioned(&sb.ensure("gunnar/alice/2026-10", &up).unwrap());
assert_eq!(cid.id, "sandbox:compute:2");
assert_eq!(cost.minor_per_month, 3042 - 1304);
let change = sb.resources().into_iter().find(|r| r.id == cid.id).unwrap();
assert_eq!(
change.from_plan.as_deref(),
Some("STARTER-2xCPU-4GB"),
"what destroy puts back"
);
// Destroying the change puts the plan back, so the same change is chargeable again.
crate::ask::destroy_one(&sb, &cid).unwrap();
assert_eq!(
crate::Cloud::price(&sb, &crate::cloud::Priced::Resource(&up))
.unwrap()
.minor_per_month,
3042 - 1304
);
// A server the sandbox was never told about is charged the WHOLE plan — the
// conservative direction for a cap.
let foreign = Resource::Compute {
server: ServerId("some-real-box".into()),
plan: "STARTER-2xCPU-4GB".into(),
};
assert_eq!(
crate::Cloud::price(&sb, &crate::cloud::Priced::Resource(&foreign))
.unwrap()
.minor_per_month,
1304
);
}
// ── behind the cap ───────────────────────────────────────────────────────────
/// Rule 4: it sits behind `SpendCap` like any provider. The cap refuses BEFORE the
/// sandbox is called — asserted on the sandbox's own call log, not on the error.
#[test]
fn the_spend_cap_refuses_before_the_sandbox_is_called() {
let capped =
SpendCap::new(sandbox(0), Ceiling::total(1000, Currency::Eur), tmp("cap")).unwrap();
// 50 GiB × 23 = 1150 > 1000.
let err = crate::ask::ensure(&capped, REF, &disk(50), None).unwrap_err();
// The cap's own refusal (`OverCap`, said as a refusal); this test first compiled in the
// workspace run when `sandbox` entered the default features (monetize v2.0 P1).
assert!(
matches!(err, CloudError::Refused(_) | CloudError::OverCap(_)),
"{err}"
);
assert!(
err.to_string().contains("11.50 EUR"),
"names the price, in EUR with two decimals: {err}"
);
assert_eq!(capped.spent_total(), 0, "nothing spent");
// The cap owns its inner, so to read the sandbox's call log the sandbox is
// held behind an Arc (a shared provider is a provider).
let sb = std::sync::Arc::new(sandbox(0));
let capped =
SpendCap::new(sb.clone(), Ceiling::total(1000, Currency::Eur), tmp("cap2")).unwrap();
assert!(crate::ask::ensure(&capped, REF, &disk(50), None).is_err());
assert!(
sb.calls().is_empty(),
"the sandbox must not have been called: {:?}",
sb.calls()
);
assert!(sb.resources().is_empty());
// Twin: under the ceiling it IS called, and the total rises by the real cost.
let out = crate::ask::ensure(&capped, REF, &disk(40), None).unwrap();
assert_eq!(provisioned(&out).1.minor_per_month, 920);
assert_eq!(
sb.calls(),
vec![Call::Ensure {
reference: REF.into(),
fingerprint: "disk:gunnar-se-sto1:40".into()
}]
);
assert_eq!(capped.spent_total(), 920);
assert_eq!(capped.ceiling(), Ceiling::total(1000, Currency::Eur));
}
// ── config from the environment ──────────────────────────────────────────────
#[test]
fn from_env_reads_the_state_path_and_the_credential_decides_the_price_source() {
let none = SandboxConfig::from_env_with(|_| None);
assert!(none.state_path.is_none());
let with = SandboxConfig::from_env_with(|k| match k {
"MONETIZE_SANDBOX_CLOUD_STATE" => Some("/var/lib/monetize/sandbox-cloud.json".into()),
_ => None,
});
assert_eq!(
with.state_path.as_deref(),
Some(Path::new("/var/lib/monetize/sandbox-cloud.json"))
);
let blank =
SandboxConfig::from_env_with(|k| (k == "MONETIZE_SANDBOX_CLOUD_STATE").then(String::new));
assert!(blank.state_path.is_none(), "an empty path is no path");
assert_eq!(CONFIG_ENVS, &["MONETIZE_SANDBOX_CLOUD_STATE"]);
}
// ── inventory ────────────────────────────────────────────────────────────────
/// `inventory()` answers from the persisted state: there after `ensure`, gone
/// after `destroy`, and still there after a restart in between.
#[test]
fn inventory_shows_the_iron_after_ensure_and_not_after_destroy() {
let path = tmp("inventory");
let mut c = cfg(0);
c.state_path = Some(path);
let sb = Sandbox::new(c.clone()).unwrap();
assert_eq!(
crate::ask::account(&sb).unwrap(),
Inventory {
owned: vec![],
foreign: 0,
currency: Currency::Eur
},
"nothing owned yet, nothing foreign ever — and still a currency"
);
let (id, cost) = provisioned(&sb.ensure(REF, &disk(10)).unwrap());
let srv = Resource::Server {
plan: "STARTER-2xCPU-4GB".into(),
zone: Zone("se-sto1".into()),
role: ServerRole::Replica,
};
let (sid, _) = provisioned(&sb.ensure(REF2, &srv).unwrap());
// A plan change is not a thing that exists at a provider: not listed.
sb.ensure(
"gunnar/alice/2026-10",
&Resource::Compute {
server: ServerId(sid.id.clone()),
plan: "STARTER-4xCPU-16GB".into(),
},
)
.unwrap();
// And an OnHand grant owns nothing.
let pooled = Sandbox::new(cfg(100)).unwrap();
pooled.ensure(REF, &disk(10)).unwrap();
assert!(
crate::ask::account(&pooled).unwrap().owned.is_empty(),
"OnHand bought nothing to own"
);
let inv = crate::ask::account(&Sandbox::new(c.clone()).unwrap()).unwrap();
assert_eq!(inv.foreign, 0);
assert_eq!(
inv.currency,
Currency::Eur,
"the declared table's currency, on the list"
);
assert_eq!(
inv.owned,
vec![
Owned {
id: id.clone(),
kind: OwnedKind::Disk,
gib: 10,
zone: "se-sto1".into(),
attached_to: Some(SRV.into()),
reference: Some(REF.into()),
monthly_minor: Some(cost.minor_per_month),
currency: cost.currency,
provenance: "sandbox".into(),
resource: None,
backup: None,
},
Owned {
id: sid.clone(),
kind: OwnedKind::Server,
gib: 0,
zone: "se-sto1".into(),
attached_to: None,
reference: Some(REF2.into()),
monthly_minor: Some(1304),
currency: Currency::Eur,
provenance: "sandbox".into(),
resource: None,
backup: None,
},
]
);
crate::ask::destroy_one(&sb, &id).unwrap();
let after = crate::ask::account(&Sandbox::new(c).unwrap()).unwrap();
assert_eq!(
after.owned.iter().map(|o| o.id.clone()).collect::<Vec<_>>(),
vec![sid],
"the destroyed disk is no longer owned"
);
}
/// **The rehearsal follows the product's room, like UpCloud.** `Room::OnHand`
/// mints nothing even with an empty declared pool; `Room::Buy` mints even with
/// a declared pool that would have covered it — the pool has never seen the
/// product's disk.
#[test]
fn the_room_decides_and_the_declared_pool_steps_aside() {
let sb = sandbox(0);
assert_eq!(
sb.ensure_with(REF, &disk(10), Room::OnHand).unwrap(),
Ensured::OnHand { pool_free_after: 0 }
);
assert!(
crate::ask::account(&sb).unwrap().owned.is_empty(),
"nothing minted for a product that had the room"
);
let roomy = sandbox(500);
let (id, _) = provisioned(&roomy.ensure_with(REF, &disk(10), Room::Buy).unwrap());
assert_eq!(id.provider, "sandbox");
// Twin: the same roomy sandbox with no room given answers from its pool.
assert!(matches!(
roomy.ensure(REF2, &disk(10)).unwrap(),
Ensured::OnHand { .. }
));
}
// ── the estate lay (monetize v2.0 P1, the money-free open default) ───────────
/// ★ **An estate is laid with play iron, each machine its own server** — two machines of one
/// plan are two servers (minted under the lay's own key `lay:<role>:server`, not the resource's
/// fingerprint); a second lay adopts every one; the plan list is the declared table's.
#[test]
fn an_estate_is_laid_one_server_per_machine_and_a_second_lay_adopts_it() {
use crate::lay::{DiskPlan, EstatePlan, MachinePlan};
let sb = sandbox(0);
let catalog = sb.plan_catalog(&Zone("se-sto1".into())).unwrap();
assert!(
catalog
.iter()
.any(|p| p.name == "STARTER-2xCPU-4GB" && p.cores == 2 && p.memory_mib == 4096),
"{catalog:?}"
);
let machine = |role: &str| MachinePlan {
role: role.into(),
hostname: format!("lake-{role}-acme"),
title: format!("Lake {role} acme"),
plan: "STARTER-2xCPU-4GB".into(),
boot_order: "disk,cdrom".into(),
labels: Default::default(),
interfaces: crate::lay::default_interfaces(),
medium: String::new(),
medium_object: None,
template: String::new(),
user_data: Default::default(),
disks: vec![DiskPlan {
title: format!("lake-{role}-acme system"),
gib: 20,
tier: "standard".into(),
labels: Default::default(),
}],
};
let plan = EstatePlan {
reference: crate::lay::reference_of("acme"),
company: "acme".into(),
zone: Zone("se-sto1".into()),
machines: vec![machine("front"), machine("be"), machine("fe")],
narrate_from: String::new(),
shape: None,
};
let at = tmp("lay");
std::fs::create_dir_all(at.parent().unwrap()).unwrap();
let journal = crate::grow::Journal::open(at.with_file_name("journal.jsonl")).unwrap();
let laid = sb.lay_estate(&plan, &journal).unwrap();
let servers: std::collections::BTreeSet<&str> =
laid.machines.iter().map(|m| m.server.as_str()).collect();
assert_eq!(
servers.len(),
3,
"three machines of one plan are three servers: {laid:?}"
);
let again = sb.lay_estate(&plan, &journal).unwrap();
assert_eq!(
again
.machines
.iter()
.map(|m| m.server.clone())
.collect::<Vec<_>>(),
laid.machines
.iter()
.map(|m| m.server.clone())
.collect::<Vec<_>>(),
"a second lay adopts"
);
let inv = crate::ask::account(&sb).unwrap();
assert_eq!(
inv.owned
.iter()
.filter(|o| o.kind == OwnedKind::Server)
.count(),
3
);
assert_eq!(
inv.owned
.iter()
.filter(|o| o.kind == OwnedKind::Disk)
.count(),
3
);
}
/// R5a: the sandbox is the open default's cloud, served by the ONE box itself.
/// A panic that once held its iron or its call log must not make every later
/// order panic: each mint is written before it answers.
#[test]
fn a_poisoned_sandbox_still_mints_and_answers() {
let sb = sandbox(0);
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _held = sb.state.lock();
panic!("a panic while the iron is held");
}));
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _held = sb.calls.lock();
panic!("a panic while the call log is held");
}));
assert!(sb.state.is_poisoned() && sb.calls.is_poisoned());
let out = sb
.ensure(REF, &disk(110))
.expect("a purchase after the panic");
let (id, _) = provisioned(&out);
assert_eq!(sb.live_gib(), 110);
assert_eq!(sb.resources().len(), 1);
assert!(!sb.calls().is_empty());
assert!(crate::ask::destroy_one(&sb, &id).is_ok());
}