uitest/src/signuppages.rs
//! ★ **The first page's two buttons and the sign-up page's account choice, in
//! a real browser** (owner 2026-10-15).
//!
//! * The first page (`login.html` as a METERED console serves it, the three
//! `login-offer.*` files spliced in as `auth::routes::login_html` does):
//! "Sign up account" and "Log in" are TWO buttons side by side on one row,
//! the same width; the free line reads "Host your own (setup guide)";
//! "Sign up account" opens the sign-up page.
//! * The sign-up page (`signup.html`, byte for byte): "Sign up for an
//! account"; the LEFT choice, "Account on <host>", selected, with a list of
//! three — the free account (read only), 10 GB · 1 month, 10 GB · 6 months,
//! each with the price the ONE box words; the server picture, its − / + and
//! its price hidden until "Private Gunnar instance" is pressed.
//! * The order: every row makes the account the same way (`gunnar/account`);
//! a plan comes back to the sign-up page to be paid, the free one goes on
//! to the console.
//!
//! Both pages are served by a stub on loopback that answers the console's
//! JSON, and driven by headless Chrome over the DevTools Protocol with REAL
//! mouse clicks at each element's own box. Pictures go to
//! `$SIGNUPPAGES_SHOTS` when it is set.
//!
//! Needs `GUNNAR_UI_CHROME` (a Chrome or chrome-headless-shell binary);
//! without it each test says NOT RUN and returns.
#![cfg(test)]
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::cdp::{Cdp, Chrome};
use crate::json::{esc, parse, Json};
const LOGIN: &str = include_str!("../../crates/gunnar-ui-server/src/auth/login.html");
const SIGNUP: &str = include_str!("../../crates/gunnar-ui-server/src/auth/signup.html");
/// The first page as a metered console assembles it (`auth::routes::login_html`). Since LOGIN-1
/// (gunnar-ui 7b5ae4a, owner 2026-10-27) nothing is spliced in: /login is the login, and sign-up
/// is the front page's; the three `login-offer.*` files are gone, and their `include_str!`
/// kept this whole crate from compiling (lane ACCT-PAGE, 2026-10-29). The first-page tests here
/// still look for the old "Sign up account" button: stink row ACCT-UITEST.
fn metered_login() -> String {
LOGIN.to_owned()
}
/// A visitor on a console that sells both: an account here and a private Gunnar.
const VISITOR: &str = r#"{"authenticated":false,"bootstrap_spent":true,"account_offer":"10GB","buy_url":"/signup?plan=10gb","signup_url":"/signup","private_url":"/signup?plan=private","may_buy":true}"#;
/// The ONE box's account plans as `/auth/signup/ladder` passes them on: its
/// names, its price words and its periods (solo.toml, 2026-10-15).
const PLANS: &str = r#"{"market":"SE","plans":[
{"id":"gunnar/10gb-1m","name":"10 GB","resources_words":"10 GB of room, kept on the server and its replica","price_words":"110.00 SEK a month","period_days":30},
{"id":"gunnar/10gb-6m","name":"10 GB","resources_words":"10 GB of room, kept on the server and its replica","price_words":"594.00 SEK for 180 days","period_days":180}]}"#;
const PRIVATE: &str = r#"{"apex":"gunnar.rs","start":0,"floor":0,"steps":[
{"name":"Mini","resources_words":"2 cores, 40 GB data","monthly_words":"40.00 EUR a month","total_words":"120.00 EUR · 3 months prepaid","available":true,
"sizes":{"front_plan":"f","main_plan":"m","main_data_gib":40},
"machines":[{"role":"front","cores":1,"memory_mib":2048},{"role":"main","cores":2,"memory_mib":4096},{"role":"twin","cores":1,"memory_mib":2048}]},
{"name":"Small","resources_words":"4 cores, 80 GB data","monthly_words":"80.00 EUR a month","total_words":"240.00 EUR · 3 months prepaid","available":true,
"sizes":{"front_plan":"f","main_plan":"m2","main_data_gib":80},
"machines":[{"role":"front","cores":1,"memory_mib":2048},{"role":"main","cores":4,"memory_mib":8192},{"role":"twin","cores":2,"memory_mib":4096}]}]}"#;
type Posted = Arc<Mutex<Vec<(String, String)>>>;
fn serve() -> (u16, Posted) {
serve_answering(r#"{"words":"recorded"}"#)
}
/// [`serve`], answering `POST /auth/signup/start` with `start` (monetize's answer, as the
/// console passes it on).
fn serve_answering(start: &'static str) -> (u16, Posted) {
let l = TcpListener::bind("127.0.0.1:0").expect("a loopback port");
let port = l.local_addr().unwrap().port();
let posted: Posted = Default::default();
let login = Arc::new(metered_login());
let seen = posted.clone();
std::thread::spawn(move || {
for conn in l.incoming().flatten() {
let (login, seen) = (login.clone(), seen.clone());
std::thread::spawn(move || answer(conn, &login, &seen, start));
}
});
(port, posted)
}
fn answer(mut conn: TcpStream, login: &str, posted: &Posted, start: &str) {
let _ = conn.set_read_timeout(Some(Duration::from_secs(5)));
let mut r = BufReader::new(conn.try_clone().unwrap());
let mut line = String::new();
if r.read_line(&mut line).is_err() {
return;
}
let mut parts = line.split_whitespace();
let (method, target) = (parts.next().unwrap_or("").to_owned(), parts.next().unwrap_or("/").to_owned());
let mut len = 0usize;
loop {
let mut h = String::new();
if r.read_line(&mut h).is_err() || h.trim().is_empty() {
break;
}
if let Some(v) = h.to_ascii_lowercase().strip_prefix("content-length:") {
len = v.trim().parse().unwrap_or(0);
}
}
let mut body = vec![0u8; len];
let _ = r.read_exact(&mut body);
let path = target.split('?').next().unwrap_or("/").to_owned();
let (code, kind, out): (u16, &str, String) = match (method.as_str(), path.as_str()) {
("GET", "/login") => (200, "text/html; charset=utf-8", login.to_owned()),
("GET", "/signup") => (200, "text/html; charset=utf-8", SIGNUP.to_owned()),
("GET", "/auth/status") => (200, "application/json", VISITOR.to_owned()),
("GET", "/auth/signup/ladder") => (200, "application/json", PLANS.to_owned()),
("GET", "/auth/private/ladder") => (200, "application/json", PRIVATE.to_owned()),
("GET", "/auth/signup/name") => (200, "application/json", r#"{"free":true}"#.to_owned()),
("POST", p) => {
posted.lock().unwrap().push((p.to_owned(), String::from_utf8_lossy(&body).into_owned()));
let said = if p == "/auth/signup/start" { start } else { r#"{"words":"recorded"}"# };
(200, "application/json", said.to_owned())
}
_ => (404, "text/plain", "no".to_owned()),
};
let head = format!("HTTP/1.1 {code} X\r\ncontent-type: {kind}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", out.len());
let _ = conn.write_all(head.as_bytes());
let _ = conn.write_all(out.as_bytes());
}
fn chrome() -> Option<String> {
let c = std::env::var("GUNNAR_UI_CHROME").ok().filter(|c| !c.is_empty());
if c.is_none() {
eprintln!("NOT RUN: set GUNNAR_UI_CHROME to a Chrome binary to open the first page and the sign-up page");
}
c
}
fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
}
fn open(bin: &str, tag: &str, w: u32, h: u32) -> (Chrome, Cdp, String) {
let port = free_port();
let profile = std::env::temp_dir().join(format!("signuppages-{tag}-{}", std::process::id()));
let chrome = Chrome::launch(bin, &profile, port).expect("chrome starts");
let mut cdp = Cdp::attach(port).expect("cdp");
let sess = cdp.page().expect("a page");
cdp.viewport(&sess, w, h).expect("the viewport");
(chrome, cdp, sess)
}
fn shot(c: &mut Cdp, sess: &str, name: &str) {
if let Ok(dir) = std::env::var("SIGNUPPAGES_SHOTS") {
let _ = std::fs::create_dir_all(&dir);
std::thread::sleep(Duration::from_millis(400));
let _ = c.screenshot(sess, &std::path::Path::new(&dir).join(name));
}
}
/// A real mouse click at the middle of the element `css` names.
fn click(c: &mut Cdp, sess: &str, css: &str) {
let at = c
.eval(sess, &format!("(() => {{ const r = document.querySelector({}).getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; }})()", esc(css)))
.expect("its box");
let xy: Vec<f64> = at.arr().iter().filter_map(Json::as_f64).collect();
for kind in ["mousePressed", "mouseReleased"] {
c.call(Some(sess), "Input.dispatchMouseEvent", &format!(r#"{{"type":"{kind}","x":{},"y":{},"button":"left","clickCount":1}}"#, xy[0], xy[1])).expect("the mouse");
}
}
/// Is the element `id` drawn (not inside anything hidden, not display:none)?
fn visible(c: &mut Cdp, sess: &str, id: &str) -> bool {
c.eval(sess, &format!("(() => {{ const b = document.getElementById({}); if (!b) return false; const s = getComputedStyle(b); return !b.closest('[hidden]') && s.display !== 'none' && s.visibility !== 'hidden' && b.getClientRects().length > 0; }})()", esc(id)))
.unwrap()
.as_bool()
.unwrap_or(false)
}
fn text(c: &mut Cdp, sess: &str, expr: &str) -> String {
c.eval_str(sess, expr).unwrap_or_default()
}
/// The two buttons' boxes: [x, y, width, height] each.
const BOXES: &str = "(() => { const r = (id) => { const b = document.getElementById(id).getBoundingClientRect(); return [b.x, b.y, b.width, b.height]; }; return [r('paid-link'), r('login')]; })()";
fn boxes(c: &mut Cdp, sess: &str) -> (Vec<f64>, Vec<f64>) {
let j = c.eval(sess, BOXES).unwrap();
let both: Vec<Vec<f64>> = j.arr().iter().map(|b| b.arr().iter().filter_map(Json::as_f64).collect()).collect();
(both[0].clone(), both[1].clone())
}
#[test]
fn the_first_page_has_sign_up_account_and_log_in_side_by_side() {
let Some(bin) = chrome() else { return };
let (port, _) = serve();
for (w, h, tag) in [(1440, 900, "desktop"), (390, 844, "phone")] {
let (_chrome, mut c, sess) = open(&bin, &format!("login-{tag}"), w, h);
c.navigate(&sess, &format!("http://127.0.0.1:{port}/login")).expect("the page loads");
c.wait_for(&sess, "!document.getElementById('paid-link').hidden", 15_000).expect("/auth/status revealed the sign-up button");
shot(&mut c, &sess, &format!("1-first-page-{tag}.png"));
assert_eq!(text(&mut c, &sess, "document.getElementById('paid-link').innerText.trim()"), "Sign up account");
assert_eq!(text(&mut c, &sess, "document.getElementById('login').innerText.trim()"), "Log in");
assert_eq!(text(&mut c, &sess, "document.getElementById('paid-link').getAttribute('href')"), "/signup");
let lines = text(&mut c, &sess, "[...document.querySelectorAll('.offers .offer')].map(p => p.innerText.replace(/\\s+/g, ' ').trim()).join(' | ')");
assert_eq!(lines, "Host your own (setup guide)", "{tag}: the line above the buttons");
let page = text(&mut c, &sess, "document.body.innerText");
for gone in ["Free option", "Sign up for paid service", "Log in with passkey"] {
assert!(!page.contains(gone), "{tag}: {gone:?} is still on the first page");
}
let (signup, login) = boxes(&mut c, &sess);
assert!(signup[2] > 60.0 && login[2] > 60.0, "{tag}: both drawn: {signup:?} {login:?}");
assert!((signup[1] - login[1]).abs() < 1.0 && (signup[3] - login[3]).abs() < 1.0, "{tag}: not one row: {signup:?} {login:?}");
assert!((signup[2] - login[2]).abs() <= 1.0, "{tag}: not the same width: {signup:?} {login:?}");
assert!(signup[0] + signup[2] <= login[0], "{tag}: \"Sign up account\" is not the left one: {signup:?} {login:?}");
// The row is the column's width: the two buttons fill it.
let row = c.eval(&sess, "document.querySelector('.entry-buttons').getBoundingClientRect().width").unwrap().as_f64().unwrap_or(0.0);
assert!((login[0] + login[2] - signup[0] - row).abs() <= 1.0, "{tag}: the buttons do not fill the row ({row} px)");
assert_eq!(text(&mut c, &sess, "getComputedStyle(document.getElementById('login')).backgroundColor === getComputedStyle(document.getElementById('paid-link')).backgroundColor ? 'same' : 'two'"), "two", "{tag}: Log in is the primary, Sign up account the other kind");
if tag == "desktop" {
click(&mut c, &sess, "#paid-link");
c.wait_for(&sess, "location.pathname === '/signup' && document.querySelectorAll('#plans button').length === 3", 15_000)
.expect("\"Sign up account\" opens the sign-up page on the account");
}
}
}
/// What the sign-up page shows: heading, choices (pressed marked *), the plan rows
/// (checked marked *), and which of the picture, − / + and price are drawn.
fn read_signup(c: &mut Cdp, sess: &str) -> (String, String, String) {
let heading = text(c, sess, "document.getElementById('heading').textContent");
let choices = text(c, sess, "[...document.querySelectorAll('#choice button')].map(b => b.textContent + (b.getAttribute('aria-pressed') === 'true' ? '*' : '')).join(' | ')");
let plans = text(c, sess, "[...document.querySelectorAll('#plans button')].map(b => b.querySelector('.plan-name').textContent + ' = ' + b.querySelector('.plan-price').textContent + (b.getAttribute('aria-checked') === 'true' ? '*' : '')).join(' | ')");
(heading, choices, plans)
}
#[test]
fn the_sign_up_page_starts_on_the_account_and_draws_the_servers_only_for_a_private_gunnar() {
let Some(bin) = chrome() else { return };
let (port, _) = serve();
let (_chrome, mut c, sess) = open(&bin, "signup", 1440, 900);
c.navigate(&sess, &format!("http://127.0.0.1:{port}/signup")).expect("the page loads");
c.wait_for(&sess, "document.querySelectorAll('#plans button').length === 3", 15_000).expect("the account's plans arrived");
shot(&mut c, &sess, "2-signup-account-desktop.png");
let (heading, choices, plans) = read_signup(&mut c, &sess);
assert_eq!(heading, "Sign up for an account");
assert_eq!(choices, "Account on 127.0.0.1* | Private Gunnar instance", "the account LEFT and selected");
assert_eq!(plans, "Free account = Free | 10 GB · 1 month = 110.00 SEK a month* | 10 GB · 6 months = 594.00 SEK for 180 days");
let note = text(&mut c, &sess, "document.querySelector('#plans button .plan-note').textContent");
assert_eq!(note, "Read only: you can read and clone, but not write to your own repositories. A card is checked at Stripe; nothing is charged.");
for id in ["estate", "size", "price"] {
assert!(!visible(&mut c, &sess, id), "#{id} is drawn for the account");
}
assert!(visible(&mut c, &sess, "plans") && visible(&mut c, &sess, "go"));
// Picking a row moves the one check.
click(&mut c, &sess, "#plans button[data-plan='gunnar/10gb-6m']");
assert!(read_signup(&mut c, &sess).2.ends_with("594.00 SEK for 180 days*"), "the 6 months are not chosen");
click(&mut c, &sess, "#plans button[data-plan='gunnar/account']");
assert!(read_signup(&mut c, &sess).2.starts_with("Free account = Free*"), "the free account is not chosen");
assert!(text(&mut c, &sess, "document.getElementById('first-account').textContent").contains("buy room when you want to write"), "the free account's line");
// The private Gunnar: the server picture, its − / + and its price — and no list.
click(&mut c, &sess, "#choice button[data-mode='private']");
c.wait_for(&sess, "document.getElementById('step-name').textContent === 'Mini'", 15_000).expect("the private ladder arrived");
shot(&mut c, &sess, "3-signup-private-desktop.png");
let (heading, choices, _) = read_signup(&mut c, &sess);
assert_eq!(heading, "Sign up for an account", "a visitor signs up for an account first, whichever way");
assert_eq!(choices, "Account on 127.0.0.1 | Private Gunnar instance*");
for id in ["estate", "size", "price"] {
assert!(visible(&mut c, &sess, id), "#{id} is not drawn for a private Gunnar");
}
assert!(!visible(&mut c, &sess, "plans"), "the account's list is drawn for a private Gunnar");
assert_eq!(text(&mut c, &sess, "document.getElementById('price-month').textContent"), "40.00 EUR a month");
assert_eq!(text(&mut c, &sess, "['front','main','twin'].map(r => document.getElementById('label-' + r).textContent).join(' ')"), "UI SERVER REPLICA");
// And back: the picture goes again.
click(&mut c, &sess, "#choice button[data-mode='account']");
c.wait_for(&sess, "document.querySelectorAll('#plans button').length === 3", 15_000).expect("the plans again");
assert!(!visible(&mut c, &sess, "estate") && visible(&mut c, &sess, "plans"), "the picture stays for the account");
// At phone width the page fits.
c.viewport(&sess, 390, 844).unwrap();
std::thread::sleep(Duration::from_millis(300));
shot(&mut c, &sess, "4-signup-account-phone.png");
let wide = c.eval(&sess, "document.documentElement.scrollWidth <= window.innerWidth + 1").unwrap().as_bool();
assert_eq!(wide, Some(true), "the sign-up page is wider than a phone");
}
/// ★ B (owner 2026-10-15): the free row orders the free account; a plan row orders ITS plan
/// (one checkout, the account made after the payment); both go on to the console.
#[test]
fn the_free_row_orders_the_account_and_a_plan_row_its_own_plan() {
let Some(bin) = chrome() else { return };
for (row, next) in [("gunnar/10gb-6m", "/"), ("gunnar/account", "/")] {
let (port, posted) = serve();
let (_chrome, mut c, sess) = open(&bin, &format!("order-{}", row.replace('/', "-")), 1440, 900);
c.navigate(&sess, &format!("http://127.0.0.1:{port}/signup")).expect("the page loads");
c.wait_for(&sess, "document.querySelectorAll('#plans button').length === 3", 15_000).expect("the plans");
click(&mut c, &sess, &format!("#plans button[data-plan='{row}']"));
c.eval(&sess, "(() => { document.getElementById('account').value = 'alice'; document.getElementById('email').value = 'alice@example.com'; return true; })()").unwrap();
// The owner's buy rule (2026-10-23): a paid plan says Pay <amount>; the free account its own words.
let go = text(&mut c, &sess, "document.getElementById('go').textContent");
if row == "gunnar/account" { assert_eq!(go, "Create your account at 127.0.0.1"); } else { assert!(go.starts_with("Pay "), "{go:?}"); }
click(&mut c, &sess, "#go");
c.wait_for(&sess, "document.getElementById('msg').textContent !== '' && !document.getElementById('msg').textContent.startsWith('Checking') && !document.getElementById('msg').textContent.startsWith('Creating')", 10_000).expect("the order was answered");
let sent = posted.lock().unwrap().clone();
assert_eq!(sent.len(), 1, "{row}: {sent:?}");
assert_eq!(sent[0].0, "/auth/signup/start");
let body = parse(&sent[0].1).expect("json");
// ★ B (owner 2026-10-15): a plan row is its own checkout; the free row makes the free account.
assert_eq!(body.text("plan"), row, "{row}: what is ordered");
assert_eq!(body.text("next"), next, "{row}: where it goes after the passkey");
}
}
/// ★ **The free account goes to Stripe's card check, and nothing is charged** (owner
/// 2026-10-15): the free row says so; the order is the account (`gunnar/account`, on to the
/// console after the passkey); monetize answers with Stripe's page, and the browser goes
/// THERE — the request is caught before it leaves (Fetch), the page having said that
/// nothing is charged — and the welcome page is told this tab paid nothing.
#[test]
fn the_free_account_goes_to_stripes_card_check_and_nothing_is_charged() {
let Some(bin) = chrome() else { return };
let (port, posted) = serve_answering(r#"{"url":"https://checkout.fake-stripe.test/c/pay/cs_test_card1"}"#);
let (_chrome, mut c, sess) = open(&bin, "card-check", 1440, 900);
c.navigate(&sess, &format!("http://127.0.0.1:{port}/signup")).expect("the page loads");
c.wait_for(&sess, "document.querySelectorAll('#plans button').length === 3", 15_000).expect("the plans");
click(&mut c, &sess, "#plans button[data-plan='gunnar/account']");
let row = text(&mut c, &sess, "document.querySelector(\"#plans button[data-plan='gunnar/account']\").innerText.replace(/\\s+/g, ' ').trim()");
assert!(row.contains("Free account") && row.contains("A card is checked at Stripe; nothing is charged."), "the free row: {row}");
c.eval(&sess, "(() => { document.getElementById('account').value = 'alice'; document.getElementById('email').value = 'alice@example.com'; return true; })()").unwrap();
c.call(Some(&sess), "Fetch.enable", r#"{"patterns":[{"urlPattern":"https://checkout.fake-stripe.test/*","requestStage":"Request"}]}"#).expect("Fetch");
c.events.clear();
click(&mut c, &sess, "#go");
let Some((id, url)) = c.paused_request(&sess, 15_000) else {
panic!("the free account did not go to Stripe; the page says {}", text(&mut c, &sess, "document.getElementById('msg').textContent"));
};
assert_eq!(url, "https://checkout.fake-stripe.test/c/pay/cs_test_card1", "to the page monetize answered");
// The navigation is aborted before it leaves: the page stays, as it was when it went.
let _ = c.call(Some(&sess), "Fetch.failRequest", &format!(r#"{{"requestId":{},"errorReason":"Aborted"}}"#, esc(&id)));
let _ = c.call(Some(&sess), "Fetch.disable", "{}");
let said = text(&mut c, &sess, "document.getElementById('msg').textContent");
let told = text(&mut c, &sess, "sessionStorage.getItem('gunnar_card_check') || ''");
shot(&mut c, &sess, "5-signup-free-card-check.png");
assert_eq!(said, "Taking you to Stripe to check your card. Nothing is charged.");
assert_eq!(told, "1", "the welcome page is told nothing was paid");
let sent = posted.lock().unwrap().clone();
assert_eq!(sent.len(), 1, "{sent:?}");
let body = parse(&sent[0].1).expect("json");
assert_eq!((sent[0].0.as_str(), body.text("plan").as_str(), body.text("next").as_str()), ("/auth/signup/start", "gunnar/account", "/"), "the account, on to the console");
}
/// ★ **B (owner 2026-10-15): a plan row goes to Stripe ONCE** — its own payment page (caught by
/// Fetch before it leaves), "Taking you to pay …", and the welcome page is NOT told it was a
/// card check (it says "Payment received").
#[test]
fn a_plan_row_goes_to_stripe_once_to_pay() {
let Some(bin) = chrome() else { return };
let (port, posted) = serve_answering(r#"{"url":"https://checkout.fake-stripe.test/c/pay/cs_test_pay1"}"#);
let (_chrome, mut c, sess) = open(&bin, "plan-row", 1440, 900);
c.navigate(&sess, &format!("http://127.0.0.1:{port}/signup")).expect("the page loads");
c.wait_for(&sess, "document.querySelectorAll('#plans button').length === 3", 15_000).expect("the plans");
click(&mut c, &sess, "#plans button[data-plan='gunnar/10gb-1m']");
c.eval(&sess, "(() => { document.getElementById('account').value = 'alice'; document.getElementById('email').value = 'alice@example.com'; return true; })()").unwrap();
c.call(Some(&sess), "Fetch.enable", r#"{"patterns":[{"urlPattern":"https://checkout.fake-stripe.test/*","requestStage":"Request"}]}"#).expect("Fetch");
c.events.clear();
click(&mut c, &sess, "#go");
let Some((id, url)) = c.paused_request(&sess, 15_000) else {
panic!("the plan row did not go to Stripe; the page says {}", text(&mut c, &sess, "document.getElementById('msg').textContent"));
};
let _ = c.call(Some(&sess), "Fetch.failRequest", &format!(r#"{{"requestId":{},"errorReason":"Aborted"}}"#, esc(&id)));
let _ = c.call(Some(&sess), "Fetch.disable", "{}");
assert_eq!(url, "https://checkout.fake-stripe.test/c/pay/cs_test_pay1");
assert_eq!(text(&mut c, &sess, "document.getElementById('msg').textContent"), "Taking you to pay …");
assert_eq!(text(&mut c, &sess, "sessionStorage.getItem('gunnar_card_check') || ''"), "", "not a card check");
let sent = posted.lock().unwrap().clone();
assert_eq!(sent.len(), 1, "ONE order: {sent:?}");
let body = parse(&sent[0].1).expect("json");
assert_eq!((body.text("plan").as_str(), body.text("next").as_str()), ("gunnar/10gb-1m", "/"));
}