crates/monetize-cloud/src/cap/tests.rs
//! Red-first for the spend cap. The assertions that matter are the ones about
//! money NOT moving: a cap is only worth having if it refuses, and only safe if
//! it refuses for a reason an operator can act on.
use super::*;
use crate::{ResourceId, ResourceStatus};
use monetize_pay::Currency;
use std::sync::atomic::{AtomicU64, Ordering};
/// A provider written here rather than reused: `monetize::testing::MockCloud`
/// lives in a crate that DEPENDS on this one, so using it would be a cycle. It is
/// also the wrong tool — this needs to count calls and be told to answer OnHand,
/// which is about the CAP's behaviour, not a provider's.
#[derive(Debug, Default)]
struct FakeCloud {
/// Price per GiB, minor units per month.
per_gib: u64,
on_hand: bool,
ensure_calls: Mutex<Vec<String>>,
destroy_calls: AtomicU64,
zones: Vec<Zone>,
/// `change_plan` and `set_power` as they reached the provider.
plan_calls: Mutex<Vec<String>>,
/// It lays an estate (three toy servers) instead of refusing to.
lays: bool,
}
impl FakeCloud {
fn new() -> Self {
FakeCloud {
per_gib: 100,
on_hand: false,
zones: vec![Zone("se-sto1".into())],
..Default::default()
}
}
fn always_on_hand(mut self) -> Self {
self.on_hand = true;
self
}
fn laying(mut self) -> Self {
self.lays = true;
self
}
fn ensure_calls_for(&self, reference: &str) -> usize {
self.ensure_calls
.lock()
.unwrap()
.iter()
.filter(|r| *r == reference)
.count()
}
fn destroy_calls(&self) -> u64 {
self.destroy_calls.load(Ordering::SeqCst)
}
}
#[allow(dead_code)]
impl FakeCloud {
pub fn provider(&self) -> &'static str {
"fake"
}
pub fn zones(&self) -> &[Zone] {
&self.zones
}
pub fn price_resource(&self, resource: &Resource) -> Result<Price, CloudError> {
let gib = match resource {
Resource::Disk { gib, .. } => *gib,
_ => 1,
};
Ok(Price {
minor_per_month: self.per_gib * gib,
currency: Currency::Sek,
})
}
pub fn ensure(&self, reference: &str, resource: &Resource) -> Result<Ensured, CloudError> {
self.ensure_calls.lock().unwrap().push(reference.to_owned());
if self.on_hand {
return Ok(Ensured::OnHand {
pool_free_after: 999,
});
}
Ok(Ensured::Provisioned {
id: ResourceId {
provider: "fake".into(),
id: format!("storage/{reference}"),
},
cost: self.price_resource(resource)?,
})
}
pub fn destroy_id(&self, _id: &ResourceId) -> Result<(), CloudError> {
self.destroy_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
pub fn status(&self, _id: &ResourceId) -> Result<ResourceStatus, CloudError> {
Ok(ResourceStatus::Active)
}
/// One owned disk per reference ever ensured — a list only the inner can
/// produce, which is what the passthrough test reads back through the cap.
pub fn list_owned(&self) -> Result<crate::Inventory, CloudError> {
let owned = self
.ensure_calls
.lock()
.unwrap()
.iter()
.map(|r| crate::Owned {
id: ResourceId {
provider: "fake".into(),
id: format!("storage/{r}"),
},
kind: crate::OwnedKind::Disk,
gib: 1,
zone: self.zones[0].0.clone(),
attached_to: None,
reference: Some(r.clone()),
monthly_minor: Some(self.per_gib),
currency: Currency::Sek,
provenance: "fake".into(),
resource: None,
backup: None,
})
.collect();
Ok(crate::Inventory {
owned,
foreign: 7,
currency: Currency::Sek,
})
}
pub fn set_power(&self, server: &ResourceId, on: bool) -> Result<(), CloudError> {
self.plan_calls
.lock()
.unwrap()
.push(format!("power {} {on}", server.id));
Ok(())
}
pub fn change_plan(
&self,
server: &ResourceId,
plan: &str,
_delta: Price,
_journal: &crate::grow::Journal,
) -> Result<(), CloudError> {
self.plan_calls
.lock()
.unwrap()
.push(format!("plan {} {plan}", server.id));
Ok(())
}
pub fn lay_estate(
&self,
plan: &crate::lay::EstatePlan,
_journal: &crate::grow::Journal,
) -> Result<crate::lay::LaidEstate, CloudError> {
if !self.lays {
return Err(crate::lay::unsupported(self.provider()));
}
let machines = plan
.machines
.iter()
.map(|m| crate::lay::LaidMachine {
role: m.role.clone(),
hostname: m.hostname.clone(),
title: m.title.clone(),
plan: m.plan.clone(),
server: format!("{}:{}", plan.reference, m.role),
public_ipv4: "192.0.2.1".into(),
utility_ipv4: "10.0.0.1".into(),
disks: vec![],
medium_clone: String::new(),
})
.collect();
Ok(crate::lay::LaidEstate {
reference: plan.reference.clone(),
company: plan.company.clone(),
zone: plan.zone.clone(),
machines,
narrate_from: plan.narrate_from.clone(),
shape: plan.shape.clone(),
})
}
pub fn release_for(&self, _references: &[String]) -> Result<crate::Reclaimed, CloudError> {
Ok(crate::Reclaimed::default())
}
}
#[allow(unused_variables, unreachable_code)]
impl crate::cloud::Cloud for FakeCloud {
fn manifest(&self) -> crate::cloud::CloudManifest {
crate::cloud::CloudManifest {
provider: self.provider().to_string(),
zones: self.zones().to_vec(),
settings: Vec::new(),
grow_refused: Some(crate::grow::unsupported(self.provider()).to_string()),
}
}
fn catalog(&self, zone: &crate::Zone) -> Result<crate::cloud::Catalog, crate::CloudError> {
Err(crate::lay::unsupported(self.provider()))
}
fn price(&self, what: &crate::cloud::Priced<'_>) -> Result<crate::Price, crate::CloudError> {
match what {
crate::cloud::Priced::Resource(r) => self.price_resource(r),
crate::cloud::Priced::Plan(p) => {
crate::price_estate_by_rows(p, |r| self.price_resource(r), None)
}
crate::cloud::Priced::Grow(set) => Err(crate::grow::unsupported(self.provider())),
}
}
fn lay(
&self,
plan: &crate::lay::EstatePlan,
journal: &crate::grow::Journal,
) -> Result<crate::lay::LaidEstate, crate::CloudError> {
self.lay_estate(plan, journal)
}
fn change(
&self,
change: &crate::cloud::Change,
journal: &crate::grow::Journal,
) -> Result<crate::cloud::Changed, crate::CloudError> {
Ok(match change {
crate::cloud::Change::Plan {
server,
plan,
delta,
} => {
self.change_plan(server, plan, *delta, journal)?;
crate::cloud::Changed::Done
}
crate::cloud::Change::Grow { set, estate } => {
return Err(crate::grow::unsupported(self.provider()))
}
crate::cloud::Change::Settle { set } => {
return Err(crate::grow::unsupported(self.provider()))
}
crate::cloud::Change::ForgetMedia { laid } => {
crate::cloud::Changed::Forgotten(Vec::new())
}
crate::cloud::Change::Ensure {
reference,
resource,
room,
} => crate::cloud::Changed::Ensured(self.ensure(reference, resource)?),
crate::cloud::Change::Retire { reference, machine } => crate::cloud::Changed::Retired(
crate::cloud::retire_by_rows(self, reference, machine)?,
),
crate::cloud::Change::LabelAdopted { .. } => {
return Err(crate::lay::unsupported_verb(
self.provider(),
"label_adopted",
))
}
})
}
fn boot(
&self,
boot: &crate::cloud::Boot,
journal: &crate::grow::Journal,
) -> Result<crate::cloud::Booted, crate::CloudError> {
Ok(match boot {
crate::cloud::Boot::Keep(k) => {
return Err(crate::lay::unsupported_verb(self.provider(), "keep_boot"))
}
crate::cloud::Boot::Power { server, on } => {
self.set_power(server, *on)?;
crate::cloud::Booted::Powered
}
crate::cloud::Boot::Restore(r) => {
return Err(crate::lay::unsupported_verb(
self.provider(),
"restore_from_backup",
))
}
})
}
fn firewall(&self, laid: &crate::lay::LaidEstate) -> Result<(), crate::CloudError> {
Err(crate::lay::unsupported_verb(
self.provider(),
"set_estate_firewall",
))
}
fn destroy(
&self,
what: &crate::cloud::Give<'_>,
) -> Result<crate::Reclaimed, crate::CloudError> {
match what {
crate::cloud::Give::References(refs) => self.release_for(refs),
crate::cloud::Give::Resource(id) => {
self.destroy_id(id)?;
Ok(crate::Reclaimed {
destroyed: vec![(*id).clone()],
..crate::Reclaimed::default()
})
}
}
}
fn inventory(
&self,
what: &crate::cloud::Listing<'_>,
) -> Result<crate::cloud::Listed, crate::CloudError> {
Ok(match what {
crate::cloud::Listing::Account => crate::cloud::Listed::Account(self.list_owned()?),
crate::cloud::Listing::Set(set) => {
return Err(crate::grow::unsupported(self.provider()))
}
crate::cloud::Listing::Status(id) => crate::cloud::Listed::Status(self.status(id)?),
crate::cloud::Listing::Under(refs) => {
crate::cloud::Listed::Under(crate::cloud::under(self.list_owned()?, refs))
}
crate::cloud::Listing::Room { delta, room } => {
crate::cloud::Listed::Room(crate::cloud::RoomLeft {
serve: Ok(()),
left_bytes: None,
ceilings: Default::default(),
})
}
crate::cloud::Listing::Admits(_plan) => crate::cloud::Listed::Admitted,
})
}
}
/// A MONTHLY ceiling in SEK — what most of these tests exercise.
fn sek(minor: u64) -> Ceiling {
Ceiling::monthly(minor, Currency::Sek)
}
/// A TOTAL (lifetime) ceiling in SEK.
fn total(minor: u64) -> Ceiling {
Ceiling::total(minor, Currency::Sek)
}
fn disk(gib: u64) -> Resource {
Resource::Disk {
server: crate::ServerId("gunnar-se-sto1".into()),
gib,
}
}
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("monetize-cap-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d.join("spent.json")
}
/// `MockCloud` prices every resource; this is what it charges per GiB so the
/// arithmetic below is not guesswork.
fn price_of(c: &FakeCloud, r: &Resource) -> u64 {
crate::Cloud::price(&c, &crate::cloud::Priced::Resource(r))
.unwrap()
.minor_per_month
}
#[test]
fn a_purchase_inside_the_ceiling_goes_through_and_is_counted() {
let inner = FakeCloud::new();
let one = price_of(&inner, &disk(10));
let cap = SpendCap::new(inner, sek(one * 2), tmp("under"))
.unwrap()
.with_month(|| "2026-09".into());
assert_eq!(cap.spent_this_month(), 0);
assert_eq!(cap.remaining(), one * 2);
let first = crate::ask::ensure(&cap, "gunnar/a/2027", &disk(10), None).unwrap();
assert!(matches!(first, Ensured::Provisioned { .. }), "{first:?}");
assert_eq!(cap.spent_this_month(), one, "a purchase is committed");
assert_eq!(cap.remaining(), one);
}
#[test]
fn refuse_twin_the_purchase_that_would_cross_the_ceiling_never_reaches_the_provider() {
let inner = FakeCloud::new();
let one = price_of(&inner, &disk(10));
// Room for exactly one.
let cap = SpendCap::new(inner, sek(one), tmp("cross"))
.unwrap()
.with_month(|| "2026-09".into());
crate::ask::ensure(&cap, "gunnar/a/2027", &disk(10), None).unwrap();
let before = cap.spent_this_month();
let err = crate::ask::ensure(&cap, "gunnar/b/2027", &disk(10), None)
.expect_err("the second must be refused");
let msg = err.to_string();
// The message must carry all three numbers and a way out, or an operator
// cannot act on it.
assert!(
msg.contains(
&monetize_pay::Money {
minor: one,
currency: Currency::Sek
}
.in_words()
),
"the price, in words: {msg}"
);
assert!(msg.contains("the cap is"), "{msg}");
assert!(
msg.contains("OnHand") || msg.contains("destroy"),
"a way out: {msg}"
);
// And nothing moved.
assert_eq!(
cap.spent_this_month(),
before,
"a refusal must not commit spend"
);
assert_eq!(
cap.inner.ensure_calls_for("gunnar/b/2027"),
0,
"the provider was never asked"
);
}
#[test]
fn on_hand_costs_nothing_so_it_must_not_consume_the_ceiling() {
// A provider that always answers OnHand: nothing is ever bought.
let inner = FakeCloud::new().always_on_hand();
let one = price_of(&inner, &disk(10));
let cap = SpendCap::new(inner, sek(one * 3), tmp("onhand"))
.unwrap()
.with_month(|| "2026-09".into());
for i in 0..3 {
let out = crate::ask::ensure(&cap, &format!("gunnar/{i}/2027"), &disk(10), None).unwrap();
assert!(matches!(out, Ensured::OnHand { .. }), "{out:?}");
}
assert_eq!(
cap.spent_this_month(),
0,
"OnHand bought nothing, so the month owes nothing"
);
assert_eq!(cap.remaining(), one * 3);
}
#[test]
fn refuse_twin_a_zero_ceiling_refuses_every_purchase_and_is_not_read_as_unlimited() {
let inner = FakeCloud::new();
let cap = SpendCap::new(inner, sek(0), tmp("zero"))
.unwrap()
.with_month(|| "2026-09".into());
let err =
crate::ask::ensure(&cap, "gunnar/a/2027", &disk(10), None).expect_err("zero means zero");
assert!(err.to_string().contains("the cap is 0.00 SEK"), "{err}");
assert_eq!(cap.spent_this_month(), 0);
// The failure mode this guards: a misconfigured cap that opens itself.
assert_eq!(cap.remaining(), 0);
}
#[test]
fn refuse_twin_a_price_in_another_currency_is_refused_not_converted() {
let inner = FakeCloud::new();
let one = price_of(&inner, &disk(10));
// MockCloud prices in Sek; set the ceiling in Eur.
let cap = SpendCap::new(inner, Ceiling::monthly(one * 10, Currency::Eur), tmp("fx"))
.unwrap()
.with_month(|| "2026-09".into());
let err = crate::ask::ensure(&cap, "gunnar/a/2027", &disk(10), None)
.expect_err("no FX inside a spending guard");
assert!(err.to_string().contains("conversion"), "{err}");
assert_eq!(cap.spent_this_month(), 0);
}
#[test]
fn the_total_survives_a_restart_and_resets_on_a_new_month() {
let path = tmp("months");
let one = price_of(&FakeCloud::new(), &disk(10));
// September buys one.
{
let cap = SpendCap::new(FakeCloud::new(), sek(one), path.clone())
.unwrap()
.with_month(|| "2026-09".into());
crate::ask::ensure(&cap, "gunnar/a/2027", &disk(10), None).unwrap();
assert_eq!(cap.spent_this_month(), one);
}
// A NEW PROCESS in the same month still sees the commitment — the whole
// reason the total is on disk rather than in memory.
{
let cap = SpendCap::new(FakeCloud::new(), sek(one), path.clone())
.unwrap()
.with_month(|| "2026-09".into());
assert_eq!(
cap.spent_this_month(),
one,
"a restart must not clear the month"
);
assert!(
crate::ask::ensure(&cap, "gunnar/b/2027", &disk(10), None).is_err(),
"still at the ceiling after a restart"
);
}
// October starts from zero.
{
let cap = SpendCap::new(FakeCloud::new(), sek(one), path.clone())
.unwrap()
.with_month(|| "2026-10".into());
assert_eq!(cap.spent_this_month(), 0, "a new month starts fresh");
assert!(crate::ask::ensure(&cap, "gunnar/c/2027", &disk(10), None).is_ok());
}
}
#[test]
fn destroy_is_never_capped_because_it_is_the_call_that_lowers_the_bill() {
let inner = FakeCloud::new();
let cap = SpendCap::new(inner, sek(0), tmp("destroy"))
.unwrap()
.with_month(|| "2026-09".into());
// At a zero ceiling — the most locked-down state there is — giving something
// back must still work, or a deployment is trapped with no way down.
let id = ResourceId {
provider: "mockcloud".into(),
id: "storage/1".into(),
};
let _ = crate::ask::destroy_one(&cap, &id);
assert!(
cap.inner.destroy_calls() >= 1,
"destroy must reach the provider even at a zero ceiling"
);
}
/// **The rehearsal's actual cost story, in code.**
///
/// The numbers are MEASURED off `GET /1.3/price` (zone se-sto1, 2026-09-04), not
/// chosen: one UpCloud credit is one euro cent, monetize converts at 730 h/month,
/// so `storage_standard` at 0.0118 credits/h/GiB is 8.6 -> **9 cents per GiB per
/// month**. The rehearsal catalogue buys exactly 1 GiB per plan.
///
/// Against a EUR 10.00 ceiling that is 111 provisions in a month, and the 112th
/// is refused. If that arithmetic ever changes — a price move, a bigger disk in
/// the plans file, a tier switch to maxiops — this test says so instead of an
/// invoice saying it.
#[test]
fn a_ten_euro_ceiling_buys_111_one_gib_disks_and_refuses_the_112th() {
const CENTS_PER_GIB_MONTH: u64 = 9; // storage_standard, se-sto1, measured
const CEILING_CENTS: u64 = 1000; // EUR 10.00
let mut inner = FakeCloud::new();
inner.per_gib = CENTS_PER_GIB_MONTH;
let cap = SpendCap::new(
inner,
Ceiling::monthly(CEILING_CENTS, Currency::Sek),
tmp("tenner"),
)
.unwrap()
.with_month(|| "2026-09".into());
let affordable = CEILING_CENTS / CENTS_PER_GIB_MONTH;
assert_eq!(
affordable, 111,
"if this changes, the rehearsal's cost story changed"
);
for i in 0..affordable {
crate::ask::ensure(&cap, &format!("gunnar/t{i}/2026-09-04"), &disk(1), None)
.unwrap_or_else(|e| panic!("provision {i} of {affordable} must fit: {e}"));
}
assert_eq!(cap.spent_this_month(), affordable * CENTS_PER_GIB_MONTH);
assert!(
cap.remaining() < CENTS_PER_GIB_MONTH,
"less than one more disk of room"
);
let err = crate::ask::ensure(&cap, "gunnar/one-too-many/2026-09-04", &disk(1), None)
.expect_err("the 112th must be refused");
assert!(err.to_string().contains("the cap is"), "{err}");
// AND the shape that matters most: one resource far over the ceiling is
// refused outright, whatever the month has already spent. A 2xCPU-4GB box is
// EUR 30.96/month at the same measured prices — 3096 cents against 1000.
let fresh = SpendCap::new(
FakeCloud::new(),
Ceiling::monthly(CEILING_CENTS, Currency::Sek),
tmp("big"),
)
.unwrap()
.with_month(|| "2026-09".into());
let a_whole_box = Resource::Disk {
server: crate::ServerId("x".into()),
gib: 3096 / 100 * 4,
};
assert!(
crate::ask::ensure(&fresh, "gunnar/t/2026-09-04", &a_whole_box, None).is_err(),
"a resource over the ceiling can never be bought, even first"
);
assert_eq!(fresh.spent_this_month(), 0);
}
#[test]
fn the_month_boundary_is_computed_by_integer_arithmetic_not_guessed() {
// 2026-09-04T00:00:00Z and the instants either side of a month boundary.
assert_eq!(year_month(1_788_480_000), (2026, 9));
// 2026-01-01T00:00:00Z
assert_eq!(year_month(1_767_225_600), (2026, 1));
// 2025-12-31T23:59:59Z — the year rollover, the one this kind of code fails on.
assert_eq!(year_month(1_767_225_599), (2025, 12));
// The epoch itself.
assert_eq!(year_month(0), (1970, 1));
// A leap day: 2024-02-29T12:00:00Z
assert_eq!(year_month(1_709_208_000), (2024, 2));
// And the formatted form zero-pads, or a string compare sorts wrongly.
assert_eq!(format!("{:04}-{:02}", 2026, 9), "2026-09");
}
/// **A TOTAL ceiling does not reset**, which is the whole difference from a
/// monthly one and the reason a rehearsal wants it.
///
/// A LIFETIME EUR 10 is what "cap it at ten euros" means about a test budget: a
/// monthly EUR 10 permits EUR 10 again in October and EUR 120 over a year.
#[test]
fn a_total_ceiling_survives_the_turn_of_the_month() {
let path = tmp("lifetime");
let one = price_of(&FakeCloud::new(), &disk(10));
let month = std::sync::Arc::new(std::sync::Mutex::new("2026-09".to_string()));
let m = month.clone();
let cap = SpendCap::new(FakeCloud::new(), total(one * 2), path.clone())
.unwrap()
.with_month(move || m.lock().unwrap().clone());
crate::ask::ensure(&cap, "gunnar/a/2027", &disk(10), None).unwrap();
crate::ask::ensure(&cap, "gunnar/b/2027", &disk(10), None).unwrap();
assert_eq!(cap.spent_total(), one * 2);
assert!(
crate::ask::ensure(&cap, "gunnar/c/2027", &disk(10), None).is_err(),
"the total is reached"
);
// THE TURN OF THE MONTH. A monthly ceiling would open here; a total must not.
*month.lock().unwrap() = "2026-10".to_string();
assert_eq!(cap.spent_this_month(), 0, "the monthly WINDOW did reset");
assert_eq!(cap.spent_total(), one * 2, "but the lifetime total did not");
let err = crate::ask::ensure(&cap, "gunnar/c/2027", &disk(10), None)
.expect_err("a new month must not reopen a total ceiling");
assert!(
err.to_string().contains("in total"),
"the message must name which limit: {err}"
);
}
/// The same property, but proven THROUGH a purchase that crosses the boundary.
///
/// The test above was blind on its own and a deliberate red found it: the
/// month-rollover reset happens when a purchase is RECORDED, which is after the
/// pre-check, so a cap that wrongly zeroed the lifetime total on rollover still
/// refused the next call and looked correct. Only a purchase that SUCCEEDS in
/// the new month exposes it.
#[test]
fn a_purchase_in_a_new_month_adds_to_the_lifetime_total_it_does_not_restart_it() {
let one = price_of(&FakeCloud::new(), &disk(10));
let month = std::sync::Arc::new(std::sync::Mutex::new("2026-09".to_string()));
let m = month.clone();
// Room for three in total, so the October purchase is affordable and the
// fourth is not — which is only true if the total carried over.
let cap = SpendCap::new(FakeCloud::new(), total(one * 3), tmp("carryover"))
.unwrap()
.with_month(move || m.lock().unwrap().clone());
crate::ask::ensure(&cap, "gunnar/a/2027", &disk(10), None).unwrap();
crate::ask::ensure(&cap, "gunnar/b/2027", &disk(10), None).unwrap();
*month.lock().unwrap() = "2026-10".to_string();
crate::ask::ensure(&cap, "gunnar/c/2027", &disk(10), None)
.expect("the third fits within the lifetime total");
assert_eq!(
cap.spent_this_month(),
one,
"October's window holds only October's purchase"
);
assert_eq!(
cap.spent_total(),
one * 3,
"the lifetime total carried September over"
);
let err = crate::ask::ensure(&cap, "gunnar/d/2027", &disk(10), None)
.expect_err("the lifetime total is now spent");
assert!(err.to_string().contains("in total"), "{err}");
}
#[test]
fn both_limits_hold_at_once_and_the_tighter_one_speaks() {
let one = price_of(&FakeCloud::new(), &disk(10));
let cap = SpendCap::new(
FakeCloud::new(),
Ceiling {
monthly_minor: Some(one),
total_minor: Some(one * 5),
currency: Currency::Sek,
},
tmp("both"),
)
.unwrap()
.with_month(|| "2026-09".into());
assert_eq!(
cap.remaining(),
one,
"the MONTHLY limit is tighter, so it is what remains"
);
crate::ask::ensure(&cap, "gunnar/a/2027", &disk(10), None).unwrap();
let err =
crate::ask::ensure(&cap, "gunnar/b/2027", &disk(10), None).expect_err("the month is full");
assert!(err.to_string().contains("this month"), "{err}");
assert_eq!(cap.spent_total(), one, "and the total counted it too");
}
/// **A ceiling that limits nothing cannot be constructed.** A guard that never
/// refuses is worse than no guard, because it reads like protection.
#[test]
fn refuse_twin_a_ceiling_with_no_limits_is_refused_at_construction() {
let none = Ceiling {
monthly_minor: None,
total_minor: None,
currency: Currency::Sek,
};
assert!(none.check().is_err());
let err = SpendCap::new(FakeCloud::new(), none, tmp("nolimit")).expect_err("must refuse");
assert!(err.to_string().contains("limits nothing"), "{err}");
// And zero still means zero, not unlimited, in EITHER slot.
assert!(SpendCap::new(FakeCloud::new(), total(0), tmp("zerototal")).is_ok());
let z = SpendCap::new(FakeCloud::new(), total(0), tmp("zerototal2"))
.unwrap()
.with_month(|| "2026-09".into());
assert!(
crate::ask::ensure(&z, "gunnar/a/2027", &disk(1), None).is_err(),
"a zero total refuses every purchase"
);
}
/// **The stored file is a CACHE; the ledger is the truth.** This is the answer to
/// "how do we persist that and make sure nothing is lost": the total ever
/// committed is recomputable from the caller's append-only ledger, so a lost or
/// stale cache cannot quietly raise the budget.
#[test]
fn a_lost_cache_is_corrected_from_the_authoritative_total_and_only_upward() {
let path = tmp("reconcile");
let one = price_of(&FakeCloud::new(), &disk(10));
let cap = SpendCap::new(FakeCloud::new(), total(one * 3), path.clone())
.unwrap()
.with_month(|| "2026-09".into());
crate::ask::ensure(&cap, "gunnar/a/2027", &disk(10), None).unwrap();
assert_eq!(cap.spent_total(), one);
// The disk dies and the cache comes back EMPTY — the state this guards.
std::fs::remove_file(&path).unwrap();
let fresh = SpendCap::new(FakeCloud::new(), total(one * 3), path.clone())
.unwrap()
.with_month(|| "2026-09".into());
assert_eq!(fresh.spent_total(), 0, "the cache really was lost");
// The caller folds its ledger and says what was actually committed.
fresh.reconcile_total(one * 3).unwrap();
assert_eq!(fresh.spent_total(), one * 3);
assert!(
crate::ask::ensure(&fresh, "gunnar/b/2027", &disk(10), None).is_err(),
"the budget is honoured again after a restore"
);
// UPWARD ONLY: a smaller authoritative number must not talk the cap down.
// Under-counting spend is the only direction that costs money.
fresh.reconcile_total(0).unwrap();
assert_eq!(
fresh.spent_total(),
one * 3,
"a lower figure must never lower the committed total"
);
// And it survives the next restart, because reconciling WROTE it.
let again = SpendCap::new(FakeCloud::new(), total(one * 3), path)
.unwrap()
.with_month(|| "2026-09".into());
assert_eq!(
again.spent_total(),
one * 3,
"the correction was persisted, not just held"
);
}
/// **The cap passes `inventory()` through unchanged.** Only the inner can produce
/// the list (one row per reference it was asked to ensure, `foreign: 7`), so a
/// list read through the cap that matches it is the inner's, not a default.
#[test]
fn inventory_passes_through_the_cap_unchanged() {
let fake = FakeCloud::new();
let per_gib = fake.per_gib;
let cap = SpendCap::new(fake, sek(100_000), tmp("inventory")).unwrap();
assert_eq!(
crate::ask::account(&cap).unwrap(),
crate::Inventory {
owned: vec![],
foreign: 7,
currency: Currency::Sek
},
"the inner's foreign count and currency, not a default"
);
crate::ask::ensure(&cap, "gunnar/a/2026-09", &disk(1), None).unwrap();
crate::ask::ensure(&cap, "gunnar/b/2026-09", &disk(1), None).unwrap();
let inv = crate::ask::account(&cap).unwrap();
assert_eq!(inv.foreign, 7);
assert_eq!(
inv.owned
.iter()
.map(|o| (
o.id.id.as_str(),
o.reference.as_deref(),
o.provenance.as_str()
))
.collect::<Vec<_>>(),
vec![
("storage/gunnar/a/2026-09", Some("gunnar/a/2026-09"), "fake"),
("storage/gunnar/b/2026-09", Some("gunnar/b/2026-09"), "fake")
]
);
assert_eq!(
inv.owned[0].monthly_minor,
Some(per_gib),
"the inner's price, untouched"
);
assert_eq!(
(inv.owned[0].currency, inv.currency),
(Currency::Sek, Currency::Sek),
"and its currency, untouched"
);
}
/// The console asks the PROVIDER SEAM for the month's spend, never the cap's
/// type: a bare provider must answer "no window" and a cap must answer the same
/// figure `ensure` refuses on. Red first: the seam did not exist.
#[test]
fn the_month_window_is_read_from_the_shared_ledger_the_host_holds() {
let inner = FakeCloud::new();
let one = price_of(&inner, &disk(10));
let cap = SpendCap::new(inner, sek(one * 3), tmp("seam"))
.unwrap()
.with_month(|| "2026-09".into());
assert_eq!(cap.cap().spent_this_month(), 0);
crate::ask::ensure(&cap, "gunnar/a/2027", &disk(10), None).unwrap();
assert_eq!(
cap.cap().spent_this_month(),
one,
"a purchase moves the window"
);
assert_eq!(
cap.cap().spent_this_month(),
cap.spent_this_month(),
"the shared ledger and the cap's own method agree"
);
// Through an `Arc`, as the server holds it.
let shared = std::sync::Arc::new(cap);
assert_eq!(shared.cap().spent_this_month(), one);
}
/// **`CannotSpend` holds a provider to its promise.** A fake that DOES buy,
/// wrapped as one that cannot: the purchase is destroyed on the spot (the
/// fake's destroy count moves), the call is refused naming the provider and the
/// resource, and there is no ceiling to report. The twin: the same fake told to
/// answer OnHand passes straight through.
#[test]
fn cannot_spend_destroys_and_refuses_a_provisioned_answer_and_passes_on_hand_through() {
let buys = CannotSpend::new(FakeCloud::new());
let err = crate::ask::ensure(&buys, "gunnar/alice/2026-09", &disk(10), None)
.err()
.map(|e| e.to_string())
.expect("a purchase must be refused");
assert!(
err.contains("fake is registered as a provider that cannot spend")
&& err.contains("storage/gunnar/alice/2026-09"),
"{err}"
);
assert!(err.contains("destroyed it"), "{err}");
assert_eq!(
buys.inner().destroy_calls(),
1,
"the byte was given back before the refusal"
);
assert_eq!(buys.inner().ensure_calls_for("gunnar/alice/2026-09"), 1);
assert_eq!(
Cloud::price(&buys, &crate::cloud::Priced::Resource(&disk(10)))
.unwrap()
.minor_per_month,
1000,
"price forwards"
);
let honest = CannotSpend::new(FakeCloud::new().always_on_hand());
assert_eq!(
crate::ask::ensure(&honest, "gunnar/alice/2026-09", &disk(10), None).unwrap(),
Ensured::OnHand {
pool_free_after: 999
}
);
assert_eq!(honest.inner().destroy_calls(), 0);
assert_eq!(
crate::ask::account(&honest).unwrap().owned.len(),
1,
"inventory forwards"
);
assert_eq!(
crate::ask::status(
&honest,
&ResourceId {
provider: "fake".into(),
id: "x".into()
}
)
.unwrap(),
ResourceStatus::Active
);
}
/// **A quote against the ceiling is the purchase's admission without the
/// purchase**: `can_serve` refuses what `ensure` would refuse, prices the delta
/// as a WHOLE, and commits nothing. Then the same delta bought really does move
/// the total — so the two cannot drift.
#[test]
fn can_serve_prices_the_whole_delta_against_the_ceiling_and_commits_nothing() {
let inner = FakeCloud::new();
let one = price_of(&inner, &disk(10));
let cap = SpendCap::new(inner, sek(one * 2), tmp("quote"))
.unwrap()
.with_month(|| "2026-09".into());
assert_eq!(crate::ask::can_serve(&cap, &[disk(10)], None), Ok(()));
assert_eq!(
crate::ask::can_serve(&cap, &[disk(10), disk(10)], None),
Ok(()),
"exactly the ceiling fits"
);
let err = crate::ask::can_serve(&cap, &[disk(10), disk(10), disk(10)], None).unwrap_err();
assert!(
err.contains("spend cap refuses")
&& err.contains(&format!(
"cost {}",
monetize_pay::Money {
minor: one * 3,
currency: Currency::Sek
}
.in_words()
)),
"three that each fit and together do not: {err}"
);
assert_eq!(
(cap.spent_this_month(), cap.spent_total()),
(0, 0),
"a quote commits nothing"
);
assert!(
!tmp("quote").exists()
|| std::fs::read_to_string(tmp("quote"))
.map(|s| !s.contains(&one.to_string()))
.unwrap_or(true),
"and writes nothing"
);
// Buying moves what the quote said it would, and the next quote sees it.
crate::ask::ensure(&cap, "gunnar/a/2027", &disk(10), None).unwrap();
assert_eq!(cap.spent_total(), one);
assert_eq!(crate::ask::can_serve(&cap, &[disk(10)], None), Ok(()));
assert!(
crate::ask::can_serve(&cap, &[disk(10), disk(10)], None).is_err(),
"one left, two asked"
);
// A currency the cap is not set in is refused by the quote as by the purchase.
struct EurCloud(FakeCloud);
#[allow(dead_code)]
impl EurCloud {
pub fn provider(&self) -> &'static str {
"eur"
}
pub fn zones(&self) -> &[Zone] {
self.0.zones()
}
pub fn price_resource(&self, r: &Resource) -> Result<Price, CloudError> {
Ok(Price {
currency: Currency::Eur,
..crate::Cloud::price(&self.0, &crate::cloud::Priced::Resource(r))?
})
}
pub fn ensure(&self, reference: &str, resource: &Resource) -> Result<Ensured, CloudError> {
self.0.ensure(reference, resource)
}
pub fn destroy_id(&self, id: &ResourceId) -> Result<(), CloudError> {
crate::ask::destroy_one(&self.0, id)
}
pub fn status(&self, id: &ResourceId) -> Result<ResourceStatus, CloudError> {
self.0.status(id)
}
pub fn list_owned(&self) -> Result<crate::Inventory, CloudError> {
crate::ask::account(&self.0)
}
}
#[allow(unused_variables, unreachable_code)]
impl crate::cloud::Cloud for EurCloud {
fn manifest(&self) -> crate::cloud::CloudManifest {
crate::cloud::CloudManifest {
provider: self.provider().to_string(),
zones: self.zones().to_vec(),
settings: Vec::new(),
grow_refused: Some(crate::grow::unsupported(self.provider()).to_string()),
}
}
fn catalog(&self, zone: &crate::Zone) -> Result<crate::cloud::Catalog, crate::CloudError> {
Err(crate::lay::unsupported(self.provider()))
}
fn price(
&self,
what: &crate::cloud::Priced<'_>,
) -> Result<crate::Price, crate::CloudError> {
match what {
crate::cloud::Priced::Resource(r) => self.price_resource(r),
crate::cloud::Priced::Plan(p) => {
crate::price_estate_by_rows(p, |r| self.price_resource(r), None)
}
crate::cloud::Priced::Grow(set) => Err(crate::grow::unsupported(self.provider())),
}
}
fn lay(
&self,
plan: &crate::lay::EstatePlan,
journal: &crate::grow::Journal,
) -> Result<crate::lay::LaidEstate, crate::CloudError> {
Err(crate::lay::unsupported(self.provider()))
}
fn change(
&self,
change: &crate::cloud::Change,
journal: &crate::grow::Journal,
) -> Result<crate::cloud::Changed, crate::CloudError> {
Ok(match change {
crate::cloud::Change::Plan {
server,
plan,
delta,
} => return Err(crate::lay::unsupported_verb(self.provider(), "change_plan")),
crate::cloud::Change::Grow { set, estate } => {
return Err(crate::grow::unsupported(self.provider()))
}
crate::cloud::Change::Settle { set } => {
return Err(crate::grow::unsupported(self.provider()))
}
crate::cloud::Change::ForgetMedia { laid } => {
crate::cloud::Changed::Forgotten(Vec::new())
}
crate::cloud::Change::Ensure {
reference,
resource,
room,
} => crate::cloud::Changed::Ensured(self.ensure(reference, resource)?),
crate::cloud::Change::Retire { reference, machine } => {
crate::cloud::Changed::Retired(crate::cloud::retire_by_rows(
self, reference, machine,
)?)
}
crate::cloud::Change::LabelAdopted { .. } => {
return Err(crate::lay::unsupported_verb(
self.provider(),
"label_adopted",
))
}
})
}
fn boot(
&self,
boot: &crate::cloud::Boot,
journal: &crate::grow::Journal,
) -> Result<crate::cloud::Booted, crate::CloudError> {
Ok(match boot {
crate::cloud::Boot::Keep(k) => {
return Err(crate::lay::unsupported_verb(self.provider(), "keep_boot"))
}
crate::cloud::Boot::Power { server, on } => {
return Err(crate::lay::unsupported_verb(self.provider(), "set_power"))
}
crate::cloud::Boot::Restore(r) => {
return Err(crate::lay::unsupported_verb(
self.provider(),
"restore_from_backup",
))
}
})
}
fn firewall(&self, laid: &crate::lay::LaidEstate) -> Result<(), crate::CloudError> {
Err(crate::lay::unsupported_verb(
self.provider(),
"set_estate_firewall",
))
}
fn destroy(
&self,
what: &crate::cloud::Give<'_>,
) -> Result<crate::Reclaimed, crate::CloudError> {
match what {
crate::cloud::Give::References(refs) => {
Ok(crate::release_for_by_rows(self.list_owned()?, refs, |id| {
self.destroy_id(id)
}))
}
crate::cloud::Give::Resource(id) => {
self.destroy_id(id)?;
Ok(crate::Reclaimed {
destroyed: vec![(*id).clone()],
..crate::Reclaimed::default()
})
}
}
}
fn inventory(
&self,
what: &crate::cloud::Listing<'_>,
) -> Result<crate::cloud::Listed, crate::CloudError> {
Ok(match what {
crate::cloud::Listing::Account => crate::cloud::Listed::Account(self.list_owned()?),
crate::cloud::Listing::Set(set) => {
return Err(crate::grow::unsupported(self.provider()))
}
crate::cloud::Listing::Status(id) => crate::cloud::Listed::Status(self.status(id)?),
crate::cloud::Listing::Under(refs) => {
crate::cloud::Listed::Under(crate::cloud::under(self.list_owned()?, refs))
}
crate::cloud::Listing::Room { delta, room } => {
crate::cloud::Listed::Room(crate::cloud::RoomLeft {
serve: Ok(()),
left_bytes: None,
ceilings: Default::default(),
})
}
crate::cloud::Listing::Admits(_plan) => crate::cloud::Listed::Admitted,
})
}
}
let cap = SpendCap::new(EurCloud(FakeCloud::new()), sek(one * 2), tmp("quote-fx")).unwrap();
assert!(
crate::ask::can_serve(&cap, &[disk(1)], None)
.unwrap_err()
.contains("no conversion"),
"the quote refuses the fiction too"
);
// The CannotSpend guard passes the question through to the box.
let inner = FakeCloud::new().always_on_hand();
assert_eq!(
crate::ask::can_serve(&CannotSpend::new(inner), &[disk(1)], None),
Ok(())
);
assert!(crate::ask::ceilings(&CannotSpend::new(FakeCloud::new())).is_empty());
}
/// **A cap at its ceiling does not refuse a sale that buys nothing.** The
/// product measured the room (`Room::OnHand`), so there is no price to admit;
/// the same resource told `Room::Buy` is refused as before.
#[test]
fn on_hand_is_not_admitted_against_the_ceiling_and_buy_still_is() {
let cap = SpendCap::new(
FakeCloud::new().always_on_hand(),
total(0),
tmp("room-on-hand"),
)
.unwrap();
assert_eq!(
crate::ask::ensure(&cap, "gunnar/a/2026-09", &disk(10), Some(Room::OnHand)).unwrap(),
Ensured::OnHand {
pool_free_after: 999
}
);
assert!(
matches!(
crate::ask::ensure(&cap, "gunnar/a/2026-09", &disk(10), Some(Room::Buy)),
Err(CloudError::OverCap(_))
),
"a zero ceiling still refuses a buy"
);
assert_eq!(
cap.inner.ensure_calls_for("gunnar/a/2026-09"),
1,
"the refused buy never reached the provider"
);
}
/// **What a provider bought against `Room::OnHand` is still counted.** The
/// cache records what was spent, not what was asked; the transaction undoes it.
#[test]
fn on_hand_that_bought_anyway_is_accounted() {
let cap = SpendCap::new(FakeCloud::new(), total(10_000), tmp("room-bought-anyway")).unwrap();
assert!(matches!(
crate::ask::ensure(&cap, "gunnar/a/2026-09", &disk(10), Some(Room::OnHand)).unwrap(),
Ensured::Provisioned { .. }
));
assert_eq!(cap.spent_total(), 1_000);
}
// ── RULING S4: is `total_minor` a lifetime budget or a standing run-rate? ────
//
// OPEN on 2026-09-20, and deliberately NOT decided here. `Spent::total_minor`
// is documented "Committed EVER, across every month. Never reset"
// (`cap/mod.rs:65`) and all three commit sites add `cost.minor_per_month` — a
// monthly RATE — into it (`cap/mod.rs:336`, `:391`, `:428`). The two readings
// differ by a factor of twelve a year on the same estate, and the estate runs
// `cap = { total_minor = 2500, monthly_minor = 2500, currency = "Eur" }`.
//
// Neither the arithmetic nor the name is touched by these two tests. Exactly
// ONE of them survives the ruling: un-ignore the reading the owner picks and
// delete the other. Reading A passes against today's arithmetic; reading B is
// RED today, and that red is the size of the change the ruling would order.
//
// Verified against the rest of the system, which reads A at every point that
// recomputes the number: `Ledger::fold_provisioned`
// (`monetize/src/ledger.rs:706`, `:712`) adds each provision's and each
// growth's `minor_per_month` ONCE per event and has no time factor at all, and
// it is the AUTHORITY — `SpendCap::reconcile_total` (`cap/mod.rs:234`) and
// `monetize_server::grow::ceiling_remaining` (`grow.rs:491`) both take their
// total from that fold.
/// A month a test can move by hand, for the two ruling tests below.
fn month_dial(
start: &str,
) -> (
std::sync::Arc<Mutex<String>>,
impl Fn() -> String + Send + Sync + 'static,
) {
let dial = std::sync::Arc::new(Mutex::new(start.to_owned()));
let read = dial.clone();
(dial, move || read.lock().unwrap().clone())
}
/// **RULING S4 — MONTHLY, RESETTING (owner 2026-09-28, a-ceiling-s3).** The
/// ceiling an estate runs on is `monthly_minor`: what monetize may COMMIT in a
/// calendar month. Past it every purchase and growth is refused (by name, and
/// the grow batcher raises an ALERT); on the 1st it is free again. The two
/// readings of a lifetime `total_minor` that stood here `#[ignore]`d are gone
/// with the question: the kit emits `monthly_minor` only.
#[test]
fn ruling_s4_a_monthly_ceiling_refuses_past_it_this_month_and_resets_on_the_first() {
let inner = FakeCloud::new();
let one = price_of(&inner, &disk(10));
let (dial, month) = month_dial("2026-09");
let cap = SpendCap::new(
inner,
Ceiling::monthly(one, Currency::Sek),
tmp("s4-monthly"),
)
.unwrap()
.with_month(month);
crate::ask::ensure(&cap, "gunnar/s4/2026-09-01", &disk(10), None).unwrap();
assert_eq!(cap.remaining(), 0, "September is spent");
let refused = crate::ask::ensure(&cap, "gunnar/s4/2026-09-02", &disk(10), None)
.unwrap_err()
.to_string();
assert!(
refused.contains("this month"),
"refused past the ceiling, by name: {refused}"
);
*dial.lock().unwrap() = "2026-10".into();
assert_eq!(cap.remaining(), one, "October starts free");
crate::ask::ensure(&cap, "gunnar/s4/2026-10-01", &disk(10), None)
.expect("the ceiling reset on the 1st");
}
// ── growing a data set through the cap: the RESERVATION (T7) ─────────────────
mod grow_reservation {
use super::*;
use crate::grow::{DataSet, GrowOutcome, Journal, SetSurvey, VolumeId};
use std::sync::atomic::AtomicU64;
/// A provider that can grow: prices the delta per member, and answers
/// `grow_set` / `resume_grow` from a script. Counts what reached it.
#[derive(Default)]
struct GrowFake {
per_gib: u64,
current_gib: u64,
grow_answer: Mutex<Option<Result<GrowOutcome, CloudError>>>,
resume_answer: Mutex<Option<Result<GrowOutcome, CloudError>>>,
grow_calls: AtomicU64,
resume_calls: AtomicU64,
zones: Vec<Zone>,
}
impl GrowFake {
fn new(current_gib: u64) -> Self {
GrowFake {
per_gib: 10,
current_gib,
zones: vec![Zone("se-sto1".into())],
..Default::default()
}
}
fn answers(self, grow: Result<GrowOutcome, CloudError>) -> Self {
*self.grow_answer.lock().unwrap() = Some(grow);
self
}
fn resumes(self, resume: Result<GrowOutcome, CloudError>) -> Self {
*self.resume_answer.lock().unwrap() = Some(resume);
self
}
}
#[allow(dead_code)]
impl GrowFake {
pub fn provider(&self) -> &'static str {
"growfake"
}
pub fn zones(&self) -> &[Zone] {
&self.zones
}
pub fn price_resource(&self, resource: &Resource) -> Result<Price, CloudError> {
let gib = match resource {
Resource::Disk { gib, .. } => *gib,
_ => 1,
};
Ok(Price {
minor_per_month: self.per_gib * gib,
currency: Currency::Sek,
})
}
pub fn ensure(
&self,
_reference: &str,
_resource: &Resource,
) -> Result<Ensured, CloudError> {
unreachable!("a growth never buys")
}
pub fn destroy_id(&self, _id: &ResourceId) -> Result<(), CloudError> {
Ok(())
}
pub fn status(&self, _id: &ResourceId) -> Result<ResourceStatus, CloudError> {
Ok(ResourceStatus::Active)
}
pub fn list_owned(&self) -> Result<crate::Inventory, CloudError> {
Ok(crate::Inventory {
owned: vec![],
foreign: 0,
currency: Currency::Sek,
})
}
pub fn price_grow(&self, set: &DataSet) -> Result<Price, CloudError> {
if set.target_gib < self.current_gib {
return Err(CloudError::Refused("shrink".into()));
}
Ok(Price {
minor_per_month: self.per_gib
* (set.target_gib - self.current_gib)
* set.members.len() as u64,
currency: Currency::Sek,
})
}
pub fn survey_set(&self, set: &DataSet) -> Result<SetSurvey, CloudError> {
Ok(SetSurvey {
volumes: set
.members
.iter()
.map(|m| crate::grow::VolumeFact {
id: m.clone(),
gib: self.current_gib,
state: "online".into(),
attached: true,
address: None,
})
.collect(),
server_state: "started".into(),
current_gib: self.current_gib,
equal: true,
all_attached: true,
})
}
pub fn grow_set(
&self,
_set: &DataSet,
_journal: &Journal,
) -> Result<GrowOutcome, CloudError> {
self.grow_calls.fetch_add(1, Ordering::SeqCst);
self.grow_answer
.lock()
.unwrap()
.take()
.expect("a scripted answer")
}
pub fn resume_grow(
&self,
_set: &DataSet,
_journal: &Journal,
) -> Result<GrowOutcome, CloudError> {
self.resume_calls.fetch_add(1, Ordering::SeqCst);
self.resume_answer
.lock()
.unwrap()
.take()
.expect("a scripted resume")
}
}
#[allow(unused_variables, unreachable_code)]
impl crate::cloud::Cloud for GrowFake {
fn manifest(&self) -> crate::cloud::CloudManifest {
crate::cloud::CloudManifest {
provider: self.provider().to_string(),
zones: self.zones().to_vec(),
settings: Vec::new(),
grow_refused: Some(crate::grow::unsupported(self.provider()).to_string()),
}
}
fn catalog(&self, zone: &crate::Zone) -> Result<crate::cloud::Catalog, crate::CloudError> {
Err(crate::lay::unsupported(self.provider()))
}
fn price(
&self,
what: &crate::cloud::Priced<'_>,
) -> Result<crate::Price, crate::CloudError> {
match what {
crate::cloud::Priced::Resource(r) => self.price_resource(r),
crate::cloud::Priced::Plan(p) => {
crate::price_estate_by_rows(p, |r| self.price_resource(r), None)
}
crate::cloud::Priced::Grow(set) => self.price_grow(set),
}
}
fn lay(
&self,
plan: &crate::lay::EstatePlan,
journal: &crate::grow::Journal,
) -> Result<crate::lay::LaidEstate, crate::CloudError> {
Err(crate::lay::unsupported(self.provider()))
}
fn change(
&self,
change: &crate::cloud::Change,
journal: &crate::grow::Journal,
) -> Result<crate::cloud::Changed, crate::CloudError> {
Ok(match change {
crate::cloud::Change::Plan {
server,
plan,
delta,
} => return Err(crate::lay::unsupported_verb(self.provider(), "change_plan")),
crate::cloud::Change::Grow { set, estate } => {
crate::cloud::Changed::Grown(self.grow_set(set, journal)?)
}
crate::cloud::Change::Settle { set } => {
crate::cloud::Changed::Grown(self.resume_grow(set, journal)?)
}
crate::cloud::Change::ForgetMedia { laid } => {
crate::cloud::Changed::Forgotten(Vec::new())
}
crate::cloud::Change::Ensure {
reference,
resource,
room,
} => crate::cloud::Changed::Ensured(self.ensure(reference, resource)?),
crate::cloud::Change::Retire { reference, machine } => {
crate::cloud::Changed::Retired(crate::cloud::retire_by_rows(
self, reference, machine,
)?)
}
crate::cloud::Change::LabelAdopted { .. } => {
return Err(crate::lay::unsupported_verb(
self.provider(),
"label_adopted",
))
}
})
}
fn boot(
&self,
boot: &crate::cloud::Boot,
journal: &crate::grow::Journal,
) -> Result<crate::cloud::Booted, crate::CloudError> {
Ok(match boot {
crate::cloud::Boot::Keep(k) => {
return Err(crate::lay::unsupported_verb(self.provider(), "keep_boot"))
}
crate::cloud::Boot::Power { server, on } => {
return Err(crate::lay::unsupported_verb(self.provider(), "set_power"))
}
crate::cloud::Boot::Restore(r) => {
return Err(crate::lay::unsupported_verb(
self.provider(),
"restore_from_backup",
))
}
})
}
fn firewall(&self, laid: &crate::lay::LaidEstate) -> Result<(), crate::CloudError> {
Err(crate::lay::unsupported_verb(
self.provider(),
"set_estate_firewall",
))
}
fn destroy(
&self,
what: &crate::cloud::Give<'_>,
) -> Result<crate::Reclaimed, crate::CloudError> {
match what {
crate::cloud::Give::References(refs) => {
Ok(crate::release_for_by_rows(self.list_owned()?, refs, |id| {
self.destroy_id(id)
}))
}
crate::cloud::Give::Resource(id) => {
self.destroy_id(id)?;
Ok(crate::Reclaimed {
destroyed: vec![(*id).clone()],
..crate::Reclaimed::default()
})
}
}
}
fn inventory(
&self,
what: &crate::cloud::Listing<'_>,
) -> Result<crate::cloud::Listed, crate::CloudError> {
Ok(match what {
crate::cloud::Listing::Account => crate::cloud::Listed::Account(self.list_owned()?),
crate::cloud::Listing::Set(set) => crate::cloud::Listed::Set(self.survey_set(set)?),
crate::cloud::Listing::Status(id) => crate::cloud::Listed::Status(self.status(id)?),
crate::cloud::Listing::Under(refs) => {
crate::cloud::Listed::Under(crate::cloud::under(self.list_owned()?, refs))
}
crate::cloud::Listing::Room { delta, room } => {
crate::cloud::Listed::Room(crate::cloud::RoomLeft {
serve: Ok(()),
left_bytes: None,
ceilings: Default::default(),
})
}
crate::cloud::Listing::Admits(_plan) => crate::cloud::Listed::Admitted,
})
}
}
fn set(target_gib: u64) -> DataSet {
DataSet {
product: "gunnar".into(),
server: crate::ServerId("srv".into()),
members: (1..=4).map(|i| VolumeId(format!("vol-{i}"))).collect(),
target_gib,
}
}
fn journal(name: &str) -> Journal {
Journal::open(tmp(name).with_file_name("grow.journal")).unwrap()
}
fn sek_price(minor: u64) -> Price {
Price {
minor_per_month: minor,
currency: Currency::Sek,
}
}
fn done(cost: u64) -> GrowOutcome {
GrowOutcome::Done {
grown: (1..=4).map(|i| VolumeId(format!("vol-{i}"))).collect(),
from_gib: 32,
to_gib: 64,
cost: sek_price(cost),
}
}
/// The delta — 4 × 32 GiB × 10 = 1280 — is admitted and RESERVED before
/// the provider is asked, and what the provider reports grown is what is
/// committed; the reservation is gone afterwards.
#[test]
fn the_delta_is_reserved_before_the_provider_is_asked_and_committed_from_what_grew() {
let inner = GrowFake::new(32).answers(Ok(done(1_280)));
let cap = SpendCap::new(inner, sek(2_000), tmp("grow-ok"))
.unwrap()
.with_month(|| "2026-09".into());
let outcome = crate::ask::grow(&cap, &set(64), false, &journal("grow-ok")).unwrap();
assert_eq!(outcome.word(), "done");
assert_eq!(cap.inner.grow_calls.load(Ordering::SeqCst), 1);
assert_eq!(cap.reserved_minor(), 0, "settled");
assert_eq!((cap.spent_this_month(), cap.spent_total()), (1_280, 1_280));
assert_eq!(cap.remaining(), 720);
}
/// **T7: the ceiling is checked against the WHOLE delta before member 1.**
/// A cap that would admit three members' delta but not four refuses with
/// the provider never asked and nothing reserved.
#[test]
fn a_cap_too_small_for_the_delta_refuses_before_member_one() {
let inner = GrowFake::new(32).answers(Ok(done(1_280)));
let cap = SpendCap::new(inner, sek(1_279), tmp("grow-short")).unwrap();
let err = crate::ask::grow(&cap, &set(64), false, &journal("grow-short"))
.unwrap_err()
.to_string();
assert!(
err.contains("12.80 SEK") && err.contains("12.79 SEK"),
"names the numbers, in words: {err}"
);
assert_eq!(
cap.inner.grow_calls.load(Ordering::SeqCst),
0,
"the provider was never asked"
);
assert_eq!(cap.reserved_minor(), 0);
assert_eq!(cap.spent_total(), 0);
}
/// The provider refused before touching anything: the reservation is
/// released, nothing is committed.
#[test]
fn a_refusal_before_anything_was_touched_releases_the_reservation() {
let inner =
GrowFake::new(32).answers(Err(CloudError::Refused("HOTPLUG_FAILED on vol-1".into())));
let cap = SpendCap::new(inner, sek(2_000), tmp("grow-refused")).unwrap();
assert!(crate::ask::grow(&cap, &set(64), false, &journal("grow-refused")).is_err());
assert_eq!(cap.reserved_minor(), 0);
assert_eq!(cap.spent_total(), 0);
}
/// **The crash story.** An outage mid-growth keeps the reservation — in
/// the FILE, so a fresh process still sees the headroom held — and the
/// resume's outcome settles it: a partial commits the delta of what grew.
#[test]
fn an_outage_keeps_the_reservation_across_a_reload_and_the_resume_settles_it() {
let path = tmp("grow-crash");
let inner = GrowFake::new(32).answers(Err(CloudError::Unavailable(
"attach of vol-3 timed out; journal open".into(),
)));
let cap = SpendCap::new(inner, sek(2_000), path.clone()).unwrap();
assert!(matches!(
crate::ask::grow(&cap, &set(64), false, &journal("grow-crash")),
Err(CloudError::Unavailable(_))
));
assert_eq!(cap.reserved_minor(), 1_280, "held for the resume");
assert_eq!(
cap.remaining(),
720,
"a second growth cannot spend the held headroom"
);
drop(cap);
// A fresh process.
let partial = GrowOutcome::Partial {
grown: vec![
VolumeId("vol-1".into()),
VolumeId("vol-2".into()),
VolumeId("vol-3".into()),
],
not_grown: vec![VolumeId("vol-4".into())],
cost: sek_price(960),
why: "3 of 4".into(),
};
let again =
SpendCap::new(GrowFake::new(32).resumes(Ok(partial)), sek(2_000), path).unwrap();
assert_eq!(
again.reserved_minor(),
1_280,
"the reservation survived the restart"
);
assert_eq!(
crate::ask::settle(&again, &set(64), &journal("grow-crash"))
.unwrap()
.word(),
"partial"
);
assert_eq!(again.reserved_minor(), 0);
assert_eq!(
again.spent_total(),
960,
"what actually grew is what is committed"
);
}
/// The idempotent repeat: the provider answers `Done` with nothing grown
/// and a cost of zero, and the cap commits nothing.
#[test]
fn a_repeat_to_the_same_target_commits_nothing() {
let inner = GrowFake::new(64).answers(Ok(GrowOutcome::Done {
grown: vec![],
from_gib: 64,
to_gib: 64,
cost: sek_price(0),
}));
let cap = SpendCap::new(inner, sek(100), tmp("grow-repeat")).unwrap();
assert_eq!(
crate::ask::grow(&cap, &set(64), false, &journal("grow-repeat"))
.unwrap()
.word(),
"done"
);
assert_eq!((cap.spent_total(), cap.reserved_minor()), (0, 0));
}
/// A provider registered as unable to spend may grow only for free.
#[test]
fn cannot_spend_refuses_a_priced_growth_and_forwards_a_free_one() {
let priced = CannotSpend::new(GrowFake::new(32).answers(Ok(done(1_280))));
let err = crate::ask::grow(&priced, &set(64), false, &journal("grow-cannot"))
.unwrap_err()
.to_string();
assert!(
err.contains("cannot spend") && err.contains("12.80 SEK"),
"{err}"
);
assert_eq!(priced.inner().grow_calls.load(Ordering::SeqCst), 0);
let free = CannotSpend::new(GrowFake::new(64).answers(Ok(GrowOutcome::Done {
grown: vec![],
from_gib: 64,
to_gib: 64,
cost: sek_price(0),
})));
assert_eq!(
crate::ask::grow(&free, &set(64), false, &journal("grow-free"))
.unwrap()
.word(),
"done"
);
// The default: a provider with no growth path refuses by name.
assert!(
crate::ask::grow(&FakeCloud::new(), &set(64), false, &journal("grow-default"))
.unwrap_err()
.to_string()
.contains("cannot grow a data set")
);
}
}
/// **A plan change is admitted by what it adds a month**, under one key per
/// server, and settled into the month when it is done; past the ceiling it is
/// refused before the provider is asked. Stopping and starting a server buys
/// nothing and is never capped — not even at a spent ceiling.
#[test]
fn a_plan_change_is_admitted_by_its_monthly_delta_and_power_is_never_capped() {
let dir = std::env::temp_dir().join(format!("monetize-cap-plan-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let journal = crate::grow::Journal::open(dir.join("journal")).unwrap();
let cap = SpendCap::new(FakeCloud::new(), sek(1_000), tmp("plan"))
.unwrap()
.with_month(|| "2026-10".into());
let main = ResourceId {
provider: "fake".into(),
id: "server/main".into(),
};
let up = |minor: u64| Price {
minor_per_month: minor,
currency: Currency::Sek,
};
crate::ask::change_plan(&cap, &main, "BIGGER", up(600), &journal).unwrap();
assert_eq!(
cap.spent_this_month(),
600,
"the change's monthly delta is spent"
);
assert_eq!(cap.reserved_minor(), 0, "and nothing stays reserved");
let e = crate::ask::change_plan(&cap, &main, "BIGGEST", up(600), &journal).unwrap_err();
assert!(
matches!(&e, CloudError::OverCap(w) if w.contains("spend cap")),
"{e:?}"
);
assert_eq!(
cap.inner.plan_calls.lock().unwrap().clone(),
vec!["plan server/main BIGGER".to_string()],
"the refused change never reached the provider"
);
// A smaller plan adds nothing: it is never refused.
crate::ask::change_plan(&cap, &main, "SMALLER", up(0), &journal).unwrap();
// Power: past the ceiling, still allowed.
crate::ask::set_power(&cap, &main, false).unwrap();
crate::ask::set_power(&cap, &main, true).unwrap();
assert_eq!(cap.inner.plan_calls.lock().unwrap().len(), 4);
let _ = std::fs::remove_dir_all(&dir);
}
/// Three machines, one 10 GiB system disk each, for `company`.
fn estate_plan(company: &str) -> crate::lay::EstatePlan {
use crate::lay::{default_interfaces, reference_of, DiskPlan, EstatePlan, MachinePlan};
let m = |role: &str| MachinePlan {
role: role.into(),
hostname: format!("gunnar-{role}-{company}"),
title: format!("gunnar {role}-{company}"),
plan: "1xCPU-2GB".into(),
boot_order: "disk,cdrom".into(),
labels: Default::default(),
interfaces: default_interfaces(),
medium: String::new(),
medium_object: None,
template: String::new(),
user_data: Default::default(),
disks: vec![DiskPlan {
title: format!("gunnar-{role}-{company} system"),
gib: 10,
tier: "standard".into(),
labels: Default::default(),
}],
};
EstatePlan {
reference: reference_of(company),
company: company.into(),
zone: Zone("se-sto1".into()),
machines: vec![m("front"), m("main"), m("twin")],
narrate_from: String::new(),
shape: None,
}
}
/// ★ **The cap is the one thing that stops a lay, and it says so in words**
/// (2026-10-08, test2's shop): an estate over the cloud's cap is refused
/// before the provider is asked, naming the estate, what it costs a month at
/// the provider and the cap, in money a reader can act on; one under it
/// reaches the provider, and a refusal there gives the reservation back.
#[test]
fn an_estate_over_the_cap_is_refused_in_words_before_the_provider_is_asked() {
let journal =
crate::grow::Journal::with_sink(vec![], Box::new(|_: &crate::grow::GrowRecord| Ok(())));
// 3 × (a server at 1.00 SEK + a 10 GiB disk at 10.00 SEK) = 33.00 SEK a month.
let tight = SpendCap::new(FakeCloud::new(), sek(2_000), tmp("lay-tight")).unwrap();
let e = crate::Cloud::lay(&tight, &estate_plan("acme"), &journal)
.unwrap_err()
.to_string();
assert!(e.contains("spend cap refuses estate/acme"), "{e}");
assert!(
e.contains("33.00 SEK a month at the provider")
&& e.contains("the cap is 20.00 SEK a month")
&& e.contains("0.00 SEK is already committed"),
"{e}"
);
assert!(
e.contains("nothing was bought") && !e.contains("cannot lay"),
"refused by the cap, the provider never asked: {e}"
);
assert!(
tight.cap.spent.lock().unwrap().reserved.is_empty(),
"nothing reserved for a refused estate"
);
let roomy = SpendCap::new(FakeCloud::new(), total(5_000), tmp("lay-roomy")).unwrap();
for _ in 0..2 {
let e = crate::Cloud::lay(&roomy, &estate_plan("acme"), &journal)
.unwrap_err()
.to_string();
assert!(
e.contains("fake cloud cannot lay an estate"),
"admitted, and the provider's own refusal comes back: {e}"
);
}
assert!(
roomy.cap.spent.lock().unwrap().reserved.is_empty(),
"the provider's refusal gave the reservation back"
);
}
/// ★ **Asked ahead, PURE** (the owner, 2026-10-08: "the customer must not know
/// about a cap! it must be queued"): `can_lay` answers what `lay_estate`'s own
/// admission would, in the same words, and reserves nothing — so the job can
/// keep the order in its queue until the cap is raised; under the cap it says
/// yes, and a resumed lay's own reservation does not count against it.
#[test]
fn can_lay_is_the_lays_admission_asked_ahead_and_reserves_nothing() {
let tight = SpendCap::new(FakeCloud::new(), sek(2_000), tmp("canlay-tight")).unwrap();
let e = tight.can_lay(&estate_plan("acme")).unwrap_err().to_string();
assert!(
e.contains(ESTATE_OVER_CAP)
&& e.contains("33.00 SEK a month at the provider")
&& e.contains("the cap is 20.00 SEK a month"),
"{e}"
);
assert!(
tight.cap.spent.lock().unwrap().reserved.is_empty(),
"asked, not reserved"
);
let roomy = SpendCap::new(FakeCloud::new(), sek(5_000), tmp("canlay-roomy")).unwrap();
assert!(roomy.can_lay(&estate_plan("acme")).is_ok());
// A resumed lay: its own reservation (3300 of 5000) is not counted twice.
roomy
.cap
.spent
.lock()
.unwrap()
.reserved
.insert("lay:estate/acme".into(), 3_300);
assert!(
roomy.can_lay(&estate_plan("acme")).is_ok(),
"its own reservation is the one it replaces"
);
assert!(
roomy.can_lay(&estate_plan("bolt")).is_err(),
"another estate counts what acme holds"
);
}
/// ★ **An estate given back in the month it was laid gives that month back**
/// (round 5.3.1; found by the UpCloud mockup's test2 row, 2026-10-08): a paid
/// order whose install failed is given back and laid again — counted twice,
/// it held the next order behind a cap it never spent. Its release credits
/// what its lay committed this month; nothing else moves.
#[test]
fn an_estate_given_back_in_its_month_gives_the_month_back() {
let journal =
crate::grow::Journal::with_sink(vec![], Box::new(|_: &crate::grow::GrowRecord| Ok(())));
// Each estate: 33.00 SEK a month; the cap: 50.00 SEK this month.
let cap = SpendCap::new(FakeCloud::new().laying(), sek(5_000), tmp("lay-credit")).unwrap();
crate::Cloud::lay(&cap, &estate_plan("acme"), &journal).expect("acme fits");
assert_eq!(cap.spent_this_month(), 3_300);
assert!(
crate::Cloud::lay(&cap, &estate_plan("bolt"), &journal)
.unwrap_err()
.to_string()
.contains(ESTATE_OVER_CAP),
"bolt does not fit beside acme"
);
crate::ask::release_for(&cap, &["estate/acme".to_string()]).unwrap();
assert_eq!(
cap.spent_this_month(),
0,
"acme's month came back with its iron"
);
crate::Cloud::lay(&cap, &estate_plan("bolt"), &journal).expect("now bolt fits");
// A release of anything else moves nothing.
crate::ask::release_for(&cap, &["estate/acme".to_string(), "room/x".to_string()]).unwrap();
assert_eq!(cap.spent_this_month(), 3_300, "bolt stays committed");
}
/// The guardian's relay of ONE role of `company` (`estate_order::relay::relay_one`'s shape).
fn relay_plan(company: &str, role: &str) -> crate::lay::EstatePlan {
let mut p = estate_plan(company);
p.machines.retain(|m| m.role == role);
for m in &mut p.machines {
m.labels.insert(crate::lay::RELAY_LABEL.into(), role.into());
m.title.push_str(" relay-1");
for d in &mut m.disks {
d.title.push_str(" relay-1");
}
}
p
}
/// ★ **A relay is committed under its ROLE, and the fence of that box gives its month
/// back** (5.4 guardian, gap-2): the relay used to settle under the estate's own key, so
/// it overwrote what the estate's lay committed and its fence never gave anything back.
/// Now `estate/acme#twin` holds the relay; retiring that box this month credits it once;
/// the estate's own release still gives the whole estate's month back.
#[test]
fn a_relay_is_committed_under_its_role_and_its_retire_gives_its_month_back() {
let journal =
crate::grow::Journal::with_sink(vec![], Box::new(|_: &crate::grow::GrowRecord| Ok(())));
let cap = SpendCap::new(FakeCloud::new().laying(), sek(10_000), tmp("relay-credit")).unwrap();
crate::Cloud::lay(&cap, &estate_plan("acme"), &journal).unwrap();
assert_eq!(cap.spent_this_month(), 3_300);
let relay =
crate::Cloud::lay(&cap, &relay_plan("acme", "twin"), &journal).expect("one box fits");
assert_eq!(cap.spent_this_month(), 4_400);
{
let s = cap.cap.spent.lock().unwrap();
assert_eq!(
s.laid.get("estate/acme").map(|l| l.1),
Some(3_300),
"the estate's own lay is not overwritten: {:?}",
s.laid
);
assert_eq!(
s.laid.get("estate/acme#twin").map(|l| l.1),
Some(1_100),
"{:?}",
s.laid
);
assert!(s.reserved.is_empty(), "{:?}", s.reserved);
}
crate::ask::retire_machine(&cap, "estate/acme", relay.machine("twin").unwrap()).unwrap();
assert_eq!(
cap.spent_this_month(),
3_300,
"the relay's month came back with its box"
);
crate::ask::retire_machine(&cap, "estate/acme", relay.machine("twin").unwrap()).unwrap();
assert_eq!(cap.spent_this_month(), 3_300, "credited once");
crate::ask::release_for(&cap, &["estate/acme".to_string()]).unwrap();
assert_eq!(cap.spent_this_month(), 0);
}
/// A relay laid this month and still standing is given back with its estate.
#[test]
fn an_estates_release_gives_back_its_relays_month_too() {
let journal =
crate::grow::Journal::with_sink(vec![], Box::new(|_: &crate::grow::GrowRecord| Ok(())));
let cap = SpendCap::new(FakeCloud::new().laying(), sek(10_000), tmp("relay-release")).unwrap();
crate::Cloud::lay(&cap, &estate_plan("acme"), &journal).unwrap();
crate::Cloud::lay(&cap, &relay_plan("acme", "main"), &journal).unwrap();
crate::Cloud::lay(&cap, &estate_plan("bolt"), &journal).unwrap();
assert_eq!(cap.spent_this_month(), 7_700);
crate::ask::release_for(&cap, &["estate/acme".to_string()]).unwrap();
assert_eq!(
cap.spent_this_month(),
3_300,
"acme and its relay came back; bolt stays"
);
}
/// ★ **An outage-kept relay reservation is dropped by the fence of its role** — and only
/// that one: the box it was for is the one being fenced, and the next lay reserves afresh.
#[test]
fn the_fence_drops_its_roles_kept_relay_reservation_only() {
let cap = SpendCap::new(
FakeCloud::new().laying(),
sek(10_000),
tmp("relay-unreserve"),
)
.unwrap();
{
let mut s = cap.cap.spent.lock().unwrap();
s.reserved.insert("lay:estate/acme#main".into(), 1_100);
s.reserved.insert("lay:estate/acme#twin".into(), 1_100);
s.reserved.insert("lay:estate/acme".into(), 3_300);
}
let main = crate::lay::LaidMachine {
role: "main".into(),
hostname: "h".into(),
title: "t".into(),
plan: "p".into(),
server: "estate/acme:main".into(),
public_ipv4: String::new(),
utility_ipv4: String::new(),
disks: vec![],
medium_clone: String::new(),
};
crate::ask::retire_machine(&cap, "estate/acme", &main).unwrap();
let s = cap.cap.spent.lock().unwrap();
assert_eq!(
s.reserved.keys().cloned().collect::<Vec<_>>(),
["lay:estate/acme", "lay:estate/acme#twin"]
);
}
/// The relay's admission is asked under the relay's own key, so a resumed relay does not
/// count its own reservation twice.
#[test]
fn can_lay_a_relay_excludes_its_own_reservation() {
let cap = SpendCap::new(FakeCloud::new(), sek(1_500), tmp("relay-canlay")).unwrap();
cap.cap
.spent
.lock()
.unwrap()
.reserved
.insert("lay:estate/acme#main".into(), 1_100);
assert!(
cap.can_lay(&relay_plan("acme", "main")).is_ok(),
"its own reservation is the one it replaces"
);
assert!(
cap.can_lay(&relay_plan("acme", "twin")).is_err(),
"the other role's counts"
);
}
/// ★ **Money in the operator's words** (live 2026-10-14: "it would cost 20422 Eur/month, 10107
/// is already c…"): a refusal says every amount with its currency and two decimals, and the
/// cap's pure answer says the same numbers before anything is paid.
#[test]
fn a_refusal_over_the_cap_says_every_amount_in_eur_with_two_decimals() {
let dir = std::env::temp_dir().join(format!("monetize-cap-words-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let journal = crate::grow::Journal::open(dir.join("journal")).unwrap();
let cap = SpendCap::new(
FakeCloud::new(),
Ceiling::monthly(20_000, Currency::Eur),
tmp("words"),
)
.unwrap()
.with_month(|| "2026-10".into());
let main = ResourceId {
provider: "fake".into(),
id: "server/main".into(),
};
let eur = |minor: u64| Price {
minor_per_month: minor,
currency: Currency::Eur,
};
crate::ask::change_plan(&cap, &main, "BIGGER", eur(10_107), &journal).unwrap();
let e = crate::ask::change_plan(&cap, &main, "BIGGEST", eur(20_422), &journal).unwrap_err();
let CloudError::OverCap(w) = &e else {
panic!("{e:?}")
};
assert!(
w.contains("204.22 EUR a month")
&& w.contains("the cap is 200.00 EUR a month")
&& w.contains("101.07 EUR is already committed"),
"{w}"
);
assert!(
!w.contains("Eur") && !w.contains("20422") && !w.contains("10107"),
"no minor units, no debug currency: {w}"
);
let short = cap
.cap_short(eur(20_422), &[])
.unwrap()
.expect("over the cap");
assert_eq!(short.words(), "raise the cap first: this needs 204.22 EUR a month, the cap is 200.00 EUR a month, 101.07 EUR is committed; nothing was charged");
assert_eq!(
cap.cap_short(eur(9_893), &[]).unwrap(),
None,
"what still fits is not short"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// R5a: the cap stands in front of every purchase on the box. A panic that once
/// held its book (a provider that panicked inside a call made under it) must not
/// make every later purchase, and every console page that reads the spend,
/// panic. The book a panic leaves is the last one written, and a reservation it
/// left behind only refuses more: the cap fails closed.
#[test]
fn a_poisoned_spend_book_still_answers_and_still_counts() {
let inner = FakeCloud::new();
let one = price_of(&inner, &disk(10));
let cap = SpendCap::new(inner, sek(one * 2), tmp("poisoned"))
.unwrap()
.with_month(|| "2026-09".into());
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _held = cap.cap.spent.lock();
panic!("a panic while the spend book is held");
}));
assert!(cap.cap.spent.is_poisoned());
assert_eq!(cap.spent_this_month(), 0);
assert_eq!(cap.spent_total(), 0);
assert_eq!(cap.remaining(), one * 2);
assert_eq!(cap.reserved_minor(), 0);
assert!(matches!(
crate::ask::ensure(&cap, "gunnar/a/2027", &disk(10), None)
.expect("a purchase after the panic"),
Ensured::Provisioned { .. }
));
assert_eq!(cap.spent_this_month(), one, "and it is counted");
}