crates/monetize-server/tests/bundles.rs
//! **The SHOP WINDOW over the wire: `Purchase.Bundles`.**
//!
//! `monetize::bundle`'s own tests prove the pricing rules on pure inputs. This
//! file proves the three things no unit test can reach — that the window is
//! PRICED by the deployment's own list and not by anything on this side, that
//! its `can_serve` is the product's real answer and never a shrug, and that a
//! bundle ordered by id and the identical order typed by hand are **one
//! transaction**: same caps, same reference, same ledger.
//!
//! # Why this exists at all — 2026-09-19
//!
//! The gunnar console's "more room" button was a form asking a customer to type
//! a meter name (`pack_bytes`) and a new limit in bytes. That is a debugging
//! form, and the console was right not to invent a size or a price: it may not
//! divide `Money.minor` by 100 and it may not name a currency. The missing half
//! was on monetize's side — there was no message for *"10 GiB more room, for
//! 220.00 SEK, per month"* as a thing somebody can buy. This is that message,
//! and every string in it is monetize's.
use std::collections::BTreeMap;
use std::sync::Arc;
use monetize::testing::{price_list, purchase, Answer, MockCloud, MockPay, MockProduct, NOW};
use monetize::transaction::Outcome;
use monetize::BundleSpec;
use monetize_proto::purchase_client::PurchaseClient;
use monetize_proto::{self as pb, PurchaseState};
use monetize_server::registry::Registry;
use monetize_server::state::AppState;
use monetize_server::testing::TestServer;
use tonic::metadata::MetadataValue;
use tonic::transport::Channel;
use tonic::{Code, Request};
const BEARER: &str = "test-bearer-token-of-sufficient-length";
const GIB: u64 = 1 << 30;
fn spec(pairs: &[(&str, u64)], period_days: Option<u32>) -> BundleSpec {
BundleSpec { cap_delta: pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect(), period_days }
}
/// The estate's own shape: one storage bundle, declared by an operator, with no
/// price in it. `[products.gunnar.bundles."10gb-1m"] cap_delta = { pack_bytes =
/// 10737418240 }, period_days = 30`.
fn the_shop() -> BTreeMap<String, BundleSpec> {
[("10gb-1m".to_string(), spec(&[("pack_bytes", 10 * GIB)], Some(30)))].into()
}
struct Rig {
state: Arc<AppState>,
product: Arc<MockProduct>,
_server: TestServer,
channel: Channel,
}
impl Rig {
/// alice bought 10 GiB at [`NOW`]; the deployment prices disk at 22.00
/// SEK/GiB-month and declares `bundles`.
async fn up(bundles: BTreeMap<String, BundleSpec>) -> Rig {
let product = Arc::new(MockProduct::gunnar());
let cloud = MockCloud::new();
cloud.answer_all(Answer::Provisioned);
let mut state = AppState::in_memory(Registry::new().shared_product(product.clone()).sells("gunnar", MockPay::new()).cloud(cloud));
*state.terms.write().unwrap() = monetize_server::state::TermsStore::fixture();
state.clock = Box::new(|| NOW);
if !bundles.is_empty() {
*state.bundles.write().unwrap() = [("gunnar".to_string(), bundles)].into();
}
let state = Arc::new(state);
// alice's first period, sold directly: this file is about the SECOND
// purchase, and an order over the wire to set it up would only add a
// second thing that could fail.
let pay = MockPay::new();
let out = state.transaction(state.registry.products().next().unwrap(), monetize_pay::Vendor::of(&pay)).run(&purchase("alice")).expect("the first period must sell");
assert!(matches!(out, Outcome::Paid { .. }), "{out:?}");
let server = TestServer::spawn(state.clone(), BEARER).await;
let channel = Channel::from_shared(server.endpoint()).unwrap().connect().await.expect("connect");
Rig { state, product, _server: server, channel }
}
fn authed<T>(&self, req: T) -> Request<T> {
let mut r = Request::new(req);
r.metadata_mut().insert("authorization", MetadataValue::try_from(format!("Bearer {BEARER}")).unwrap());
r
}
async fn bundles(&self, tenant: &str) -> Result<Vec<pb::Bundle>, tonic::Status> {
PurchaseClient::new(self.channel.clone())
.bundles(self.authed(pb::ListBundlesRequest { product: "gunnar".into(), tenant: tenant.into(), market: 0, actor_ticket: Vec::new() }))
.await
.map(|r| r.into_inner().bundles)
}
/// A typed quote: the caps the customer would have had to compose by hand.
async fn quote_caps(&self, tenant: &str, caps: BTreeMap<String, u64>) -> Result<pb::QuoteResponse, tonic::Status> {
PurchaseClient::new(self.channel.clone())
.quote(self.authed(pb::QuoteRequest {
product: "gunnar".into(),
tenant: tenant.into(),
caps,
market: 0,
starts_unix_ms: NOW,
renew: false,
actor_ticket: Vec::new(),
bundle: String::new(),
}))
.await
.map(|r| r.into_inner())
}
async fn start(&self, tenant: &str, caps: BTreeMap<String, u64>, bundle: &str) -> Result<pb::StartPurchaseResponse, tonic::Status> {
PurchaseClient::new(self.channel.clone())
.start(self.authed(pb::StartPurchaseRequest {
product: "gunnar".into(),
tenant: tenant.into(),
caps,
vendor: "mock".into(),
payer: String::new(),
starts_unix_ms: NOW,
market: 0,
renew: false,
actor_ticket: Vec::new(),
bundle: bundle.into(),
users: None,
}))
.await
.map(|r| r.into_inner())
}
}
/// ★ **A bundle is priced from the price list, and the window and the quote are
/// the same number because they are the same writer.**
///
/// The deployment prices disk at 2200 öre/GiB-month (`TermsStore::fixture`) and
/// the bundle declares no amount at all — there is nowhere for it to restate a
/// price. The proof is the equality with the typed quote for the caps the
/// bundle resolves to: `220.00 SEK`, from `Money::in_words`, on both wires.
#[tokio::test]
async fn a_bundle_is_priced_from_the_price_list_and_never_restated() {
let rig = Rig::up(the_shop()).await;
let bundles = rig.bundles("alice").await.expect("the window");
assert_eq!(bundles.len(), 1, "one declared bundle, one row: {bundles:?}");
let b = &bundles[0];
assert_eq!(b.id, "10gb-1m", "the operator's key, echoed back unparsed");
assert_eq!(b.label, "10.0 GiB more room", "monetize's own words for the delta");
assert_eq!(b.price, "220.00 SEK", "10 GiB × 22.00 SEK/GiB-month — from [prices], never from the bundle");
assert_eq!(b.period, "month", "the deployment's [policy] period");
assert_eq!(b.delta, [("pack_bytes".to_string(), 10 * GIB)].into(), "the cap increase, for the console's own byte formatter");
assert!(b.can_serve && b.reason.is_empty(), "the mock product absorbs it: {b:?}");
// **The same money, by the other door.** alice holds 10 GiB, so the typed
// order that buys this bundle is a target of 20 GiB.
let typed = rig.quote_caps("alice", [("pack_bytes".to_string(), 20 * GIB)].into()).await.expect("a quote");
assert_eq!(typed.price_words, b.price, "the window and the confirm button must not be two opinions about the price");
assert_eq!(typed.delta, b.delta, "and not two opinions about what is sold");
assert_eq!(typed.period, b.period);
// And the price FOLLOWS the list: double the unit, double the window, same
// declared bundle. Nothing else in this deployment changed.
let dearer = monetize::PriceList { units: [("disk_gib_month".to_string(), 4_400u64)].into(), ..price_list() };
rig.state.terms.write().unwrap().prices.insert("gunnar".to_string(), [(monetize_pay::Market::Se, dearer)].into());
let b = rig.bundles("alice").await.expect("the window").remove(0);
assert_eq!(b.price, "440.00 SEK", "one source of prices, and the bundle followed it without being edited");
}
/// ★ **A bundle whose meters the price list cannot price is NOT OFFERED, and
/// the customer is told nothing about it at all.**
///
/// `cpu_millicores` is backed by `cpu_core_month`, which the fixture list does
/// not price while it does price disk. Giving it away would be a sale at a
/// price nobody chose, so it is absent from the window — and absent from the
/// order door too, because a stale console page must not be able to buy it.
/// The operator's sentence (which names the config key and the missing unit)
/// goes to the server's log; `monetize::bundle`'s own tests hold its wording.
/// ★ **The window over the wire is cheapest first** (owner, gunnar.rs 2026-10-27: signed in,
/// Account → "Grow your plan" listed 100 GB first). The ONE box's gunnar bundles are
/// `"10gb"`, `"50gb"`, `"100gb"`, a map whose keys sort as text (`"100gb"` first); the page
/// draws the rows in the order it is given, so the order is this answer's.
#[tokio::test]
async fn the_window_is_cheapest_first_whatever_the_ids_sort_as() {
let shop: BTreeMap<String, BundleSpec> = [
("10gb".to_string(), spec(&[("pack_bytes", 10 * GIB)], None)),
("50gb".to_string(), spec(&[("pack_bytes", 50 * GIB)], None)),
("100gb".to_string(), spec(&[("pack_bytes", 100 * GIB)], None)),
]
.into();
let rig = Rig::up(shop).await;
let bundles = rig.bundles("alice").await.expect("the window");
let ids: Vec<&str> = bundles.iter().map(|b| b.id.as_str()).collect();
assert_eq!(ids, ["10gb", "50gb", "100gb"], "the smallest step first: {bundles:?}");
assert_eq!(bundles[0].price, "220.00 SEK", "the first row is the cheapest: {bundles:?}");
}
#[tokio::test]
async fn a_bundle_the_price_list_cannot_price_is_not_offered_and_the_operator_is_told_why() {
let mut shop = the_shop();
shop.insert("cores".to_string(), spec(&[("cpu_millicores", 2_000)], None));
// A second way to be unpriceable: a meter that does not exist. It would
// price at zero for want of a backing — a silent gift.
shop.insert("typo".to_string(), spec(&[("pack_byes", 10 * GIB)], None));
let rig = Rig::up(shop).await;
let offered: Vec<String> = rig.bundles("alice").await.expect("the window").into_iter().map(|b| b.id).collect();
assert_eq!(offered, ["10gb-1m"], "only the bundle this deployment priced is on sale");
// The order door refuses it by the same rule, and the refusal says nothing
// about price lists, units or config keys — that is the operator's half.
let err = rig.start("alice", BTreeMap::new(), "cores").await.unwrap_err();
assert_eq!(err.code(), Code::FailedPrecondition, "{err:?}");
assert!(err.message().contains("not on sale"), "{}", err.message());
for word in ["cpu_core_month", "price list", "[prices", "cap_delta"] {
assert!(!err.message().contains(word), "the customer's refusal must not read as a config error: {}", err.message());
}
}
/// ★ **An appliance that did not answer makes every bundle `can_serve: false`,
/// and the customer's sentence names no machine.**
///
/// This is the rule the whole window hangs on: monetize never turns silence
/// into a yes. `products/gunnar`'s `absorbs` refuses `Capacity::Unanswered`
/// with a reason that says "THAT IS A MEASUREMENT THAT DID NOT HAPPEN" and
/// names the data set; here the mock refuses with a reason of exactly that
/// shape, and what reaches the wire is `words::cannot_add_room` for an
/// UNMEASURED store — no infrastructure vocabulary, no invented `0 B`, and
/// nothing charged.
#[tokio::test]
async fn an_unmeasured_appliance_makes_every_bundle_unservable_with_no_infrastructure_vocabulary_in_the_customers_sentence() {
let rig = Rig::up(the_shop()).await;
rig.product.cannot_absorb(
"gunnar did not answer whether it can serve 20.0 GiB of stored bytes: the Admin.Capacity call did not complete. \
THAT IS A MEASUREMENT THAT DID NOT HAPPEN, not a full disk — the data set is a 4x75 GB raid0 xfs set and gunnar \
is PID 1 with no shell",
);
let bundles = rig.bundles("alice").await.expect("the window");
assert_eq!(bundles.len(), 1, "an unservable bundle is still SHOWN, with the reason — it is not hidden");
let b = &bundles[0];
assert!(!b.can_serve, "silence is not a yes: {b:?}");
// Round 5.3: the offer's one line, which names no room, no measurement
// and no number (owner 2026-10-08: the customer never reads a cap).
assert_eq!(b.reason, monetize::transaction::words::NOT_OFFERED_HERE, "silence is not a yes, and never 0 B: {}", b.reason);
assert!(!b.reason.contains("0 B"), "{}", b.reason);
assert!(!b.reason.to_lowercase().contains("maintenance"), "an unmeasured store is not maintenance: {}", b.reason);
// An OFFER nobody has ordered, not an order's refusal (owner, test2
// 2026-10-05): the window marks it; the order, if placed anyway, says
// nothing was charged.
assert!(!b.reason.contains("Nothing has been charged") && !b.reason.contains("We cannot raise"), "{}", b.reason);
// The vocabulary gate, held against BOTH lists — the transaction's own and
// the signup engine's, because a sentence a customer reads is a sentence a
// customer reads whichever door wrote it.
let lower = b.reason.to_lowercase();
for word in monetize::transaction::words::NEVER_SHOWN.iter().chain(monetize_server::signup::words::NEVER_SHOWN) {
assert!(!lower.contains(&word.to_lowercase()), "{word:?} is infrastructure vocabulary and reached a customer: {}", b.reason);
}
// The price is still the price: a refusal is not a reason to stop saying
// what it would have cost.
assert_eq!(b.price, "220.00 SEK");
}
/// ★ **A deployment that declared no bundles answers an EMPTY list, and the
/// typed order it has always had is untouched.**
///
/// The empty answer is load-bearing: it is what tells a console to fall back to
/// its own form rather than to draw an error. An error here would take the
/// open-source deployment's purchase page down.
#[tokio::test]
async fn a_deployment_with_no_bundles_answers_an_empty_list_and_the_typed_path_still_works() {
let rig = Rig::up(BTreeMap::new()).await;
assert!(rig.bundles("alice").await.expect("not an error").is_empty(), "no bundles is an empty shop, not a broken one");
// The path that predates all of this, byte for byte.
let q = rig.quote_caps("alice", [("pack_bytes".to_string(), 100 * GIB)].into()).await.expect("a typed quote");
assert_eq!(q.delta, [("pack_bytes".to_string(), 90 * GIB)].into(), "the 90 she lacks, not the 100");
assert_eq!(q.price_words, "1980.00 SEK");
let out = rig.start("alice", [("pack_bytes".to_string(), 100 * GIB)].into(), "").await.expect("a typed order");
assert_eq!(out.state, PurchaseState::Paid as i32, "{out:?}");
}
/// ★ **Ordering a bundle and ordering the same caps by hand reach the SAME
/// transaction.**
///
/// Not "an equivalent one": the same reference, the same ledger attempt, the
/// same money. The proof is idempotency — after the bundle is bought, the
/// hand-typed order for the caps it produced comes back PAID against the very
/// reference the bundle created, which is only possible if the bundle resolved
/// to those caps and went down the ordinary `Transaction::order`.
///
/// It is the guard on the rule that there is no second way to spend money here.
#[tokio::test]
async fn ordering_a_bundle_and_ordering_the_same_caps_by_hand_reach_the_same_transaction() {
let rig = Rig::up(the_shop()).await;
// alice holds 10 GiB; the bundle raises her to 20.
let by_hand: BTreeMap<String, u64> = [("pack_bytes".to_string(), 20 * GIB)].into();
// What the two doors QUOTE, before any money moves.
let bundle_row = rig.bundles("alice").await.expect("the window").remove(0);
let typed = rig.quote_caps("alice", by_hand.clone()).await.expect("a quote");
assert_eq!(typed.caps, by_hand, "the bundle's delta over her caps is the hand-typed target");
// BUY IT BY ID.
let bought = rig.start("alice", BTreeMap::new(), "10gb-1m").await.expect("the bundle sells");
assert_eq!(bought.state, PurchaseState::Paid as i32, "{bought:?}");
assert_eq!(bought.reference, typed.reference, "the bundle ran the reference the typed quote named");
assert_eq!(bought.words, monetize::transaction::words::ORDER_GRANTED, "the ordinary granted sentence, not a bundle's own");
// The ordinary reference shape — `<product>/<tenant>/<date>+<caps>`, with
// the TARGET caps in it. Nothing in it says "bundle": there is no such
// thing by the time the ledger sees it.
assert!(
bought.reference.starts_with("gunnar/alice/") && bought.reference.ends_with(&format!("+pack_bytes={}", 20 * GIB)),
"{}",
bought.reference
);
// NOW THE SAME ORDER BY HAND. It is already filled, so it answers the
// bundle's own reference and moves nothing: one sale, not two.
let again = rig.start("alice", by_hand.clone(), "").await.expect("the hand order");
assert_eq!(again.reference, bought.reference, "the same transaction, reached from the other door");
assert_eq!(again.state, PurchaseState::Paid as i32);
let caps = rig.state.ledger.entitlement("gunnar", "alice").unwrap().expect("alice holds a fact").caps;
assert_eq!(caps, by_hand, "20 GiB, once — not 30 from two sales of the same thing");
// And the price on the window was the price that was charged.
let attempt = rig.state.ledger.attempt(&bought.reference).unwrap().expect("the attempt");
assert_eq!(attempt.purchase.price.in_words(), bundle_row.price, "the shop window quoted what the ledger recorded");
}
/// A request that names a bundle AND caps is refused rather than having one of
/// them win silently: a client that sends both disagrees with itself about what
/// it is buying, and the loser would be the one the customer looked at.
#[tokio::test]
async fn a_request_naming_both_a_bundle_and_caps_is_refused_by_name() {
let rig = Rig::up(the_shop()).await;
let err = rig.start("alice", [("pack_bytes".to_string(), 99 * GIB)].into(), "10gb-1m").await.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument, "{err:?}");
assert!(err.message().contains("OR caps"), "{}", err.message());
}
/// ★ **g-dk-market / g-currencies: a FULL box whose data set GROWS AT RUNTIME
/// under the cloud's capped growth still sells what the batcher can deliver.**
///
/// MEASURED on the release-3 mock 2026-09-29 (proof runner, a-money-1.log):
/// after the functest + SE + FI orders (30 GiB sold on a 37.8 GiB set) the DK
/// window answered `{"offers":[],"withheld":3}` — `default_cloud = server`
/// asks the product about the box's CURRENT room only, and gunnar's own
/// refusal said "AND THE SET GROWS WITHOUT A RESTART". The estate's order
/// cloud is the box; its SET is grown by the grow batcher through the capped
/// upcloud guest (`[products.gunnar.grow]`), so the window must count the
/// growable reach — the room on hand plus what the monthly ceiling can still
/// buy (`grow::affordable_bytes`, the batcher's own clamp). A sealed box, or
/// one with no grow set, still refuses; since round 5.3 a set the wallet
/// cannot grow NOW is offered and its order queued for the resize.
#[tokio::test]
async fn a_full_box_whose_set_grows_at_runtime_under_the_cap_sells_what_the_growth_can_deliver() {
use monetize::testing::GrowScript;
use monetize_cloud::cap::{Ceiling, SpendCap};
use monetize_product::Growth;
const FULL: &str = "gunnar cannot serve 20.0 GiB of stored bytes (pack_bytes + lfs_bytes): /data has 8.0 GiB free — short 12.0 GiB. \
AND THE SET GROWS WITHOUT A RESTART";
async fn rig(tag: &str, monthly_minor: u64, growth: Growth, set_configured: bool) -> Rig {
let product = Arc::new(MockProduct::gunnar());
product.grows(vec!["draining", "unmounted", "growing", "mounting", "done"], true);
product.cannot_absorb(FULL);
product.can_serve_growing(8 * GIB, 40 * GIB, growth);
let cloud = MockCloud::new();
cloud.answer_all(Answer::Provisioned);
// 4 members at 10 GiB, 23 cents per GiB-month per member.
cloud.grows(GrowScript { current_gib: 10, per_gib_minor: 23, ..GrowScript::default() });
let spend = std::env::temp_dir().join(format!("bundles-growable-{tag}-{}.spend", std::process::id()));
let _ = std::fs::remove_file(&spend);
let capped = SpendCap::new(cloud, Ceiling::monthly(monthly_minor, monetize_pay::Currency::Eur), &spend).expect("a ceiling");
let mut state = AppState::in_memory(Registry::new().shared_product(product.clone()).sells("gunnar", MockPay::new()).cloud_capped(capped));
*state.terms.write().unwrap() = monetize_server::state::TermsStore::fixture();
state.clock = Box::new(|| NOW);
*state.bundles.write().unwrap() = [("gunnar".to_string(), the_shop())].into();
if set_configured {
let set = monetize_server::config::GrowSetConfig { cloud: "mockcloud".into(), server: "srv-1".into(), members: ["v1", "v2", "v3", "v4"].iter().map(|m| m.to_string()).collect() };
state.grow.add("gunnar", set, None, None).expect("the set");
}
let state = Arc::new(state);
let server = TestServer::spawn(state.clone(), BEARER).await;
let channel = Channel::from_shared(server.endpoint()).unwrap().connect().await.expect("connect");
Rig { state, product, _server: server, channel }
}
// 50.00 EUR a month buys 50_00 / (4 × 23) = 54 GiB more per member: the
// set can reach 8 + 216 GiB, so bob's first 10 GiB — past the 8 on hand —
// is sold.
let grows = rig("grows", 5_000, Growth::AtRuntime("grows in place".into()), true).await;
let b = grows.bundles("bob").await.expect("the window").remove(0);
assert!(b.can_serve, "the set grows at runtime under a 50 EUR ceiling: the batcher delivers the room — {b:?}");
let q = grows.quote_caps("bob", [("pack_bytes".to_string(), 10 * GIB)].into()).await.expect("a quote");
assert!(q.can_serve, "the confirm button says what the window said: {q:?}");
// A SEALED box grows for nobody: the refusal stands.
let sealed = rig("sealed", 5_000, Growth::Sealed("PID 1, no grow engine".into()), true).await;
assert!(!sealed.bundles("bob").await.expect("the window").remove(0).can_serve, "a sealed box is only its current room");
// No set configured for the batcher: nothing will grow it.
let unset = rig("unset", 5_000, Growth::AtRuntime("grows in place".into()), false).await;
assert!(!unset.bundles("bob").await.expect("the window").remove(0).can_serve, "no [products.gunnar.grow]: no batcher, no reach");
// A wallet that cannot buy even one GiB per member (0.50 EUR < 4 × 0.23
// EUR): the set cannot grow NOW, and 10 GiB > the 8 on hand. Round 5.3
// (owner 2026-10-08: "the customer must not know about a cap! it must be
// queued and resized later"): the set is resized later, so the bundle is
// OFFERED — the order is queued for the resize and the operator is told
// to raise the cap; the customer reads no cap.
let poor = rig("poor", 50, Growth::AtRuntime("grows in place".into()), true).await;
let b = poor.bundles("bob").await.expect("the window").remove(0);
assert!(b.can_serve && b.reason.is_empty(), "past the capped growth, offered and queued — never a cap told to the customer: {b:?}");
}