crates/monetize-ui-server/tests/site_money.rs
//! ★ **A site's Money page shows that site's money only** (owner, live
//! 2026-10-12: "test2-gunnar-rs · Money" listed `stripe/gunnar:cs_live_…`
//! rows — gunnar.rs's Stripe account — under Paid at the vendor, Owed back and
//! Paid signups). The ONE box sells for two sites through two Stripe
//! instances (`[estates.<label>] vendor`); each site's panels list only rows
//! of its own instance. With no site named, every row (the box's own view).
use std::sync::Arc;
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::products_server::{Products, ProductsServer};
use monetize_proto::reconcile_admin_server::{ReconcileAdmin, ReconcileAdminServer};
use monetize_proto::signup_admin_server::{SignupAdmin, SignupAdminServer};
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 = "site-money-bearer";
const ADMIN: &str = "break-glass-site-money";
#[derive(Clone)]
struct Svc;
fn row(vendor: &str, session: &str) -> pb::SignupRow {
pb::SignupRow {
vendor: vendor.into(),
session: session.into(),
amount: Some(pb::Money {
minor: 12_500,
currency: pb::Currency::Sek as i32,
}),
..Default::default()
}
}
fn case(vendor: &str, session: &str, state: &str) -> pb::PaidAtVendorCase {
pb::PaidAtVendorCase {
vendor: vendor.into(),
session: session.into(),
state: state.into(),
amount_minor: 12_500,
currency: "sek".into(),
..Default::default()
}
}
#[tonic::async_trait]
impl Products for Svc {
async fn list(
&self,
_: TReq<pb::ListProductsRequest>,
) -> Result<Response<pb::ListProductsResponse>, Status> {
Ok(Response::new(pb::ListProductsResponse {
vendors: vec!["stripe/gunnar".into(), "stripe/gunnar-test2".into()],
estate_vendors: [
("gunnar-rs".to_string(), "stripe/gunnar".to_string()),
(
"test2-gunnar-rs".to_string(),
"stripe/gunnar-test2".to_string(),
),
]
.into(),
..Default::default()
}))
}
}
#[tonic::async_trait]
impl SignupAdmin for Svc {
async fn list(
&self,
_: TReq<pb::ListSignupsRequest>,
) -> Result<Response<pb::ListSignupsResponse>, Status> {
Ok(Response::new(pb::ListSignupsResponse {
signups: vec![
row("stripe/gunnar", "cs_live_kicka"),
row("stripe/gunnar-test2", "cs_test_t2"),
],
}))
}
async fn resend_welcome(
&self,
_: TReq<pb::ResendWelcomeRequest>,
) -> Result<Response<pb::ResendWelcomeResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn list_owed_back(
&self,
_: TReq<pb::ListOwedBackRequest>,
) -> Result<Response<pb::ListOwedBackResponse>, Status> {
Ok(Response::new(pb::ListOwedBackResponse {
signups: vec![
row("stripe/gunnar", "cs_live_kicka"),
row("stripe/gunnar-test2", "cs_test_owed"),
],
total: 2,
}))
}
async fn refund(
&self,
_: TReq<pb::RefundSignupRequest>,
) -> Result<Response<pb::RefundSignupResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
async fn settle(
&self,
_: TReq<pb::SettleSignupRequest>,
) -> Result<Response<pb::RefundSignupResponse>, Status> {
Err(Status::unimplemented("not in this mock"))
}
}
#[tonic::async_trait]
impl ReconcileAdmin for Svc {
async fn list_paid_at_vendor(
&self,
_: TReq<pb::ListPaidAtVendorRequest>,
) -> Result<Response<pb::ListPaidAtVendorResponse>, Status> {
Ok(Response::new(pb::ListPaidAtVendorResponse {
cases: vec![
case("stripe/gunnar", "cs_live_kicka", "settled"),
case("stripe/gunnar", "cs_live_x", "needs-operator"),
case("stripe/gunnar-test2", "cs_test_t2", "settled"),
],
total: 3,
needs_operator: 1,
settled: 2,
last_reconcile_unix_ms: 1,
}))
}
}
async fn console() -> axum::Router {
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = l.local_addr().unwrap().to_string();
tokio::spawn(
tonic::transport::Server::builder()
.add_service(ProductsServer::new(Svc))
.add_service(SignupAdminServer::new(Svc))
.add_service(ReconcileAdminServer::new(Svc))
.serve_with_incoming(tonic::transport::server::TcpIncoming::from(l)),
);
let monetize = Monetize::connect_lazy(&at, BEARER).unwrap();
let app = Arc::new(App {
operators: None,
admin_token: Some(ADMIN.into()),
events: EventHub::spawn(monetize.clone(), Duration::from_secs(3600)),
monetize,
operator: None,
ui_dir: None,
});
serve::router(app)
}
async fn get(router: &axum::Router, path: &str) -> Value {
let req = Request::builder()
.uri(path)
.header("authorization", format!("Bearer {ADMIN}"))
.body(Body::empty())
.unwrap();
let res = router.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK, "{path}");
serde_json::from_slice(&res.into_body().collect().await.unwrap().to_bytes()).unwrap()
}
fn sessions(rows: &Value) -> Vec<String> {
rows.as_array()
.unwrap()
.iter()
.map(|r| r["session"].as_str().unwrap().to_owned())
.collect()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn each_sites_money_panels_list_only_its_own_stripe_rows() {
let router = console().await;
let t2 = get(&router, "/api/signups?estate=test2-gunnar-rs").await;
assert_eq!(
sessions(&t2["signups"]),
["cs_test_t2"],
"test2's paid signups: {t2}"
);
let t2 = get(&router, "/api/owed-back?estate=test2-gunnar-rs").await;
assert_eq!(
(sessions(&t2["signups"]), t2["total"].as_u64()),
(vec!["cs_test_owed".to_string()], Some(1)),
"test2's owed back: {t2}"
);
let t2 = get(&router, "/api/paid-at-vendor?estate=test2-gunnar-rs").await;
assert_eq!(
(sessions(&t2["cases"]), t2["needs_operator"].as_u64()),
(vec!["cs_test_t2".to_string()], Some(0)),
"test2's paid at the vendor: {t2}"
);
let own = get(&router, "/api/signups?estate=gunnar-rs").await;
assert_eq!(
sessions(&own["signups"]),
["cs_live_kicka"],
"gunnar.rs keeps its own: {own}"
);
let own = get(&router, "/api/paid-at-vendor?estate=gunnar-rs").await;
assert_eq!(
(
own["cases"].as_array().unwrap().len(),
own["needs_operator"].as_u64()
),
(2, Some(1)),
"{own}"
);
// A site this box sells through no vendor for lists nothing, never another's.
let ghost = get(&router, "/api/signups?estate=ghost").await;
assert!(ghost["signups"].as_array().unwrap().is_empty(), "{ghost}");
// No site named: the whole box.
let all = get(&router, "/api/signups").await;
assert_eq!(all["signups"].as_array().unwrap().len(), 2);
}