crates/monetize-ui-web/src/test_buy.rs
//! **THE TEST BUTTON** (owner 2026-10-11) — every decision the Test buy page
//! takes, compiled for the host and tested there; `pages::test_buy` only lays
//! them out.
//!
//! Each PRODUCT declares its test actions (monetize's `TestPurchase.Actions`):
//! one button each, nothing else — the console never invents an action, a plan
//! or a size. A press makes a real account (and, for a private instance, real
//! machines) through the sign-up page's own path, paid with the vendor's TEST
//! payment method; the run's lines are the setup's own, kept in monetize.
//!
//! ```text
//! Test buy: per product, one button per declared action and site (disabled, with why, when
//! it cannot run; "LIVE — you will be charged" on a live key: a real purchase,
//! paid by the operator at the vendor's page the run hands back)
//! runs: started | product | action | account | instance | state | where
//! one run: its lines (when | from | what)
//! ```
use serde::Deserialize;
use crate::fmt::{since, Tone};
/// The runs table's columns.
pub const RUN_COLUMNS: [&str; 7] = [
"started", "product", "action", "account", "instance", "state", "where",
];
/// One run's line columns.
pub const LINE_COLUMNS: [&str; 3] = ["when", "from", "what"];
/// A cell with nothing to say.
pub const NONE: &str = "—";
/// What the browser posts to.
pub const START_API: &str = "/api/test-purchase/start";
pub const LIST_API: &str = "/api/test-purchase";
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct ActionJson {
pub product: String,
pub id: String,
pub label: String,
pub kind: String,
pub cap_gib: u64,
pub front_plan: String,
pub main_plan: String,
pub main_data_gib: u64,
pub runnable: bool,
pub why_not: String,
pub site: String,
pub site_host: String,
pub live: bool,
/// ★ A33-11: monetize's one sentence for the button (real money on a LIVE key, none on TEST).
pub words: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct LineJson {
pub at_unix_ms: u64,
pub source: String,
pub line: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct RunJson {
pub id: String,
pub product: String,
pub action: String,
pub kind: String,
pub label: String,
pub username: String,
pub company: String,
pub vendor: String,
pub session: String,
pub started_unix_ms: u64,
pub state: String,
pub words: String,
pub signup_state: String,
pub phase: String,
pub mailbox: String,
pub lines: Vec<LineJson>,
pub site: String,
pub pay_url: String,
pub live: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct TestPurchaseJson {
pub actions: Vec<ActionJson>,
pub runs: Vec<RunJson>,
}
/// The actions grouped under their product, in the order monetize answered.
pub fn by_product(actions: &[ActionJson]) -> Vec<(String, Vec<ActionJson>)> {
let mut out: Vec<(String, Vec<ActionJson>)> = Vec::new();
for a in actions {
match out.iter_mut().find(|(p, _)| *p == a.product) {
Some((_, v)) => v.push(a.clone()),
None => out.push((a.product.clone(), vec![a.clone()])),
}
}
out
}
/// What the action buys, in words — from its declaration, never a price.
pub fn buys(a: &ActionJson) -> String {
match a.kind.as_str() {
"hosted_plan" => format!(
"a hosted account with {} GiB, at the plan the sign-up page offers for it now",
a.cap_gib
),
"private_instance" => {
let sizes: Vec<String> = [
("front", a.front_plan.as_str()),
("main", a.main_plan.as_str()),
]
.iter()
.filter(|(_, v)| !v.is_empty())
.map(|(k, v)| format!("{k} {v}"))
.collect();
let data = if a.main_data_gib > 0 {
format!(", {} GiB data", a.main_data_gib)
} else {
String::new()
};
let sizes = if sizes.is_empty() && data.is_empty() {
"the ladder's default sizes".to_owned()
} else {
format!("{}{data}", sizes.join(", "))
};
format!("an account and a private instance ({sizes}): real machines")
}
other => format!("{other} (a kind this console does not know)"),
}
}
/// The words LIVE buttons and runs carry (monetize's `test_purchase::LIVE_WORDS`).
pub const LIVE_WORDS: &str = "LIVE — you will be charged";
/// The button's words: its label, the site it sells as, and LIVE when the key is live (ST-8).
pub fn button_words(a: &ActionJson) -> String {
// ★ A33-11: a LIVE key's press is a real purchase: its label says no "Test:".
let mut w = if a.live {
a.label.trim_start_matches("Test:").trim_start().to_owned()
} else {
a.label.clone()
};
if !a.site_host.is_empty() {
w.push_str(&format!(" · {}", a.site_host));
}
if a.live {
w.push_str(&format!(" · {LIVE_WORDS}"));
}
w
}
/// What a press posts: the action, its site, and on a LIVE key the operator's confirmation
/// (the button itself says "LIVE — you will be charged").
pub fn start_body(a: &ActionJson) -> serde_json::Value {
serde_json::json!({ "product": a.product, "action": a.id, "site": a.site, "live": a.live })
}
/// A LIVE run's way to pay: the words and the vendor's own https checkout page.
pub fn pay_link(r: &RunJson) -> Option<(String, String)> {
(r.live && r.pay_url.starts_with("https://")).then(|| {
(
format!("{LIVE_WORDS}: pay at the vendor's page"),
r.pay_url.clone(),
)
})
}
/// Is the button pressable, and if not, why (shown as its title and beside it).
pub fn press(a: &ActionJson, is_admin: bool) -> (bool, String) {
if !a.runnable {
return (false, a.why_not.clone());
}
if !is_admin {
return (false, "a test buy needs an admin passkey".into());
}
(true, String::new())
}
/// The badge tone of a run's state.
pub fn state_tone(state: &str) -> Tone {
match state {
"done" => Tone::Pass,
"failed" => Tone::Bad,
"running" => Tone::Live,
_ => Tone::Plain,
}
}
fn or_none(s: &str) -> String {
if s.is_empty() {
NONE.to_owned()
} else {
s.to_owned()
}
}
/// One run's row: started | product | action | account | instance | state | where.
pub fn run_cells(r: &RunJson, now_ms: u64) -> [String; 7] {
let account = if r.signup_state.is_empty() {
or_none(&r.username)
} else {
format!("{} ({})", r.username, r.signup_state.to_lowercase())
};
let instance = if r.company.is_empty() {
NONE.to_owned()
} else if r.phase.is_empty() {
r.company.clone()
} else {
format!("{} ({})", r.company, r.phase)
};
[
since(r.started_unix_ms, now_ms),
r.product.clone(),
or_none(&r.label),
account,
instance,
or_none(&r.state),
or_none(&r.words),
]
}
/// One line's row: when | from | what.
pub fn line_cells(l: &LineJson, now_ms: u64) -> [String; 3] {
[
since(l.at_unix_ms, now_ms),
l.source.clone(),
l.line.clone(),
]
}
/// **A refusal of the test button, in plain words** — a headline and what to
/// do; never a raw status or a wire code (live 2026-10-14: an admin saw
/// `HTTP 403 · monetize: permissiondenied`).
pub fn refusal_words(f: &crate::fmt::Fail) -> (String, String) {
match (f.status, f.code.as_str()) {
(403, "stepup") => (f.summary().to_owned(), String::new()),
(403, _) | (_, "permissiondenied") => (
"Only the operator can make a test buy.".to_owned(),
"This console could not show monetize that it acts for the operator, so nothing was bought. Sign in with an admin passkey on the operator's own console.".to_owned(),
),
(_, "unavailable") if f.message.contains("operator credential") => ("This console cannot make a test buy.".to_owned(), "It holds no operator credential; nothing was bought.".to_owned()),
_ => {
let detail = f.message.trim().trim_start_matches("REFUSED").trim().to_owned();
(f.summary().to_owned(), detail)
}
}
}
/// The screen as plain text — what the tests read.
pub fn page_text(j: &TestPurchaseJson, is_admin: bool, now_ms: u64) -> String {
let mut out = String::new();
for (product, actions) in by_product(&j.actions) {
out.push_str(&format!("{product}\n"));
for a in actions {
let (ok, why) = press(&a, is_admin);
out.push_str(&format!(
" [{}]{} — {}{}\n",
button_words(&a),
if ok { "" } else { " (disabled)" },
if a.words.is_empty() {
buys(&a)
} else {
a.words.clone()
},
if why.is_empty() {
String::new()
} else {
format!(" — {why}")
}
));
}
}
if j.actions.is_empty() {
out.push_str("No product declares a test action.\n");
}
for r in &j.runs {
out.push_str(&run_cells(r, now_ms).join(" | "));
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// ★ live 2026-10-14: a refusal is said in words — no status number, no wire code.
#[test]
fn a_refusal_is_said_in_plain_words() {
let f = crate::fmt::Fail { status: 403, code: "permissiondenied".into(), message: "the test button is the operator's: the operator bearer, or the console's signed call".into() };
let (head, detail) = refusal_words(&f);
assert_eq!(head, "Only the operator can make a test buy.");
for raw in ["403", "permissiondenied", "bearer", "HTTP"] {
assert!(
!head.contains(raw) && !detail.contains(raw),
"{raw}: {head} {detail}"
);
}
let (head, _) = refusal_words(&crate::fmt::Fail { status: 503, code: "unavailable".into(), message: "this console holds no operator credential (monetize's operator-token), so it cannot refund or settle; nothing was sent".into() });
assert_eq!(head, "This console cannot make a test buy.");
}
fn action(product: &str, id: &str, kind: &str, runnable: bool) -> ActionJson {
ActionJson {
product: product.into(),
id: id.into(),
label: format!("Test: {id}"),
kind: kind.into(),
cap_gib: 10,
runnable,
why_not: if runnable {
String::new()
} else {
"stripe/gunnar: the vendor's key is not a TEST key".into()
},
..Default::default()
}
}
/// ★ **One button per declared action, under its product; the console
/// invents none** — and a button that cannot run says why instead of
/// pressing into a refusal; a viewer cannot press at all.
#[test]
fn one_button_per_declared_action_and_a_disabled_one_says_why() {
let j = TestPurchaseJson {
actions: vec![
action("gunnar", "hosted-10gb", "hosted_plan", true),
action("gunnar", "private-small", "private_instance", false),
action("holger", "hosted-10gb", "hosted_plan", true),
],
runs: vec![],
};
let text = page_text(&j, true, 0);
assert_eq!(
by_product(&j.actions)
.iter()
.map(|(p, a)| (p.as_str(), a.len()))
.collect::<Vec<_>>(),
[("gunnar", 2), ("holger", 1)]
);
assert!(
text.contains("[Test: hosted-10gb] — a hosted account with 10 GiB"),
"{text}"
);
assert!(text.contains("[Test: private-small] (disabled) — an account and a private instance (the ladder's default sizes): real machines — stripe/gunnar: the vendor's key is not a TEST key"), "{text}");
// A33-11: what money moves is the server's sentence (from the key verdict), shown as sent.
let mut said = j.clone();
said.actions[0].words = "Test purchase on test2.gunnar.rs: real machines, paid with the vendor's test card — no money moves.".into();
assert!(page_text(&said, true, 0).contains("[Test: hosted-10gb] — Test purchase on test2.gunnar.rs: real machines, paid with the vendor's test card — no money moves."), "{}", page_text(&said, true, 0));
assert_eq!(
press(&j.actions[0], false),
(false, "a test buy needs an admin passkey".into())
);
assert!(page_text(&TestPurchaseJson::default(), true, 0)
.contains("No product declares a test action."));
}
/// ★ ST-8: **a LIVE button says so, per site** — "LIVE — you will be charged", the site
/// it sells as; and a live run hands the operator the vendor's checkout page to pay at.
#[test]
fn a_live_button_says_you_will_be_charged_and_names_its_site() {
let live = ActionJson {
site: "gunnar-rs".into(),
site_host: "gunnar.rs".into(),
live: true,
..action("gunnar", "hosted-10gb", "hosted_plan", true)
};
let test = ActionJson {
site: "test2-gunnar-rs".into(),
site_host: "test2.gunnar.rs".into(),
..action("gunnar", "hosted-10gb", "hosted_plan", true)
};
assert_eq!(
button_words(&live),
"hosted-10gb · gunnar.rs · LIVE — you will be charged",
"A33-11: no Test: on a real purchase"
);
assert_eq!(button_words(&test), "Test: hosted-10gb · test2.gunnar.rs");
assert!(page_text(
&TestPurchaseJson {
actions: vec![live.clone(), test],
runs: vec![]
},
true,
0
)
.contains("[hosted-10gb · gunnar.rs · LIVE — you will be charged]"));
assert_eq!(
start_body(&live),
serde_json::json!({ "product": "gunnar", "action": "hosted-10gb", "site": "gunnar-rs", "live": true })
);
let run = RunJson {
live: true,
pay_url: "https://checkout.stripe.com/c/pay/cs_live_x".into(),
..Default::default()
};
assert_eq!(
pay_link(&run),
Some((
"LIVE — you will be charged: pay at the vendor's page".to_string(),
"https://checkout.stripe.com/c/pay/cs_live_x".to_string()
))
);
assert_eq!(
pay_link(&RunJson {
pay_url: "http://x".into(),
..run
}),
None,
"only an https page is offered"
);
}
/// ★ **A run reads as where it stands**: the account and its signup state,
/// the instance and its phase, the run's state and monetize's words; its
/// lines in monetize's order.
#[test]
fn a_run_reads_as_where_it_stands() {
let r = RunJson {
product: "gunnar".into(),
label: "Test: private Gunnar small".into(),
username: "test-261011153012".into(),
company: "tb261011153012".into(),
signup_state: "CREATED".into(),
phase: "installing".into(),
state: "running".into(),
words: "installing".into(),
started_unix_ms: 0,
lines: vec![LineJson {
at_unix_ms: 0,
source: "installer".into(),
line: "pairing 3 machine(s)".into(),
}],
..Default::default()
};
let [_, product, action, account, instance, state, words] = run_cells(&r, 0);
assert_eq!(
(
product.as_str(),
action.as_str(),
account.as_str(),
instance.as_str(),
state.as_str(),
words.as_str()
),
(
"gunnar",
"Test: private Gunnar small",
"test-261011153012 (created)",
"tb261011153012 (installing)",
"running",
"installing"
)
);
assert_eq!(state_tone("done"), Tone::Pass);
assert_eq!(state_tone("failed"), Tone::Bad);
assert_eq!(
line_cells(&r.lines[0], 0)[1..],
["installer".to_string(), "pairing 3 machine(s)".to_string()]
);
let hosted = RunJson {
company: String::new(),
..r
};
assert_eq!(run_cells(&hosted, 0)[4], NONE);
}
}