crates/monetize-server/src/estate_order/tests/add_twin.rs
//! ★ **ADDING A TWIN to a Gunnar-shaped estate** (monetize v2.0 A4-FACTS, item 3).
//!
//! The flock ([`crate::testing::flock`]) is Gunnar's three machines with its twin OPTIONAL: an
//! order `{twin: false}` lays a front and a main alone. Before A4-FACTS the cloud's plan check
//! held every plan that names a main or a twin to exactly one front, one main and one twin, so
//! such an estate could not be laid, and its twin could never be laid beside it. The check now
//! takes its shape from the product's facts (its roles, which may be left out), and a plan
//! change lays the twin, pairs it and lets it follow main.
//!
//! ```text
//! order {twin: false} ─▶ front, main laid (the plan carries the flock's shape)
//! ChangePlan {twin: true} ─▶ the twin is not laid: an ADD (facts.grow may_add twin)
//! ─▶ its plan the twin rule's (chosen from main's), its data disk main's whole set
//! ─▶ held, paid ─▶ the twin laid beside the estate (added=change-1), installed (paired),
//! its key named to the guardian ─▶ it calls home ─▶ captured, done
//! ─▶ it follows: the run's twin is the new one, the twin rule reads it
//! ```
use super::*;
use crate::testing::flock;
fn flock_cfg() -> EstateOrderConfig {
flock::install();
EstateOrderConfig {
lifecycle: flock::KIND.into(),
..cfg()
}
}
fn roles_laid(r: &Rig) -> Vec<String> {
r.state
.estates
.get("acme")
.and_then(|run| run.laid)
.map(|l| flock::roles_of(&l))
.unwrap_or_default()
}
/// A flock delivered and paid at `sizes`.
fn delivered_flock(name: &str, sizes: &EstateSizes) -> Rig {
let r = rig_with(name, flock_cfg(), "PASS");
start_for(&r.state, "acme", "buyer@example.com", "", "alice", sizes).expect("ordered");
r.pay.authorize(&handle_of(&r, "acme"));
drive(&r.state, "acme");
let run = r.state.estates.get("acme").unwrap();
assert_eq!(run.phase, phase::CAPTURED, "{:#?}", run.history);
r
}
/// ★ **A Gunnar-shaped estate with no twin is laid, and a plan change lays its twin, pairs it
/// and lets it follow main.**
#[test]
fn a_gunnar_shaped_estate_laid_without_its_twin_takes_one_by_a_plan_change() {
let r = delivered_flock("add-twin", &flock::sizes(false, 0, 10));
assert_eq!(
roles_laid(&r),
["front", "main"],
"a front and a main alone: the flock's twin is optional"
);
let before = r.state.estates.get("acme").unwrap();
assert!(
before.quote.machines.iter().all(|m| m.role != "twin"),
"nothing is priced for a twin it has not got: {:?}",
before.quote.machines
);
let lays_before = r.cloud.lays().len();
let c = change::change(
&r.state,
"acme",
&EstateSizes {
order: serde_json::json!({"twin": true}),
..EstateSizes::default()
},
)
.unwrap_or_else(|e| panic!("a twin is added by a plan change: {e:?}"));
assert_eq!(
c.adds.iter().map(|a| a.role.as_str()).collect::<Vec<_>>(),
["twin"],
"{c:#?}"
);
assert!(
c.steps.is_empty(),
"nothing that stands is resized: {:?}",
c.steps
);
assert_eq!(c.status, change::status::WAITING_FOR_PAYMENT);
assert!(
c.quote.monthly.minor > before.quote.monthly.minor,
"the twin is priced in"
);
// The twin follows main: its plan is the twin rule's for main's, its data disk main's set.
let line = |role: &str| {
c.quote
.machines
.iter()
.find(|m| m.role == role)
.cloned()
.unwrap_or_else(|| panic!("no {role} in {:?}", c.quote.machines))
};
assert_eq!(
line("twin").plan,
c.quote.twin_plan,
"the twin's plan is chosen from main's"
);
let main_set: u64 = line("main").disks_gib.iter().skip(1).sum();
assert!(
line("twin").disks_gib.get(1).copied().unwrap_or(0) >= main_set,
"the twin's data disk holds main's whole set: {:?} / {:?}",
line("twin"),
line("main")
);
assert!(
c.words
.contains(&format!("a new twin ({})", c.quote.twin_plan)),
"{}",
c.words
);
assert_eq!(
r.cloud.lays().len(),
lays_before,
"nothing is laid before the money is held"
);
r.pay.authorize(&c.handle.as_ref().expect("a hold").id);
drive(&r.state, "acme");
drive(&r.state, "acme");
let run = r.state.estates.get("acme").unwrap();
assert!(run.change.is_none(), "{:#?}", run.change);
let done = run.changes.last().expect("the change is kept");
assert_eq!(
done.status,
change::status::DONE,
"{done:#?}\n{:#?}",
run.history
);
assert_eq!(
roles_laid(&r),
["front", "main", "twin"],
"the twin is laid beside the estate"
);
assert_eq!(
r.cloud.lays().len(),
lays_before + 1,
"one lay: the twin alone"
);
assert!(
r.kit
.roles
.lock()
.unwrap()
.iter()
.any(|role| role == "twin"),
"the installer paired the twin: {:?}",
r.kit.roles.lock().unwrap()
);
let g = r.state.fleet.guardian().unwrap();
assert!(
g.store
.box_record("acme", "twin")
.is_some_and(|b| b.heard_unix_ms > 0),
"the twin called home"
);
assert_eq!(
r.pay.captures(),
2,
"the change's hold captured once the twin was heard"
);
// It follows main: the run's twin is the one laid, at the rule's plan, and the shop's twin
// reading names it beside main.
assert_eq!(run.quote.twin_plan, c.quote.twin_plan);
assert!(
run.quote.machines.iter().any(|m| m.role == "twin"),
"the bill follows: the renewal prices the twin"
);
let life = lifecycle::of(r.state.estates.cfg.as_ref().unwrap()).unwrap();
assert_eq!(
twin::reviewing_roles(&life.twin().expect("the flock has a twin")),
["main", "twin"]
);
assert!(
r.mails
.last()
.body
.contains(&format!("a new twin runs on {}", c.quote.twin_plan)),
"{}",
r.mails.last().body
);
}
/// ★ **The plan the core hands the cloud carries the product's shape** (A4-FACTS): every lay and
/// every price is checked against the flock's roles — the twin optional, a node laid n times —
/// never against Gunnar's three machines.
#[test]
fn the_plan_the_core_lays_carries_the_products_shape() {
let c = flock_cfg();
let plan = lifecycle::lay_plan_of(&c, "acme", &flock::sizes(false, 2, 10), "STARTER-1xCPU-4GB")
.unwrap();
assert_eq!(
plan.machines
.iter()
.map(|m| m.role.as_str())
.collect::<Vec<_>>(),
["front", "main", "node", "node-2"]
);
let shape = plan.shape.clone().expect("the shape rides the plan");
let twin = shape
.roles
.iter()
.find(|r| r.role == "twin")
.expect("the twin is a role");
assert!(twin.optional && !twin.many);
assert!(shape.roles.iter().any(|r| r.role == "node" && r.many));
assert!(
plan.check().is_ok(),
"a Gunnar-shaped estate with no twin is an estate of this product: {:?}",
plan.check()
);
// Gunnar's own shape keeps its three machines.
crate::testing::lifecycles::install();
let g = lifecycle::lay_plan_of(&cfg(), "acme", &EstateSizes::default(), "STARTER-1xCPU-4GB")
.unwrap();
let mut two = g.clone();
two.machines.retain(|m| m.role != "twin");
assert!(
matches!(two.check(), Err(monetize_cloud::CloudError::Refused(w)) if w.contains("has no twin")),
"Gunnar's twin is not optional"
);
}