Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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);
}