crates/monetize-ui-web/src/sites.rs
//! **THE SITE ORDER** (owner 2026-10-13: "the shop must deploy gunnar.rs itself") — every
//! decision the Sites page takes, compiled for the host and tested there; `pages::sites`
//! only lays them out.
//!
//! The operator's own sites, one form each. The form asks what a declared site does not
//! already have — its size and its first admin's address — and shows the rest as already
//! fed: its domain, its own Stripe account, the box's one DNS token and one cloud account.
//! The console decides nothing: which sites, whether one can be ordered and why not, where
//! an order stands and where the operator pays are monetize's words.
//!
//! ```text
//! Sites: per site — domain · Stripe · DNS · cloud · first admin (each "already fed")
//! size [select] first admin's address [input] seed key, public [input, optional] [Lay <domain>]
//! its order: state — words — [Pay <monetize's price words>] while it waits for payment
//! ```
use serde::{Deserialize, Serialize};
use crate::fmt::Tone;
pub const LIST_API: &str = "/api/sites";
pub const ORDER_API: &str = "/api/sites/order";
pub const TERMINATE_API: &str = "/api/sites/terminate";
pub const UPDATE_API: &str = "/api/sites/update";
pub const UPDATE_STATUS_API: &str = "/api/sites/update-status";
pub const LIVE_UPDATE_API: &str = "/api/sites/live-update";
/// MONETIZE-FIX: the order kinds a site's product declares, and one queued (and its answer).
pub const KINDS_API: &str = "/api/sites/kinds";
pub const DO_API: &str = "/api/sites/do";
/// How often the page reads the list again while an order is on its way.
pub const REREAD_MS: u32 = 3_000;
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct SiteJson {
pub estate: String,
pub domain: String,
pub vendor: String,
pub first_admin: String,
pub dns_words: String,
pub dns_writes: bool,
pub cloud_words: String,
pub state: String,
pub words: String,
pub checkout_url: String,
pub orderable: bool,
pub why_not: String,
/// monetize's word that it may be terminated; a protected site is false.
pub terminable: bool,
/// monetize's word that "Check again" may ask its boxes (F5).
pub recheckable: bool,
/// ★ A33-6: monetize's words for what it costs (the page computes no price).
pub price_words: String,
/// ★ A32-29: the settles the SERVER says this site offers (F2, F5), with their words; the
/// page draws them for an admin.
#[serde(default)]
pub actions: Vec<crate::accounts::ActionJson>,
/// ★ FIRST-LINK-1: the first-admin door as the server says it (`open` | `spent` |
/// `expired` | `handed_off` | `unknown`), and its sentence; empty when not asked.
pub first_admin_door: String,
pub first_admin_door_words: String,
}
/// Pure: **the first-admin door, beside "Send the first sign-in link again"** (FIRST-LINK-1):
/// its word and the server's sentence; `None` when the server said none.
pub fn door_line(s: &SiteJson) -> Option<String> {
let word = match s.first_admin_door.as_str() {
"" => return None,
"open" => "open",
"spent" => "spent (the first admin signed in)",
"expired" => "expired",
"handed_off" => "handed off to the site",
other => other,
};
Some(if s.first_admin_door_words.is_empty() {
format!("First-admin door: {word}")
} else {
format!("First-admin door: {word} — {}", s.first_admin_door_words)
})
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct SitesJson {
pub sites: Vec<SiteJson>,
pub sizes: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct OrderedJson {
pub reference: String,
pub checkout_url: String,
pub price_words: String,
pub words: String,
}
/// What the form posts: the three fields, and the operator's "lay it".
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct OrderBody {
pub estate: String,
pub size: String,
pub email: String,
/// The seed's PUBLIC key (optional): the kit seeds the site's repositories with its private half.
pub seed_key: String,
pub confirm: bool,
}
/// **What a site already has**, as the form shows it: `(field, value, fed)` — the fields an
/// order needs and never asks for.
pub fn fed_rows(s: &SiteJson) -> Vec<(&'static str, String, bool)> {
vec![
(
"Domain",
format!("{0}, www.{0}, git.{0}", s.domain),
!s.domain.is_empty(),
),
("Stripe", s.vendor.clone(), !s.vendor.is_empty()),
("DNS", s.dns_words.clone(), s.dns_writes),
("Cloud", s.cloud_words.clone(), !s.cloud_words.is_empty()),
(
"First admin",
s.first_admin.clone(),
!s.first_admin.is_empty(),
),
]
}
/// The mark beside a field the site already has, or one it lacks.
pub fn fed_mark(fed: bool) -> &'static str {
if fed {
"already fed"
} else {
"missing"
}
}
/// **May the button be pressed, and why not**: monetize's own answer first; then the form's
/// own two fields; an admin's alone.
pub fn press(s: &SiteJson, email: &str, admin: bool) -> (bool, String) {
if !s.orderable {
return (false, s.why_not.clone());
}
if !admin {
return (false, "an admin orders a site".into());
}
if !email.trim().contains('@') {
return (
false,
"the first admin's address: the sign-in link is mailed there".into(),
);
}
(true, String::new())
}
/// The site's name as a person reads it: its domain, else its label.
pub fn site_name(s: &SiteJson) -> &str {
if s.domain.is_empty() {
&s.estate
} else {
&s.domain
}
}
/// The button's words.
pub fn button_words(s: &SiteJson) -> String {
format!("Order {}", site_name(s))
}
/// **The state as a person reads it** — monetize's word, spelled out; a word this page
/// does not know is shown as monetize sent it.
pub fn state_words(state: &str) -> String {
match state {
"none" => "not ordered",
"waiting_for_payment" => "waiting for payment",
"queued" => "ordered",
"waiting_for_dns" => "waiting for DNS",
"stands" => "running",
"owed" => "setup failed after payment",
"install_failed" => "install failed",
"unjoined" => "not joined: its shop cannot answer",
other => return other.replace('_', " "),
}
.to_string()
}
/// Is an order on its way? Then the page reads the list again every [`REREAD_MS`], so the
/// state is seen to move without a reload. Nothing is decided by it.
pub fn in_flight(state: &str) -> bool {
matches!(
state,
"waiting_for_payment"
| "queued"
| "waiting_for_dns"
| "laying"
| "installing"
| "terminating"
)
}
/// **A refusal, in plain words**: the server's own sentence; when it sent none, one of
/// this page's. Never a status code or a transport word.
pub fn fail_words(status: u16, message: &str) -> String {
let m = message.trim();
if !m.is_empty() {
return m.to_string();
}
match status {
0 | 502 | 503 | 504 => "The box did not answer. Try again in a moment.",
403 => "You are not allowed to do that.",
_ => "That did not work. Try again in a moment.",
}
.to_string()
}
/// Where a site's settle is posted, and what: its order is kept under the site's label.
pub fn settle_post(s: &SiteJson, action: &str) -> (String, serde_json::Value) {
(
crate::accounts::settle_api(&s.estate),
serde_json::json!({ "action": action, "confirm": true }),
)
}
/// What the terminate posts: the site and its name typed back. No backup is offered at
/// terminate (owner 2026-10-22).
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct TerminateBody {
pub estate: String,
pub confirm: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct TerminatedJson {
pub words: String,
pub done: bool,
}
/// May Terminate be pressed? Only on a site monetize says may be terminated, by an admin,
/// with the site's name typed back (monetize checks the name again).
pub fn may_terminate(s: &SiteJson, typed: &str, admin: bool) -> bool {
offers_terminate(s) && admin && !typed.trim().is_empty() && typed.trim() == site_name(s)
}
/// **Is Terminate shown at all**: monetize's word alone (thin UI, F3 2026-10-20). monetize says
/// yes only over something that stands, in a phase its terminate takes — a running site, a
/// stopped install, an owed site whose boxes stand — and never for a protected site.
pub fn offers_terminate(s: &SiteJson) -> bool {
s.terminable
}
pub fn terminate_body(s: &SiteJson, typed: &str) -> TerminateBody {
TerminateBody {
estate: s.estate.clone(),
confirm: typed.trim().to_string(),
}
}
/// **May a sold estate's Terminate be pressed** (the operator, on the estate's Guardian page,
/// owner 2026-10-22): by an admin, with the estate's name typed back exactly. monetize checks
/// the name again and refuses a protected site.
pub fn may_terminate_estate(estate: &str, typed: &str, admin: bool) -> bool {
admin && !typed.trim().is_empty() && typed.trim() == estate.trim()
}
/// The operator's terminate of a sold estate, as `POST /api/sites/terminate` takes it.
pub fn estate_terminate_body(estate: &str, typed: &str) -> TerminateBody {
TerminateBody {
estate: estate.trim().to_string(),
confirm: typed.trim().to_string(),
}
}
/// The order the form sends.
pub fn order_body(s: &SiteJson, size: &str, email: &str, seed_key: &str) -> OrderBody {
OrderBody {
estate: s.estate.clone(),
size: size.trim().to_string(),
email: email.trim().to_string(),
seed_key: seed_key.trim().to_string(),
confirm: true,
}
}
/// **Where the operator pays**, while the order waits for it: the words and the vendor's
/// own https page. Nothing else is ever a link.
pub fn pay_link(s: &SiteJson) -> Option<(String, String)> {
(s.state == "waiting_for_payment" && s.checkout_url.starts_with("https://"))
.then(|| (format!("Pay {}", s.price_words), s.checkout_url.clone()))
}
/// The state's badge.
pub fn state_tone(state: &str) -> Tone {
match state {
"ready" | "stands" => Tone::Pass,
"failed" | "install_failed" | "owed" | "cancelled" | "deleted" | "terminated"
| "unjoined" => Tone::Bad,
"none" => Tone::Plain,
_ => Tone::Live,
}
}
// ── THE SITE BOOK (2026-10-14: config in the application) ──────────────────
//
// ```text
// [New site] → the form, blank (the box's choices)
// per site: [Edit] → the form, filled from the box (never a secret)
// form: label · domain · product · first admin · operator site · terminable
// Stripe account [held | a new one] · test/live · secret key [password] · webhook secret [password]
// term days · term months · grace hours · margin bp · media from · (currency, sizes: the shop's)
// [Add site | Save] → the box judges it (refused whole, by name) and applies it live
// ```
pub const BOOK_API: &str = "/api/sites/book";
pub const PUT_API: &str = "/api/sites/put";
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct TermsJson {
pub term_days: u32,
pub term_months: u32,
pub grace_hours: u32,
pub margin_bp: u32,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct SiteRecordJson {
pub estate: String,
pub domain: String,
pub product: String,
pub first_admin: String,
pub operator_site: bool,
pub terminable: bool,
pub vendor: String,
pub live: bool,
pub terms: TermsJson,
pub media_from: String,
pub source: String,
pub secret_key_set: bool,
pub secret_key_fingerprint: String,
pub webhook_secret_set: bool,
pub webhook_secret_fingerprint: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct SiteShownJson {
pub site: SiteRecordJson,
pub products: Vec<String>,
pub vendors: Vec<String>,
pub media_sites: Vec<String>,
pub currency: String,
pub sizes: Vec<String>,
pub history: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct SitePutJson {
pub words: String,
pub changed: Vec<String>,
}
/// What the form holds while it is filled in — the secrets included, until it is sent.
#[derive(Clone, Default, PartialEq, Eq, Serialize)]
pub struct SitePutBody {
pub estate: String,
pub create: bool,
pub domain: String,
pub product: String,
pub first_admin: String,
pub operator_site: bool,
pub terminable: bool,
pub vendor: String,
pub vendor_secret_key: String,
pub vendor_webhook_secret: String,
pub live: bool,
pub terms: TermsJson,
pub media_from: String,
}
impl std::fmt::Debug for SitePutBody {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SitePutBody")
.field("estate", &self.estate)
.field("create", &self.create)
.field("domain", &self.domain)
.field("vendor", &self.vendor)
.field("secrets", &"(never shown)")
.finish()
}
}
/// The book's path for one site (empty: a blank new one).
pub fn book_path(estate: &str) -> String {
if estate.is_empty() {
BOOK_API.to_string()
} else {
format!("{BOOK_API}?estate={}", crate::fmt::percent_encode(estate))
}
}
/// **Where this site's buyers order a private estate**: the site's OWN sign-up page,
/// `https://<domain>/signup?plan=private` (owner decision OD6, 2026-10-26: the ONE box sells on
/// no order page of its own; buying happens on each product site's own sign-up). `None` until
/// the site names its domain.
pub fn buyer_order_page(domain: &str) -> Option<String> {
let domain = domain.trim().trim_end_matches('/');
(!domain.is_empty()).then(|| format!("https://{domain}/signup?plan=private"))
}
/// The Stripe-account choice that means "a new account of the site's own".
pub const NEW_ACCOUNT: &str = "";
/// Pure: **the form, filled from what the box showed** — an edit starts from the site as it
/// is; a new site from the box's first product, and a new Stripe account of its own. The
/// secrets always start empty: the box never answers one.
pub fn form_of(shown: &SiteShownJson, create: bool) -> SitePutBody {
let s = &shown.site;
SitePutBody {
estate: s.estate.clone(),
create,
domain: s.domain.clone(),
product: if s.product.is_empty() {
shown.products.first().cloned().unwrap_or_default()
} else {
s.product.clone()
},
first_admin: s.first_admin.clone(),
operator_site: s.operator_site,
terminable: s.terminable,
vendor: if create {
NEW_ACCOUNT.to_string()
} else {
s.vendor.clone()
},
vendor_secret_key: String::new(),
vendor_webhook_secret: String::new(),
live: s.live,
terms: s.terms.clone(),
media_from: s.media_from.clone(),
}
}
/// Pure: **the Stripe-account choices** — a new account of the site's own (a new site only),
/// then every account the box holds.
pub fn vendor_choices(shown: &SiteShownJson, create: bool, estate: &str) -> Vec<(String, String)> {
let mut v = Vec::new();
if create {
let own = if estate.trim().is_empty() {
"a new account of its own (stripe/<name>)".to_string()
} else {
format!("a new account: stripe/{}", estate.trim())
};
v.push((NEW_ACCOUNT.to_string(), own));
}
v.extend(shown.vendors.iter().map(|x| (x.clone(), x.clone())));
v
}
/// Pure: **a secret as the form shows it** — set or not, and a fingerprint at most.
pub fn secret_words(set: bool, fingerprint: &str) -> String {
match (set, fingerprint.is_empty()) {
(true, false) => format!("set ({fingerprint})"),
(true, true) => "set".to_string(),
(false, _) => "not set".to_string(),
}
}
/// Pure: **may the form be sent, and why not** — what the form itself can see; the box judges
/// the rest (refused whole, by name).
pub fn may_put(b: &SitePutBody, admin: bool) -> (bool, String) {
if !admin {
return (false, "an admin adds and edits a site".into());
}
if b.create && !label_ok(b.estate.trim()) {
return (
false,
"the site's name: lowercase letters, digits and hyphens".into(),
);
}
if !b.domain.trim().contains('.') {
return (false, "the site's domain".into());
}
if b.first_admin.trim().is_empty() {
return (false, "the site's first admin".into());
}
if b.create
&& b.vendor == NEW_ACCOUNT
&& (b.vendor_secret_key.trim().is_empty() || b.vendor_webhook_secret.trim().is_empty())
{
return (
false,
"a new Stripe account needs its secret key and its webhook secret".into(),
);
}
let key = b.vendor_secret_key.trim();
if !key.is_empty() && key.contains("_live_") != b.live {
return (
false,
if b.live {
"a live site takes a live key".into()
} else {
"a test site takes a test key".into()
},
);
}
(true, String::new())
}
/// Pure: **a site's label**, the box's own rule (`monetize-admin` `app_config::label_ok`):
/// 1–63 lowercase letters, digits and hyphens, not starting or ending with a hyphen.
pub fn label_ok(l: &str) -> bool {
!l.is_empty()
&& l.len() <= 63
&& !l.starts_with('-')
&& !l.ends_with('-')
&& l.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
/// Pure: **the label a typed name could be** (owner 2026-11-03: `holger.rs` → `holger-rs`):
/// lowercased, every other character a hyphen, hyphens never doubled nor at an end.
pub fn label_from(typed: &str) -> String {
let mut out = String::new();
for c in typed.trim().chars().flat_map(char::to_lowercase) {
let c = if c.is_ascii_lowercase() || c.is_ascii_digit() {
c
} else {
'-'
};
if c == '-' && (out.is_empty() || out.ends_with('-')) {
continue;
}
out.push(c);
}
let mut out = out.trim_end_matches('-').to_string();
out.truncate(63);
out.trim_end_matches('-').to_string()
}
/// Pure: **the label's warning as it is typed** — none for an empty or a good label; else
/// the rule and the label it could be.
pub fn label_hint(typed: &str) -> Option<String> {
let t = typed.trim();
if t.is_empty() || label_ok(t) {
return None;
}
let could = label_from(t);
Some(if could.is_empty() {
"lowercase letters, digits and hyphens".to_string()
} else {
format!("lowercase letters, digits and hyphens — e.g. {could}")
})
}
/// Pure: **the label after the domain changed** (a new site only): it follows the domain while
/// it is empty or still the one the old domain gave; a label typed by hand is kept.
pub fn label_follows(label: &str, old_domain: &str, new_domain: &str) -> Option<String> {
let l = label.trim();
(l.is_empty() || l == label_from(old_domain)).then(|| label_from(new_domain))
}
/// The button's words.
pub fn put_words(b: &SitePutBody) -> &'static str {
if b.create {
"Add site"
} else {
"Save"
}
}
/// Pure: a term field as typed (empty or not a number: 0, the shop's).
pub fn term_of(typed: &str) -> u32 {
typed.trim().parse().unwrap_or(0)
}
// ── the update of a site this box laid (owner 2026-10-14) ───────────────────
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct UpdateStepJson {
pub role: String,
pub state: String,
pub how: String,
pub words: String,
}
/// monetize's word on a site's update: the release sold now, what each box runs, live on or
/// off, the open (else last) update.
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct UpdateJson {
pub host: String,
pub release: String,
pub release_of: std::collections::BTreeMap<String, String>,
pub live_on: bool,
pub state: String,
pub steps: Vec<UpdateStepJson>,
pub update_release: String,
pub words: String,
/// Every box runs the release sold now.
pub up_to_date: bool,
/// The button: a newer release that carries a way to update every box.
pub offered: bool,
/// Why there is no button, in monetize's plain words.
pub why_not: String,
/// The release sold now, named for a person; empty: no note.
pub release_name: String,
pub boxes: Vec<UpdateBoxJson>,
}
/// One box's line, as monetize said it.
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct UpdateBoxJson {
pub role: String,
/// `current` | `behind` | `not-reported`
pub state: String,
pub words: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct UpdateBody {
pub estate: String,
pub release: String,
pub confirm: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct LiveBody {
pub estate: String,
pub on: bool,
}
/// Is the update shown for this site? Only one this box laid and that is up.
pub fn offers_update(s: &SiteJson) -> bool {
s.state == "ready"
}
/// A release id as a person reads it: its first twelve characters.
pub fn short_release(r: &str) -> &str {
&r[..r.len().min(12)]
}
/// **The release, for a person**: its name (the kit's note: label · date · commits), else
/// "release <short id>"; and the short id, shown small.
pub fn release_words(u: &UpdateJson) -> (String, String) {
let short = short_release(&u.release).to_string();
let name = if u.release_name.trim().is_empty() {
format!("release {short}")
} else {
u.release_name.trim().to_string()
};
(name, short)
}
/// **The update's head line** — up to date (and on which release), or why there is nothing to
/// update; `None` while the button is the answer (a newer release that can be taken) or an
/// update runs.
pub fn update_head(u: &UpdateJson) -> Option<(Tone, String)> {
if update_in_flight(u) {
return None;
}
if u.up_to_date {
return Some((Tone::Pass, format!("Up to date · {}", release_words(u).0)));
}
if u.offered {
return None;
}
let why = if u.why_not.trim().is_empty() {
"No newer release with update media is published yet.".to_string()
} else {
u.why_not.trim().to_string()
};
Some((Tone::Plain, why))
}
/// Each box's line: `(role, monetize's words, tone)` — every role has one.
pub fn box_lines(u: &UpdateJson) -> Vec<(String, String, Tone)> {
u.boxes
.iter()
.map(|b| {
let tone = match b.state.as_str() {
"current" => Tone::Pass,
"behind" => Tone::Warn,
_ => Tone::Plain,
};
(b.role.clone(), b.words.clone(), tone)
})
.collect()
}
/// **A site this box did not lay** (kit-installed, its boxes report in): it is updated here only
/// once adopted — said, with no button.
pub fn not_managed(s: &SiteJson) -> Option<&'static str> {
(s.state == "stands").then_some("Not managed by this box yet — adopt it to update it here")
}
/// **What the site has, in one line**: "Configured" when every field an order needs is fed
/// (the details are on Edit), else what is missing.
pub fn configured(s: &SiteJson) -> (bool, String) {
let missing: Vec<&str> = fed_rows(s)
.into_iter()
.filter(|r| !r.2)
.map(|r| r.0)
.collect();
if missing.is_empty() {
(true, "Configured".to_string())
} else {
(false, format!("Missing: {}", missing.join(", ")))
}
}
/// **The ordering UI** (the form, or why it cannot be ordered) is for a site not ordered yet; a
/// site on its way, running or settled shows its state instead.
pub fn shows_order(s: &SiteJson) -> bool {
s.state == "none"
}
/// **The site's line**: a running site says where it runs; else monetize's words.
pub fn site_words(s: &SiteJson) -> String {
if matches!(s.state.as_str(), "ready" | "stands") && !s.domain.is_empty() {
format!("Running at https://{}", s.domain)
} else {
s.words.clone()
}
}
/// **"Update to <release>"** — offered only when monetize says a newer release can be taken
/// (and while one runs, disarmed): `(may press, words, why not)`; `None`: no button.
pub fn update_button(u: &UpdateJson, admin: bool) -> Option<(bool, String, String)> {
if !u.offered && !update_in_flight(u) {
return None;
}
let words = format!("Update to {}", release_words(u).0);
if update_in_flight(u) {
return Some((false, words, "an update is running".into()));
}
if !admin {
return Some((false, words, "an admin updates a site".into()));
}
Some((true, words, String::new()))
}
/// The update the button sends: the release the page SHOWED (monetize refuses a stale one).
pub fn update_body(estate: &str, u: &UpdateJson) -> UpdateBody {
UpdateBody {
estate: estate.to_string(),
release: u.release.clone(),
confirm: true,
}
}
/// The live-update switch: what pressing it sends.
pub fn live_body(estate: &str, u: &UpdateJson) -> LiveBody {
LiveBody {
estate: estate.to_string(),
on: !u.live_on,
}
}
/// The switch: its label, whether it is on, and one line of what it means.
pub fn live_words(u: &UpdateJson) -> (&'static str, bool, &'static str) {
("Live updates", u.live_on, "On: an update stages the new app live where it can. Off = every update reinstalls the box from its keep medium, /data kept.")
}
/// **The update's steps, as the page lists them** (owner 2026-10-17: each box's step shown):
/// `(role, the line for it, failed)` — the open update's, else the last one's; none before any.
pub fn step_lines(u: &UpdateJson) -> Vec<(String, String, bool)> {
u.steps
.iter()
.map(|s| {
let mut line = s.state.clone();
if !s.how.is_empty() {
line.push_str(&format!(" ({})", s.how));
}
if !s.words.is_empty() {
line.push_str(&format!(" — {}", s.words));
}
(s.role.clone(), line, s.state == "failed")
})
.collect()
}
/// Whether the update (open or last) stopped on a failure — the page says it as an alert.
pub fn update_failed(u: &UpdateJson) -> bool {
u.state == "failed" || u.steps.iter().any(|s| s.state == "failed")
}
/// While an update runs the page reads it again.
pub fn update_in_flight(u: &UpdateJson) -> bool {
matches!(u.state.as_str(), "running" | "front-keep")
}
// ── A PRODUCT'S OWN ORDERS (MONETIZE-FIX 2026-11-01) ───────────────────────────
//
// Each kind the site's product declares in its facts is a row: its words, a field per param,
// one button. The box PULLS the order; its answer is the box's ack, read again until it came.
// A kind that carries a file is the CLI's (`monetize site do <site> <kind> --file …`).
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct KindParamJson {
pub name: String,
pub words: String,
pub required: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct KindJson {
pub kind: String,
pub role: String,
pub words: String,
pub params: Vec<KindParamJson>,
pub file: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct KindsJson {
pub kinds: Vec<KindJson>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct DoBody {
pub estate: String,
pub kind: String,
pub params: std::collections::BTreeMap<String, String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct DoneJson {
pub words: String,
pub seq: u64,
pub role: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct DoAnswerJson {
pub kind: String,
pub answered: bool,
pub ok: bool,
pub detail: String,
}
/// Pure: **the kind's button**: `(may press, why not)` — an admin's, every required param typed,
/// and never a kind that carries a file (the CLI's).
pub fn may_do(
k: &KindJson,
typed: &std::collections::BTreeMap<String, String>,
admin: bool,
) -> (bool, String) {
if !k.file.is_empty() {
return (
false,
format!(
"this order carries a file: `monetize site do <site> {} --file {}=<path>`",
k.kind, k.file
),
);
}
if !admin {
return (false, "an admin's".into());
}
match k
.params
.iter()
.find(|p| p.required && typed.get(&p.name).is_none_or(|v| v.trim().is_empty()))
{
Some(p) => (false, format!("{} is needed", p.name)),
None => (true, String::new()),
}
}
/// Pure: **what is sent**: the typed params the kind declares, trimmed, empty ones left out.
pub fn do_body(
estate: &str,
k: &KindJson,
typed: &std::collections::BTreeMap<String, String>,
) -> DoBody {
let params = k
.params
.iter()
.filter_map(|p| {
typed
.get(&p.name)
.map(|v| (p.name.clone(), v.trim().to_string()))
})
.filter(|(_, v)| !v.is_empty())
.collect();
DoBody {
estate: estate.to_string(),
kind: k.kind.clone(),
params,
}
}
/// Pure: the answer path of order `seq` on `estate`.
pub fn answer_path(estate: &str, seq: u64) -> String {
format!("{DO_API}?estate={estate}&seq={seq}")
}
/// Pure: **the box's answer, as the page says it**: waiting, its answer, or its refusal.
pub fn answer_words(a: &DoAnswerJson) -> (Tone, String) {
match (a.answered, a.ok) {
(false, _) => (
Tone::Warn,
format!("{}: queued; the box has not pulled it yet", a.kind),
),
(true, true) => (Tone::Pass, a.detail.clone()),
(true, false) => (Tone::Bad, format!("refused by the box: {}", a.detail)),
}
}
#[cfg(test)]
mod tests {
#[test]
fn a_label_is_the_boxs_rule_and_a_domain_gives_one() {
use super::*;
assert!(label_ok("holger-rs") && label_ok("test5-acme-example") && label_ok("a1"));
for bad in [
"",
"holger.rs",
"Holger-rs",
"-a",
"a-",
"a b",
&"a".repeat(64),
] {
assert!(!label_ok(bad), "{bad:?}");
}
assert_eq!(label_from("holger.rs"), "holger-rs");
assert_eq!(label_from(" Test5.Acme..Example. "), "test5-acme-example");
assert_eq!(label_from("..."), "");
assert!(label_ok(&label_from(&"x.".repeat(60))));
assert_eq!(label_hint(""), None);
assert_eq!(label_hint("holger-rs"), None);
assert_eq!(
label_hint("holger.rs").as_deref(),
Some("lowercase letters, digits and hyphens — e.g. holger-rs")
);
// The label follows the domain while it is empty or the old domain's; typed by hand, kept.
assert_eq!(label_follows("", "", "h").as_deref(), Some("h"));
assert_eq!(
label_follows("holger-r", "holger.r", "holger.rs").as_deref(),
Some("holger-rs")
);
assert_eq!(label_follows("mine", "holger.r", "holger.rs"), None);
}
#[test]
fn a_new_site_with_a_label_that_is_not_one_is_not_sent() {
use super::*;
let mut b = SitePutBody {
create: true,
estate: "holger.rs".into(),
domain: "holger.rs".into(),
first_admin: "ops".into(),
vendor: "stripe/x".into(),
..Default::default()
};
let (may, why) = may_put(&b, true);
assert!(
!may && why.contains("lowercase letters, digits and hyphens"),
"{why}"
);
b.estate = "holger-rs".into();
assert_eq!(may_put(&b, true), (true, String::new()));
}
/// ★ MONETIZE-FIX: a declared kind's button is an admin's with every required param typed;
/// a kind with a file is the CLI's; what is sent is the declared params, trimmed.
#[test]
fn a_declared_order_kind_is_a_button_with_its_params() {
let k = KindJson {
kind: "lab_join".into(),
params: vec![
KindParamJson {
name: "name".into(),
required: true,
..Default::default()
},
KindParamJson {
name: "key".into(),
..Default::default()
},
],
..Default::default()
};
let typed = |kv: &[(&str, &str)]| {
kv.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect::<std::collections::BTreeMap<_, _>>()
};
assert_eq!(
may_do(&k, &typed(&[("name", "lab-1")]), true),
(true, String::new())
);
assert!(!may_do(&k, &typed(&[("name", " ")]), true).0);
assert!(!may_do(&k, &typed(&[("name", "lab-1")]), false).0);
let file = KindJson {
file: "bundle".into(),
..k.clone()
};
assert!(may_do(&file, &typed(&[("name", "x")]), true)
.1
.contains("--file bundle="));
let b = do_body(
"holger-rs",
&k,
&typed(&[("name", " lab-1 "), ("key", ""), ("other", "x")]),
);
assert_eq!(b.params, typed(&[("name", "lab-1")]));
assert_eq!(
answer_path("holger-rs", 7),
"/api/sites/do?estate=holger-rs&seq=7"
);
assert_eq!(
answer_words(&DoAnswerJson {
kind: "k".into(),
answered: true,
ok: false,
detail: "no".into()
})
.0,
Tone::Bad
);
}
/// ★ **The operator's Terminate on a sold estate** (owner 2026-10-22): an admin, the
/// estate's name typed back exactly; the body carries the name, and no backup word.
#[test]
fn a_sold_estates_terminate_needs_an_admin_and_its_name_typed_back() {
use super::*;
assert!(may_terminate_estate("polka", " polka ", true));
assert!(
!may_terminate_estate("polka", "polk", true)
&& !may_terminate_estate("polka", "", true)
);
assert!(
!may_terminate_estate("polka", "polka", false),
"an admin's alone"
);
assert_eq!(
estate_terminate_body("polka", " polka "),
TerminateBody {
estate: "polka".into(),
confirm: "polka".into()
}
);
assert_eq!(
serde_json::to_value(estate_terminate_body("polka", "polka")).unwrap(),
serde_json::json!({ "estate": "polka", "confirm": "polka" }),
"no backup is asked at terminate"
);
}
use super::buyer_order_page;
/// ★ OD6: a site's buyers order a private estate on the site's own sign-up page, never on a
/// page of the ONE shop's.
#[test]
fn a_sites_buyers_order_on_the_sites_own_sign_up_page() {
assert_eq!(
buyer_order_page("test3.holger.rs").as_deref(),
Some("https://test3.holger.rs/signup?plan=private")
);
assert_eq!(buyer_order_page(" "), None);
assert!(!buyer_order_page("holger.rs").unwrap().contains("/shop/"));
}
use super::*;
/// ★ Each box's step is listed (front first when the box chose it), and a failure is said.
#[test]
fn the_update_lists_each_box_and_says_a_failure() {
let step = |role: &str, state: &str, how: &str, words: &str| UpdateStepJson {
role: role.into(),
state: state.into(),
how: how.into(),
words: words.into(),
};
let mut u = UpdateJson {
state: "running".into(),
steps: vec![
step("front", "done", "keep", "booted from the front keep medium"),
step("twin", "waiting", "", ""),
step("main", "waiting", "", ""),
],
..UpdateJson::default()
};
let l = step_lines(&u);
assert_eq!(
l[0],
(
"front".to_string(),
"done (keep) — booted from the front keep medium".to_string(),
false
)
);
assert_eq!((l[1].0.as_str(), l[1].1.as_str()), ("twin", "waiting"));
assert!(!update_failed(&u));
u.state = "failed".into();
u.steps[1] = step("twin", "failed", "", "the stage was not confirmed");
assert!(update_failed(&u) && step_lines(&u)[1].2);
assert!(
step_lines(&UpdateJson::default()).is_empty(),
"no update yet: no steps"
);
}
fn shown() -> SiteShownJson {
SiteShownJson {
site: SiteRecordJson {
estate: "test3-gunnar-rs".into(),
domain: "test3.gunnar.rs".into(),
product: "gunnar".into(),
first_admin: "ignalina".into(),
operator_site: true,
terminable: true,
vendor: "stripe/gunnar-test2".into(),
terms: TermsJson {
term_days: 1,
grace_hours: 1,
margin_bp: 2500,
term_months: 0,
},
source: "fed".into(),
secret_key_set: true,
secret_key_fingerprint: "sha256:0123abcd".into(),
..Default::default()
},
products: vec!["gunnar".into()],
vendors: vec!["stripe/gunnar".into(), "stripe/gunnar-test2".into()],
media_sites: vec!["test2-gunnar-rs".into()],
currency: "EUR".into(),
sizes: vec!["Mini".into()],
history: vec![],
}
}
/// ★ **An edit starts from the site as the box has it, never with a secret**; a new site
/// starts on a new Stripe account of its own; the choices are the box's.
#[test]
fn the_form_starts_from_the_box_and_never_holds_a_secret_it_was_not_typed() {
let e = form_of(&shown(), false);
assert_eq!(
(
e.estate.as_str(),
e.domain.as_str(),
e.vendor.as_str(),
e.terms.term_days,
e.create
),
(
"test3-gunnar-rs",
"test3.gunnar.rs",
"stripe/gunnar-test2",
1,
false
)
);
assert!(e.vendor_secret_key.is_empty() && e.vendor_webhook_secret.is_empty());
let n = form_of(
&SiteShownJson {
site: SiteRecordJson::default(),
..shown()
},
true,
);
assert_eq!(
(n.product.as_str(), n.vendor.as_str(), n.create),
("gunnar", NEW_ACCOUNT, true)
);
let c = vendor_choices(&shown(), true, "test5-gunnar-rs");
assert_eq!(
c[0],
(
NEW_ACCOUNT.to_string(),
"a new account: stripe/test5-gunnar-rs".to_string()
)
);
assert_eq!(c.len(), 3);
assert_eq!(
vendor_choices(&shown(), false, "x").len(),
2,
"an edit picks a held account"
);
assert_eq!(
secret_words(true, "sha256:0123abcd"),
"set (sha256:0123abcd)"
);
assert_eq!(secret_words(false, ""), "not set");
assert_eq!(
book_path("test3-gunnar-rs"),
"/api/sites/book?estate=test3-gunnar-rs"
);
assert_eq!(book_path(""), "/api/sites/book");
let d = format!(
"{:?}",
SitePutBody {
vendor_secret_key: "sk_test_SECRET".into(),
..e
}
);
assert!(!d.contains("SECRET"), "{d}");
}
/// ★ The form says what it lacks before the box is asked: an admin, a name, a domain, a
/// first admin, a new account's two secrets, a key of the site's mode.
#[test]
fn the_form_says_what_it_lacks() {
let mut b = form_of(
&SiteShownJson {
site: SiteRecordJson::default(),
..shown()
},
true,
);
assert!(!may_put(&b, false).0);
assert!(may_put(&b, true).1.contains("name"));
b.estate = "test5-gunnar-rs".into();
assert!(may_put(&b, true).1.contains("domain"));
b.domain = "test5.gunnar.rs".into();
assert!(may_put(&b, true).1.contains("first admin"));
b.first_admin = "ignalina".into();
assert!(may_put(&b, true).1.contains("secret key"));
b.vendor_secret_key = "sk_live_x".into();
b.vendor_webhook_secret = "whsec_x".into();
assert_eq!(may_put(&b, true).1, "a test site takes a test key");
b.vendor_secret_key = "sk_test_x".into();
assert_eq!(may_put(&b, true), (true, String::new()));
assert_eq!(put_words(&b), "Add site");
let e = form_of(&shown(), false);
assert_eq!(
may_put(&e, true),
(true, String::new()),
"an edit keeps the held secrets"
);
assert_eq!(put_words(&e), "Save");
assert_eq!((term_of(" 7 "), term_of(""), term_of("x")), (7, 0, 0));
}
fn site() -> SiteJson {
SiteJson {
estate: "gunnar-rs".into(),
domain: "gunnar.rs".into(),
vendor: "stripe/gunnar".into(),
first_admin: "ignalina".into(),
dns_words: "this box's one DNS token writes its zone".into(),
dns_writes: true,
cloud_words: "upcloud · se-sto1".into(),
state: "none".into(),
words: "Not laid by this box.".into(),
orderable: true,
..Default::default()
}
}
/// ★ A declared site's form asks its size and its first admin's address — the rest reads
/// "already fed": the domain, its own Stripe, the box's one DNS token and one cloud.
#[test]
fn a_declared_sites_form_shows_what_is_already_fed_and_asks_only_size_and_address() {
let rows = fed_rows(&site());
assert_eq!(
rows.iter().map(|r| r.0).collect::<Vec<_>>(),
vec!["Domain", "Stripe", "DNS", "Cloud", "First admin"]
);
assert!(rows.iter().all(|r| r.2), "{rows:?}");
assert_eq!(rows[0].1, "gunnar.rs, www.gunnar.rs, git.gunnar.rs");
assert_eq!(
(fed_mark(true), fed_mark(false)),
("already fed", "missing")
);
let b = order_body(&site(), " Mini ", " ops@example.invalid ", "");
assert_eq!(
(
b.estate.as_str(),
b.size.as_str(),
b.email.as_str(),
b.confirm
),
("gunnar-rs", "Mini", "ops@example.invalid", true)
);
let no_dns = SiteJson {
dns_writes: false,
dns_words: "this box's DNS token does not write its zone".into(),
..site()
};
assert!(
!fed_rows(&no_dns)[2].2,
"a zone the box cannot write is said, not hidden"
);
}
/// ★ The button is monetize's to allow: its own "why not" first, then an admin, then the
/// address; and the only link is the vendor's page while the order waits for payment.
#[test]
fn the_button_is_monetizes_to_allow_and_the_only_link_is_the_vendors_pay_page() {
assert_eq!(
press(&site(), "ops@example.invalid", true),
(true, String::new())
);
assert!(!press(&site(), "ops@example.invalid", false).0);
assert!(!press(&site(), "nobody", true).0);
let stands = SiteJson {
orderable: false,
why_not: "it stands: a site that runs is not laid over".into(),
state: "stands".into(),
..site()
};
assert_eq!(
press(&stands, "ops@example.invalid", true),
(
false,
"it stands: a site that runs is not laid over".to_string()
)
);
assert_eq!(button_words(&site()), "Order gunnar.rs");
assert_eq!(pay_link(&site()), None);
// ★ A33-6: the price is the server's word, never the page's.
let waiting = SiteJson {
state: "waiting_for_payment".into(),
checkout_url: "https://checkout.example/x".into(),
price_words: "1.25 EUR".into(),
..site()
};
assert_eq!(
pay_link(&waiting),
Some((
"Pay 1.25 EUR".to_string(),
"https://checkout.example/x".to_string()
))
);
assert_eq!(
pay_link(&SiteJson {
checkout_url: "javascript:alert(1)".into(),
..waiting
}),
None
);
assert_eq!(state_tone("ready"), Tone::Pass);
assert_eq!(state_tone("laying"), Tone::Live);
}
/// ★ The state reads as words and moves on its own; a refusal is the server's sentence,
/// never a code; Terminate needs monetize's yes and the site's name typed.
#[test]
fn the_state_reads_plainly_a_refusal_is_a_sentence_and_terminate_needs_the_name() {
assert_eq!(
[
"none",
"waiting_for_payment",
"queued",
"laying",
"installing",
"ready",
"stands"
]
.map(state_words),
[
"not ordered",
"waiting for payment",
"ordered",
"laying",
"installing",
"ready",
"running"
]
);
assert!(
in_flight("queued")
&& in_flight("laying")
&& in_flight("installing")
&& in_flight("waiting_for_payment")
);
assert!(
!in_flight("ready")
&& !in_flight("none")
&& !in_flight("stands")
&& !in_flight("failed")
);
assert_eq!(
fail_words(400, "gunnar.rs already runs: nothing was ordered"),
"gunnar.rs already runs: nothing was ordered"
);
assert_eq!(
fail_words(503, ""),
"The box did not answer. Try again in a moment."
);
let old = SiteJson {
terminable: true,
state: "ready".into(),
..site()
};
assert!(may_terminate(&old, " gunnar.rs ", true));
assert!(!may_terminate(&old, "gunnar", true) && !may_terminate(&old, "gunnar.rs", false));
assert!(
!may_terminate(&site(), "gunnar.rs", true),
"a protected site offers no terminate"
);
assert!(
!offers_terminate(&SiteJson {
terminable: false,
state: "owed".into(),
..site()
}),
"an owed site of which nothing stands: monetize says no"
);
assert!(offers_terminate(&SiteJson {
terminable: true,
state: "stands".into(),
..site()
}));
assert_eq!(state_words("owed"), "setup failed after payment");
assert_eq!(state_tone("owed"), Tone::Bad);
assert!(!in_flight("owed"));
// M-FOLLOW 1: a site whose shop cannot answer is never shown as a ready one.
assert_eq!(
(state_words("unjoined").as_str(), state_tone("unjoined")),
("not joined: its shop cannot answer", Tone::Bad)
);
assert!(!in_flight("unjoined"));
assert_eq!(
settle_post(&site(), "abandon").1,
serde_json::json!({ "action": "abandon", "confirm": true })
);
assert_eq!(
(
state_words("install_failed").as_str(),
state_tone("install_failed")
),
("install failed", Tone::Bad)
);
assert!(
offers_terminate(&SiteJson {
terminable: true,
state: "install_failed".into(),
..site()
}),
"a stopped install's boxes are terminated"
);
assert!(
!offers_terminate(&SiteJson {
terminable: false,
state: "install_failed".into(),
..site()
}),
"unless protected"
);
assert_eq!(
settle_post(&site(), "refund"),
(
"/api/estate-orders/gunnar-rs/settle".to_string(),
serde_json::json!({ "action": "refund", "confirm": true })
)
);
assert_eq!(
terminate_body(&old, " gunnar.rs "),
TerminateBody {
estate: "gunnar-rs".into(),
confirm: "gunnar.rs".into()
}
);
}
/// ★ **F2/F5, A32-29: a site's settles are the server's** (monetize-ui-server
/// `site_order::site_settles`): the page reads them, words and all, off the site's JSON and
/// keeps no rule of its own on the state.
#[test]
fn a_sites_settles_are_read_off_the_servers_answer() {
let j = serde_json::json!({"estate": "gunnar-rs", "state": "install_failed", "recheckable": true, "actions": [
{"verb": "recheck", "label": "Check again", "confirm": "Confirm: check every box again"},
{"verb": "retry", "label": "Set up again", "confirm": "Confirm: set it up again"}]});
let s: SiteJson = serde_json::from_value(j).expect("a site");
assert_eq!(
s.actions
.iter()
.map(|a| (a.verb.as_str(), a.label.as_str(), a.confirm.as_str()))
.collect::<Vec<_>>(),
[
("recheck", "Check again", "Confirm: check every box again"),
("retry", "Set up again", "Confirm: set it up again")
]
);
let none: SiteJson =
serde_json::from_value(serde_json::json!({"estate": "x", "state": "owed"}))
.expect("an older server");
assert!(
none.actions.is_empty(),
"an older server sends none: the page draws none"
);
assert_eq!(
settle_post(&s, "recheck").1,
serde_json::json!({ "action": "recheck", "confirm": true })
);
}
/// ★ **F3: Terminate is monetize's word alone** (live 2026-10-20: an owed site whose boxes
/// stood showed no Terminate): an owed site monetize calls terminable offers it, behind the
/// same guard (an admin, the name typed back); the page keeps no rule of its own on the state.
#[test]
fn an_owed_site_with_machines_offers_terminate_because_monetize_says_so() {
let owed = SiteJson { terminable: true, state: "owed".into(), words: "The setup failed after payment; the operator can retry or refund. Last failure: x Terminate removes its boxes.".into(), ..site() };
assert!(
offers_terminate(&owed),
"monetize says its boxes can be removed"
);
assert!(may_terminate(&owed, " gunnar.rs ", true));
assert!(
!may_terminate(&owed, "gunnar", true) && !may_terminate(&owed, "gunnar.rs", false),
"the guard stands"
);
for st in [
"none",
"waiting_for_payment",
"owed",
"failed",
"install_failed",
"installing",
"ready",
"stands",
"terminating",
] {
assert!(
offers_terminate(&SiteJson {
terminable: true,
state: st.into(),
..site()
}),
"{st}: monetize's yes is the answer"
);
assert!(
!offers_terminate(&SiteJson {
terminable: false,
state: st.into(),
..site()
}),
"{st}: monetize's no is the answer"
);
}
}
fn boxes(states: &[(&str, &str, &str)]) -> Vec<UpdateBoxJson> {
states
.iter()
.map(|(r, st, w)| UpdateBoxJson {
role: r.to_string(),
state: st.to_string(),
words: w.to_string(),
})
.collect()
}
/// ★ **Never an update to the release a site runs** (owner 2026-10-17, test2 live): up to
/// date says so and names the release; the button only for a newer release monetize says
/// can be taken, named for a person; when not, monetize's plain reason; each box its line.
#[test]
fn an_up_to_date_site_is_said_so_and_the_button_is_only_for_a_newer_release_that_can_be_taken()
{
let current = UpdateJson {
release: "27478f3a499ff8c5".into(),
up_to_date: true,
release_name: "r5.4 · 2026-10-17 · gunnar aa14357 · gunnar-ui f4b5d44".into(),
live_on: true,
boxes: boxes(&[
("twin", "current", "current · release 27478f3a499f"),
("main", "current", "current · release 27478f3a499f"),
("front", "current", "current · release 38a4a08277d7"),
]),
..Default::default()
};
assert_eq!(
update_head(¤t),
Some((
Tone::Pass,
"Up to date · r5.4 · 2026-10-17 · gunnar aa14357 · gunnar-ui f4b5d44".to_string()
))
);
assert_eq!(
update_button(¤t, true),
None,
"no update to the release it runs"
);
assert_eq!(
release_words(¤t).1,
"27478f3a499f",
"the hash only as the small id"
);
assert_eq!(
release_words(&UpdateJson {
release_name: String::new(),
..current.clone()
})
.0,
"release 27478f3a499f",
"no note: the short id"
);
assert_eq!(
box_lines(¤t).iter().map(|b| b.2).collect::<Vec<_>>(),
vec![Tone::Pass, Tone::Pass, Tone::Pass],
"every role has its state"
);
let stuck = UpdateJson { up_to_date: false, offered: false, why_not: "No newer release with update media is published yet: release 27478f3a499f carries neither an in-place upgrade nor a live update for test2.gunnar.rs.".into(), ..current.clone() };
assert_eq!(update_button(&stuck, true), None);
let (_, why) = update_head(&stuck).unwrap();
assert!(
why.starts_with("No newer release with update media is published yet")
&& !why.contains('`'),
"{why}"
);
let newer = UpdateJson {
up_to_date: false,
offered: true,
why_not: String::new(),
..current.clone()
};
assert_eq!(update_head(&newer), None, "the button is the answer");
assert_eq!(
update_button(&newer, true),
Some((
true,
"Update to r5.4 · 2026-10-17 · gunnar aa14357 · gunnar-ui f4b5d44".to_string(),
String::new()
))
);
assert!(!update_button(&newer, false).unwrap().0, "an admin's");
let running = UpdateJson {
state: "running".into(),
..newer.clone()
};
assert!(!update_button(&running, true).unwrap().0 && update_head(&running).is_none());
let s = SiteJson {
estate: "test2-gunnar-rs".into(),
state: "ready".into(),
..Default::default()
};
assert_eq!(
update_body(&s.estate, &newer),
UpdateBody {
estate: "test2-gunnar-rs".into(),
release: "27478f3a499ff8c5".into(),
confirm: true
}
);
let unreported = UpdateJson { boxes: boxes(&[("front", "not-reported", "not reported yet — the front has no update taker yet; the update starts with the front")]), ..current };
assert_eq!(box_lines(&unreported)[0], ("front".to_string(), "not reported yet — the front has no update taker yet; the update starts with the front".to_string(), Tone::Plain));
}
/// ★ **A site reads in one glance** (owner 2026-10-17): one "Configured" line, not "already
/// fed" on every field; the ordering UI only for a site not ordered yet; a running site says
/// where it runs; a kit-installed site says it is not managed here until adopted; the live
/// switch says what off means.
#[test]
fn a_site_reads_configured_running_or_not_managed_and_the_switch_says_what_off_means() {
assert_eq!(configured(&site()), (true, "Configured".to_string()));
assert_eq!(
configured(&SiteJson {
dns_writes: false,
..site()
}),
(false, "Missing: DNS".to_string())
);
assert!(shows_order(&site()));
let running = SiteJson {
state: "ready".into(),
domain: "test2.gunnar.rs".into(),
words:
"The site is ready at https://test2.gunnar.rs. Its first sign-in link was mailed."
.into(),
orderable: false,
why_not: "it is already ordered".into(),
..site()
};
assert!(
!shows_order(&running),
"no 'Cannot be ordered' on a running site"
);
assert_eq!(site_words(&running), "Running at https://test2.gunnar.rs");
assert!(offers_update(&running) && not_managed(&running).is_none());
let kit = SiteJson {
state: "stands".into(),
..site()
};
assert!(!shows_order(&kit) && !offers_update(&kit));
assert_eq!(
not_managed(&kit),
Some("Not managed by this box yet — adopt it to update it here")
);
assert_eq!(site_words(&kit), "Running at https://gunnar.rs");
let u = UpdateJson {
live_on: false,
..Default::default()
};
let (label, on, meaning) = live_words(&u);
assert_eq!((label, on), ("Live updates", false));
assert!(
meaning
.contains("Off = every update reinstalls the box from its keep medium, /data kept"),
"{meaning}"
);
assert_eq!(
live_body("test2-gunnar-rs", &u),
LiveBody {
estate: "test2-gunnar-rs".into(),
on: true
}
);
}
/// ★ **FIRST-LINK-1: the door's state stands beside the button**, in words, from the
/// server's field; nothing when the server said nothing.
#[test]
fn the_first_admin_door_is_said_beside_the_send_again_button() {
let mut s = site();
assert_eq!(door_line(&s), None);
s.first_admin_door = "open".into();
assert_eq!(door_line(&s).as_deref(), Some("First-admin door: open"));
s.first_admin_door = "handed_off".into();
s.first_admin_door_words = "its main says the link was sent to r***@ignalina.dk".into();
assert_eq!(door_line(&s).as_deref(), Some("First-admin door: handed off to the site — its main says the link was sent to r***@ignalina.dk"));
s.first_admin_door = "spent".into();
assert!(door_line(&s)
.unwrap()
.starts_with("First-admin door: spent"));
s.first_admin_door = "expired".into();
assert!(door_line(&s)
.unwrap()
.starts_with("First-admin door: expired"));
}
}