crates/monetize-site-buy/tests/browser_plans_order.rs
//! ★ **The plans are listed cheapest first, in a real browser** (owner, gunnar.rs 2026-10-27:
//! signed in, Account → "Grow your plan" opened on 100 GB, not 10 GB). The order is the ONE
//! box's: monetize-server answers every list cheapest first (`monetize::bundle::offers`, the
//! account plans by size, the ladder smallest first), and the shared `buy.js` draws the rows in
//! the order it is given and opens on the first: a thin page that never sorts.
//!
//! The page (`tests/browser/page.html`, `buy.js` filled as a site fills it) runs in headless
//! Chromium over the crate's relay and the fake ONE box, for Gunnar and Holger, signed in and
//! not, on both tabs: the account ("Account on <site>" / "Grow your plan") and the private
//! instance ("New instance"). What the buyer sees is read from the page's `verdict`.
//!
//! Needs `CHROME` (a Chromium binary, as holger-ui's browser image sets it); without it the
//! test says NOT RUN and returns, and with `MONETIZE_NEEDS_BROWSER` set (the CI's `cargo xtask
//! browser`, in the browser image) it FAILS instead.
use std::net::SocketAddr;
use std::sync::Arc;
use monetize_proto as pb;
use monetize_site_buy::assets::{buy_js, Settings};
use monetize_site_buy::testkit::{FakeOneBox, Shape, TestSite, LADDER, PAID_WORDS};
use monetize_site_buy::Relay;
use serde_json::Value;
const BEARER: &str = "site-bearer-for-browser-tests";
const PAGE: &str = include_str!("browser/page.html");
const GIB: u64 = 1 << 30;
fn chrome() -> Option<String> {
let c = std::env::var("CHROME").ok().filter(|c| !c.is_empty());
if c.is_none() {
// The CI's browser step (`cargo xtask browser`) requires the browser: red, never NOT RUN.
assert!(std::env::var_os("MONETIZE_NEEDS_BROWSER").is_none(), "MONETIZE_NEEDS_BROWSER is set and CHROME names no Chromium: this browser test cannot run (`cargo xtask browser` runs it in the browser image)");
eprintln!("NOT RUN: set CHROME to a Chromium binary to open the buy page");
}
c
}
fn settings(shape: Shape) -> Settings {
Settings {
product: shape.word().into(),
storage: format!("{}_", shape.product()),
free_account: format!("{}/account", shape.product()),
free_note: "Free: clone only. Pushing needs a paid plan.".into(),
card_check: "A card check at Stripe (0 kr) proves you are a person.".into(),
beta_line: "Beta: we replicate your data, but cannot guarantee it.".into(),
manage: shape.pages().manage,
account_plan: "account".into(),
here: "site".into(),
bearer_key: String::new(),
status_url: "/auth/status".into(),
status_shape: String::new(),
offer_url: "/auth/buy/offer".into(),
contact: String::new(),
}
}
/// The site on loopback, `user` signed in (`None`: a visitor). The browser's session is played
/// by the site itself: every request carries `user`'s cookie (Gunnar) or bearer (Holger).
async fn site(shape: Shape, fake: &FakeOneBox, user: Option<&'static str>) -> String {
let relay = Arc::new(Relay::new(TestSite::over(shape, fake), fake.link()));
let page = PAGE.replacen("/*@buy-script*/", &buy_js(&settings(shape)), 1);
let status = match user {
None => {
serde_json::json!({ "authenticated": false, "private_url": shape.pages().private, "buy_url": "/signup?plan=10gb" })
}
Some(u) => {
serde_json::json!({ "authenticated": true, "user": u, "private_url": shape.pages().private, "buy_url": "/signup?plan=10gb", "grow_account_url": "/signup?plan=10gb" })
}
};
let app = monetize_site_buy::router(relay)
.route(
"/signup",
axum::routing::get(move || async move { axum::response::Html(page) }),
)
.route(
"/auth/status",
axum::routing::get(move || async move { axum::Json(status) }),
)
.layer(axum::middleware::map_request(
move |mut req: axum::extract::Request| async move {
if let Some(u) = user {
let (name, value) = match shape {
Shape::Gunnar => {
(axum::http::header::COOKIE, format!("gunnar_session={u}"))
}
Shape::Holger => (axum::http::header::AUTHORIZATION, format!("Bearer {u}")),
};
req.headers_mut().insert(name, value.parse().unwrap());
}
req
},
));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
tokio::spawn(async move {
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
});
base
}
/// What a buyer sees at `url` once the page has settled: the page's `verdict`.
async fn seen(chrome: &str, url: &str) -> Value {
let (chrome, url) = (chrome.to_owned(), url.to_owned());
tokio::task::spawn_blocking(move || {
let profile = profile_dir(&url);
std::fs::create_dir_all(&profile).unwrap();
let out = std::process::Command::new(&chrome)
.args([
"--headless",
"--no-sandbox",
"--disable-gpu",
"--disable-dev-shm-usage",
"--no-first-run",
])
.arg(format!("--user-data-dir={}", profile.display()))
.arg("--virtual-time-budget=8000")
.arg("--dump-dom")
.arg(&url)
.output()
.expect("chromium runs");
let _ = std::fs::remove_dir_all(&profile);
let dom = String::from_utf8_lossy(&out.stdout).into_owned();
let verdict = dom
.split("<pre id=\"verdict\">")
.nth(1)
.and_then(|r| r.split("</pre>").next())
.unwrap_or("");
let verdict = verdict
.replace(""", "\"")
.replace("<", "<")
.replace(">", ">")
.replace("&", "&");
serde_json::from_str(&verdict).unwrap_or_else(|e| {
panic!(
"no verdict at {url} ({e}):\n{dom}\n{}",
String::from_utf8_lossy(&out.stderr)
)
})
})
.await
.unwrap()
}
/// One row of the grow window, as monetize-server answers it.
fn row(id: &str, gib: u64, price: &str, meter: &str) -> pb::Bundle {
pb::Bundle {
id: id.into(),
label: format!("{gib}.0 GiB more room"),
price: price.into(),
period: "month".into(),
delta: [(meter.to_string(), gib * GIB)].into(),
can_serve: true,
..Default::default()
}
}
/// ★ Signed in, Account → "Grow your plan": the page opens on the cheapest row, the − is the
/// floor, and the rows follow in the ONE box's order (10, 50, 100 GiB on Gunnar; 5, 10, 50 GiB
/// on Holger; the ids "100gb" and "10gib" sort first as text, and must not lead).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn grow_your_plan_opens_on_the_cheapest_step() {
let Some(chrome) = chrome() else { return };
for shape in [Shape::Gunnar, Shape::Holger] {
let fake = FakeOneBox::spawn(shape.product(), BEARER).await;
fake.add_account("alica", "alica@mock.invalid", &[]);
let rows = match shape {
Shape::Gunnar => vec![
row("10gb", 10, "220.00 SEK", "pack_bytes"),
row("50gb", 50, "1100.00 SEK", "pack_bytes"),
row("100gb", 100, "2200.00 SEK", "pack_bytes"),
],
Shape::Holger => vec![
row("5gib", 5, "110.00 SEK", "disk_bytes"),
row("10gib", 10, "220.00 SEK", "disk_bytes"),
row("50gib", 50, "1100.00 SEK", "disk_bytes"),
],
};
let first = rows[0].label.clone();
fake.sell_bundles(rows);
let base = site(shape, &fake, Some("alica")).await;
let v = seen(&chrome, &format!("{base}/signup?plan=10gb")).await;
assert!(
v["choices"]
.as_array()
.into_iter()
.flatten()
.any(|c| c == "Grow your plan"),
"{shape:?}: the signed-in account tab: {v}"
);
assert_eq!(
v["pressed"],
serde_json::json!(["Grow your plan"]),
"{shape:?}: the grow tab is the one open: {v}"
);
assert_eq!(
v["step"].as_str(),
Some(first.as_str()),
"{shape:?}: the page opens on the cheapest step: {v}"
);
assert!(
v["buttons"]
.as_array()
.into_iter()
.flatten()
.any(|b| b == "Upgrade"),
"{shape:?}: the step is buyable: {v}"
);
}
}
/// ★ Not signed in, "Account on <site>": the free account, then the plans cheapest first; and
/// the private instance ("New instance", signed in or not) opens on the ladder's smallest step.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_account_plans_and_the_new_instance_open_on_the_cheapest_step() {
let Some(chrome) = chrome() else { return };
for shape in [Shape::Gunnar, Shape::Holger] {
let fake = FakeOneBox::spawn(shape.product(), BEARER).await;
fake.add_account("alica", "alica@mock.invalid", &[]);
let visitor = site(shape, &fake, None).await;
let v = seen(&chrome, &format!("{visitor}{}", shape.pages().account)).await;
let rows: Vec<&str> = v["rows"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect();
assert!(
rows.len() >= 2,
"{shape:?}: the free account and a plan: {v}"
);
assert!(
rows[0].starts_with("Free account"),
"{shape:?}: the free account first: {v}"
);
assert!(
rows[1].contains(PAID_WORDS),
"{shape:?}: then the cheapest plan: {v}"
);
let (smallest, ..) = LADDER[0];
for (who, base) in [
("a visitor", visitor.clone()),
("signed in", site(shape, &fake, Some("alica")).await),
] {
let v = seen(&chrome, &format!("{base}{}", shape.pages().private)).await;
assert_eq!(
v["step"].as_str(),
Some(smallest),
"{shape:?} {who}: the new instance opens on the smallest step: {v}"
);
}
}
}
/// The Chromium profile directory of one page load: this process's, numbered, so no two loads
/// share one (it was named from a sum over the URL, which two loads can share).
fn profile_dir(_url: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
std::env::temp_dir().join(format!(
"site-buy-order-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::SeqCst)
))
}
/// Two page loads never share a Chromium profile: the tests of this file run in parallel, and a
/// second Chromium on a held profile exits at once (GROW-ORDER's flake, RESUME 13).
#[test]
fn two_page_loads_never_share_a_profile() {
assert_ne!(
profile_dir("http://127.0.0.1:1/a"),
profile_dir("http://127.0.0.1:1/a"),
"the same page twice"
);
assert_ne!(
profile_dir("http://127.0.0.1:1/ab"),
profile_dir("http://127.0.0.1:1/ba"),
"two pages with the same length and byte sum"
);
}