crates/monetize-ui-server/tests/accounts.rs
//! **ACCOUNTS over the wire** (ONE monetize, 5.4): the console's
//! `/api/accounts` and `/api/accounts/<product>/<tenant…>` against a mock
//! monetize that speaks `Accounts.List` / `Accounts.Rows`, through the real
//! router (auth included). An account is (product, tenant); a Private Gunnar
//! is a row on its gunnar account; Stripe is the product's.
use std::sync::{Arc, Mutex};
use std::time::Duration;
use axum::body::Body;
use http::{Request, StatusCode};
use http_body_util::BodyExt;
use monetize_proto as pb;
use monetize_proto::accounts_server::{Accounts, AccountsServer};
use monetize_ui_server::app::App;
use monetize_ui_server::grpc::Monetize;
use monetize_ui_server::serve;
use monetize_ui_server::sse::EventHub;
use serde_json::Value;
use tonic::{Request as TReq, Response, Status};
use tower::ServiceExt;
const BEARER: &str = "the-accounts-bearer";
const ADMIN: &str = "break-glass-accounts";
pub mod fixture {
use monetize_proto as pb;
pub const T_ISSUED: u64 = 1_759_276_800_000; // 2025-10-01
pub const T_PG: u64 = 1_759_881_600_000; // 2025-10-08
pub const UNTIL: u64 = 1_793_836_800_000; // 2026-11-05
fn summary(product: &str, tenant: &str, state: pb::State, rows: u32, instances: u32, needs_you: bool, livemode: Option<bool>) -> pb::AccountSummary {
pb::AccountSummary {
product: product.into(),
tenant: tenant.into(),
vendor: format!("stripe/{product}"),
state: state as i32,
paid_until_unix_ms: (state != pb::State::Unspecified).then_some(UNTIL),
rows,
instances,
last_unix_ms: T_PG,
needs_you,
livemode,
}
}
/// Three accounts: acme on gunnar (a SaaS plan, a Private Gunnar, a
/// payment), acme on holger (the same company, another account, holger's
/// Stripe), bolt on gunnar (only a Private Gunnar, stopped: needs you).
pub fn list() -> pb::ListAccountsResponse {
pb::ListAccountsResponse {
accounts: vec![
summary("gunnar", "acme", pb::State::Paid, 3, 1, false, Some(true)),
summary("holger", "acme", pb::State::Paid, 2, 0, false, Some(true)),
summary("gunnar", "bolt", pb::State::Unspecified, 1, 1, true, Some(false)),
],
vendors: [("gunnar".to_string(), "stripe/gunnar".to_string()), ("holger".to_string(), "stripe/holger".to_string())].into(),
}
}
pub fn acme_rows() -> pb::AccountRowsResponse {
let fact = pb::EntitlementFact { tenant: "acme".into(), plan: "10gb".into(), state: pb::State::Paid as i32, paid_until_unix_ms: Some(UNTIL), issued_unix_ms: Some(T_ISSUED), ..Default::default() };
let inst = pb::AccountInstance { kind: "estate".into(), company: "acme".into(), host: "acme.gunnar.rs".into(), phase: "laid".into(), state: "ready".into(), monthly_words: "132.00 EUR a month".into(), paid_until: "2027-04-07".into(), ..Default::default() };
let sale = pb::Sale { reference: "gunnar/acme/2025-10-01+ab12".into(), outcome: "granted".into(), drawn: true, ..Default::default() };
pb::AccountRowsResponse {
account: Some(list().accounts[0].clone()),
rows: vec![
pb::AccountRow { kind: "subscription".into(), vendor: "stripe/gunnar".into(), words: "Paid until 2026-11-05 · 10 GiB".into(), at_unix_ms: T_ISSUED, fact: Some(fact), ..Default::default() },
pb::AccountRow { kind: "instance".into(), id: "acme".into(), vendor: "stripe/gunnar".into(), words: "Private Gunnar acme · laid · 132.00 EUR a month".into(), at_unix_ms: T_PG, instance: Some(inst), ..Default::default() },
pb::AccountRow { kind: "purchase".into(), id: "gunnar/acme/2025-10-01+ab12".into(), vendor: "stripe/gunnar".into(), words: "Sold acme 10 GiB · 110.00 SEK".into(), at_unix_ms: T_ISSUED, sale: Some(sale), ..Default::default() },
],
}
}
}
#[derive(Default)]
struct Mock {
/// `None`: a monetize whose Accounts handler has not landed (UNIMPLEMENTED).
list: Option<pb::ListAccountsResponse>,
rows: Vec<pb::AccountRowsResponse>,
asked: Mutex<Vec<(String, String)>>,
}
#[derive(Clone)]
struct Svc(Arc<Mock>);
#[tonic::async_trait]
impl Accounts for Svc {
async fn list(&self, _: TReq<pb::ListAccountsRequest>) -> Result<Response<pb::ListAccountsResponse>, Status> {
self.0.list.clone().map(Response::new).ok_or_else(|| Status::unimplemented("Accounts.List is not served by this monetize yet"))
}
async fn rows(&self, req: TReq<pb::AccountRowsRequest>) -> Result<Response<pb::AccountRowsResponse>, Status> {
let r = req.into_inner();
self.0.asked.lock().unwrap().push((r.product.clone(), r.tenant.clone()));
let a = |x: &pb::AccountRowsResponse| x.account.as_ref().map(|a| (a.product.clone(), a.tenant.clone()));
self.0.rows.iter().find(|x| a(x) == Some((r.product.clone(), r.tenant.clone()))).cloned().map(Response::new).ok_or_else(|| Status::not_found(format!("no account ({}, {})", r.product, r.tenant)))
}
async fn instance_log(&self, _: TReq<pb::InstanceLogRequest>) -> Result<Response<pb::InstanceLogResponse>, Status> {
Ok(Response::new(pb::InstanceLogResponse::default()))
}
}
async fn spawn(mock: Arc<Mock>) -> String {
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = l.local_addr().unwrap();
tokio::spawn(tonic::transport::Server::builder().add_service(AccountsServer::new(Svc(mock))).serve_with_incoming(tonic::transport::server::TcpIncoming::from(l)));
at.to_string()
}
async fn get(mock: Arc<Mock>, uri: &str) -> (StatusCode, Value) {
let control = spawn(mock).await;
let monetize = Monetize::connect_lazy(&control, BEARER).unwrap();
let app = Arc::new(App { webauthn: None, admin_token: Some(ADMIN.into()), events: EventHub::spawn(monetize.clone(), Duration::from_secs(3600)), monetize, operator: None, ui_dir: None });
let req = Request::builder().uri(uri).header("authorization", format!("Bearer {ADMIN}")).body(Body::empty()).unwrap();
let res = serve::router(app).oneshot(req).await.unwrap();
let status = res.status();
let bytes = res.into_body().collect().await.unwrap().to_bytes();
(status, serde_json::from_slice(&bytes).unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).into_owned())))
}
fn full() -> Arc<Mock> {
Arc::new(Mock { list: Some(fixture::list()), rows: vec![fixture::acme_rows()], ..Default::default() })
}
/// ★ **The accounts list**: one line per (product, tenant), the product's
/// Stripe on each, the same company on two products as two accounts.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_accounts_list_is_every_product_tenant_with_its_products_stripe() {
let (status, j) = get(full(), "/api/accounts").await;
assert_eq!(status, StatusCode::OK, "{j}");
let keys: Vec<(&str, &str, &str)> = j["accounts"].as_array().unwrap().iter().map(|a| (a["product"].as_str().unwrap(), a["tenant"].as_str().unwrap(), a["vendor"].as_str().unwrap())).collect();
assert_eq!(keys, [("gunnar", "acme", "stripe/gunnar"), ("holger", "acme", "stripe/holger"), ("gunnar", "bolt", "stripe/gunnar")], "{j}");
assert_eq!(j["accounts"][0]["state"], "paid", "{j}");
assert!(j["accounts"][2]["state"].is_null(), "bolt holds no SaaS plan: {j}");
assert_eq!(j["accounts"][2]["needs_you"], true, "{j}");
assert_eq!(j["vendors"]["holger"], "stripe/holger", "{j}");
}
/// ★ **One account's rows**: the SaaS plan and the Private Gunnar as rows,
/// the purchase as a payment; a tenant with a slash reaches monetize whole.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn one_accounts_rows_are_its_saas_plan_and_its_private_instances() {
let (status, j) = get(full(), "/api/accounts/gunnar/acme").await;
assert_eq!(status, StatusCode::OK, "{j}");
let kinds: Vec<&str> = j["rows"].as_array().unwrap().iter().map(|r| r["kind"].as_str().unwrap()).collect();
assert_eq!(kinds, ["saas", "private-instance"], "{j}");
assert_eq!(j["rows"][1]["host"], "acme.gunnar.rs", "{j}");
assert!(j["rows"][1].get("reach").is_none(), "pull model: monetize never reaches into an estate: {j}");
assert!(j["rows"][1]["last_heard_unix_ms"].is_null(), "the estate has not called home yet (no pull channel): {j}");
assert!(j["rows"][0]["last_heard_unix_ms"].is_null(), "a SaaS plan has no estate of its own: {j}");
assert_eq!(j["payments"][0]["reference"], "gunnar/acme/2025-10-01+ab12", "{j}");
let mock = full();
let (status, j) = get(mock.clone(), "/api/accounts/gunnar/team/sub").await;
assert_eq!(status, StatusCode::NOT_FOUND, "an account monetize does not know is a 404 in its words: {j}");
assert!(j["message"].as_str().unwrap_or_default().contains("no account (gunnar, team/sub)"), "{j}");
assert_eq!(mock.asked.lock().unwrap().as_slice(), [("gunnar".to_string(), "team/sub".to_string())], "the tenant reaches monetize whole");
}
/// A monetize whose Accounts handler has not landed says so — 501 with its
/// words, never an empty list that reads as "no accounts".
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_monetize_without_accounts_says_so_never_an_empty_list() {
let (status, j) = get(Arc::new(Mock::default()), "/api/accounts").await;
assert_eq!(status, StatusCode::NOT_IMPLEMENTED, "{j}");
assert_eq!(j["code"], "unimplemented", "{j}");
}
/// The agreed accounts screen, drawn by the console's own cells
/// (`monetize_ui_web::accounts`) from THIS server's answers over the mock.
const ACCOUNTS_SCREEN: &str = "\
Accounts
product | tenant | rows | Stripe | status
--------+--------+---------------------------------------+----------------------+--------------------------------
gunnar | acme | SaaS plan · 1 Private Gunnar instance | stripe/gunnar · live | SaaS plan paid until 2026-11-05
holger | acme | SaaS plan | stripe/holger · live | SaaS plan paid until 2026-11-05
gunnar | bolt | 1 Private Gunnar instance | stripe/gunnar · test | needs you
";
const ACME_SCREEN: &str = "\
Account gunnar / acme
Stripe: stripe/gunnar · live · status: SaaS plan paid until 2026-11-05
Rows
kind | plan / stop | host | last heard | billing | since
-----------------+--------------------+----------------+------------+------------------------+-----------
saas | 10gb | — | — | paid until 2026-11-05 | 2025-10-01
private-instance | 132.00 EUR a month | acme.gunnar.rs | never | ready until 2027-04-07 | 2025-10-08
Payments
when | reference | outcome | what
-----------+-----------------------------+---------+------------------------------
2025-10-01 | gunnar/acme/2025-10-01+ab12 | granted | Sold acme 10 GiB · 110.00 SEK
";
/// ★ **The screens, end to end**: monetize's Accounts answers → this
/// server's JSON → the console's cells. What the operator reads.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_accounts_screens_read_as_agreed_from_this_servers_answers() {
use monetize_ui_web::accounts::{account_text, accounts_text, AccountDetailJson, AccountsJson};
let (_, list) = get(full(), "/api/accounts").await;
let list: AccountsJson = serde_json::from_value(list).unwrap();
let (_, acme) = get(full(), "/api/accounts/gunnar/acme").await;
let acme: AccountDetailJson = serde_json::from_value(acme).unwrap();
let (a, b) = (accounts_text(&list), account_text(&acme, 1_793_836_800_000));
println!("{a}\n{b}");
assert_eq!(a, ACCOUNTS_SCREEN);
assert_eq!(b, ACME_SCREEN);
}