crates/monetize-ui-server/src/test_purchase.rs
//! **THE TEST BUTTON** (owner 2026-10-11): each product's declared test actions
//! and the runs they made, reshaped from monetize's `TestPurchase` service for
//! the browser. Thin: monetize decides what is offered, whether it may run and
//! what a run did; this side only carries it.
//!
//! ```text
//! GET /api/test-purchase → {actions: [ActionJson], runs: [RunJson]}
//! POST /api/test-purchase/start {product, action, site, live} → RunJson
//! GET /api/test-purchase/run/<id> → RunJson
//! ```
//!
//! The POST is a mutation under `/api/`, so the console's own gate floors it
//! at admin with a fresh passkey touch (`auth::role_floor`, `needs_step_up`);
//! monetize refuses anyone but the operator a second time.
use axum::extract::State;
use axum::Json;
use monetize_proto as pb;
use serde::{Deserialize, Serialize};
use crate::api::ApiError;
use crate::app::AppState;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
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,
/// The site the button sells as (its estate) and that site's host (ST-8).
pub site: String,
pub site_host: String,
/// The site's vendor key is LIVE: a press is a real purchase the operator pays himself.
pub live: bool,
/// ★ A33-11: monetize's one sentence for the button.
pub words: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LineJson {
pub at_unix_ms: u64,
pub source: String,
pub line: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
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,
/// LIVE: the vendor's own checkout page the operator pays at himself.
pub pay_url: String,
pub live: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TestPurchaseJson {
pub actions: Vec<ActionJson>,
pub runs: Vec<RunJson>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct StartBody {
pub product: String,
pub action: String,
/// The offer's site; monetize refuses a press that names none where one is needed.
#[serde(default)]
pub site: String,
/// The operator's "LIVE — you will be charged"; monetize refuses a live press without it.
#[serde(default)]
pub live: bool,
}
/// Pure: the press as monetize takes it — carried, never decided here.
pub fn start_request(body: StartBody) -> pb::TestPurchaseStartRequest {
pb::TestPurchaseStartRequest { product: body.product, action: body.action, site: body.site, live: body.live }
}
/// Pure: one offer for the browser.
pub fn action_json(a: &pb::TestActionOffer) -> ActionJson {
let sizes = a.sizes.clone().unwrap_or_default();
ActionJson {
product: a.product.clone(),
id: a.id.clone(),
label: a.label.clone(),
kind: a.kind.clone(),
cap_gib: a.cap_gib,
front_plan: sizes.front_plan,
main_plan: sizes.main_plan,
main_data_gib: sizes.main_data_gib,
runnable: a.runnable,
why_not: a.why_not.clone(),
site: a.site.clone(),
site_host: a.site_host.clone(),
live: a.live,
words: a.words.clone(),
}
}
/// Pure: one run for the browser, its lines as monetize ordered them.
pub fn run_json(r: &pb::TestPurchaseRun) -> RunJson {
RunJson {
id: r.id.clone(),
product: r.product.clone(),
action: r.action.clone(),
kind: r.kind.clone(),
label: r.label.clone(),
username: r.username.clone(),
company: r.company.clone(),
vendor: r.vendor.clone(),
session: r.session.clone(),
started_unix_ms: r.started_unix_ms,
state: r.state.clone(),
words: r.words.clone(),
signup_state: r.signup_state.clone(),
phase: r.phase.clone(),
mailbox: r.mailbox.clone(),
lines: r.lines.iter().map(|l| LineJson { at_unix_ms: l.at_unix_ms, source: l.source.clone(), line: l.line.clone() }).collect(),
site: r.site.clone(),
pay_url: r.pay_url.clone(),
live: r.live,
}
}
/// **Who the console is to monetize on the test button**: the OPERATOR — the
/// console's operator bearer when it holds one (the ONE box: its console sits
/// on loopback beside monetize and its plain client carries the SERVICE
/// bearer, which monetize refuses here — live 2026-10-14, an admin's press got
/// `permission denied`), else its own client, which outside an estate signs
/// every call with the console's key (the "console's signed call").
pub fn door(state: &AppState) -> &crate::grpc::Monetize {
state.operator.as_ref().unwrap_or(&state.monetize)
}
/// `GET /api/test-purchase`: every declared action and the last 20 runs.
pub async fn list(State(state): State<AppState>) -> Result<Json<TestPurchaseJson>, ApiError> {
let m = door(&state).clone();
let actions = m.test_purchase().actions(pb::TestActionsRequest::default()).await?.into_inner().actions;
let runs = m.test_purchase().list(pb::TestPurchaseListRequest { limit: 20 }).await?.into_inner().runs;
Ok(Json(TestPurchaseJson { actions: actions.iter().map(action_json).collect(), runs: runs.iter().map(run_json).collect() }))
}
/// `POST /api/test-purchase/start` `{product, action, site, live}`.
pub async fn start(State(state): State<AppState>, Json(body): Json<StartBody>) -> Result<Json<RunJson>, ApiError> {
let r = door(&state).test_purchase().start(start_request(body)).await?.into_inner();
Ok(Json(run_json(&r)))
}
#[cfg(test)]
mod tests {
use super::*;
/// ★ **An offer and a run reach the browser whole** — the button's words,
/// whether it may be pressed and why not, and every setup line in order.
#[test]
fn an_offer_and_a_run_reach_the_browser_whole() {
let a = pb::TestActionOffer {
product: "gunnar".into(),
id: "private-small".into(),
label: "Test: private Gunnar small".into(),
kind: "private_instance".into(),
cap_gib: 0,
sizes: Some(pb::EstateSizes { front_plan: "1xCPU-1GB".into(), main_plan: String::new(), main_data_gib: 20, ..Default::default() }),
runnable: false,
why_not: "stripe/gunnar: the vendor's key is not a TEST key".into(),
..Default::default()
};
let j = action_json(&a);
assert_eq!((j.id.as_str(), j.kind.as_str(), j.front_plan.as_str(), j.main_data_gib, j.runnable), ("private-small", "private_instance", "1xCPU-1GB", 20, false));
assert!(j.why_not.contains("TEST key"));
let r = pb::TestPurchaseRun {
id: "tp-1".into(),
state: "running".into(),
phase: "installing".into(),
lines: vec![
pb::TestPurchaseLine { at_unix_ms: 1, source: "order".into(), line: "held".into() },
pb::TestPurchaseLine { at_unix_ms: 2, source: "acme-main".into(), line: "serving".into() },
],
..Default::default()
};
let j = run_json(&r);
assert_eq!((j.state.as_str(), j.phase.as_str()), ("running", "installing"));
assert_eq!(j.lines.iter().map(|l| (l.source.as_str(), l.line.as_str())).collect::<Vec<_>>(), [("order", "held"), ("acme-main", "serving")]);
}
/// ★ ST-8: **the site and LIVE reach the browser, and go back with the press** — an
/// offer's site and host and whether its key is live; a run's site and the vendor's
/// checkout page the operator pays at; the press carries the site and the confirmation.
#[test]
fn the_site_and_live_reach_the_browser_and_ride_the_press() {
let a = pb::TestActionOffer { product: "gunnar".into(), id: "hosted-10gb".into(), site: "test2-gunnar-rs".into(), site_host: "test2.gunnar.rs".into(), live: true, runnable: true, ..Default::default() };
let j = action_json(&a);
assert_eq!((j.site.as_str(), j.site_host.as_str(), j.live), ("test2-gunnar-rs", "test2.gunnar.rs", true));
let r = run_json(&pb::TestPurchaseRun { site: "test2-gunnar-rs".into(), pay_url: "https://checkout.stripe.com/c/pay/cs_live_x".into(), live: true, ..Default::default() });
assert_eq!((r.site.as_str(), r.pay_url.as_str(), r.live), ("test2-gunnar-rs", "https://checkout.stripe.com/c/pay/cs_live_x", true));
let body: StartBody = serde_json::from_str(r#"{"product":"gunnar","action":"hosted-10gb","site":"test2-gunnar-rs","live":true}"#).unwrap();
let req = start_request(body);
assert_eq!((req.site.as_str(), req.live), ("test2-gunnar-rs", true));
let old: StartBody = serde_json::from_str(r#"{"product":"gunnar","action":"hosted-10gb"}"#).unwrap();
assert_eq!((start_request(old).site, false), (String::new(), false), "no site, no confirmation: monetize decides");
}
}