crates/monetize-server/tests/console_signup_contract.rs
//! **The console's signup calls against ONE monetize selling one product for two SITES, each
//! site its own vendor INSTANCE** (`stripe/gunnar` for gunnar.rs, `stripe/gunnar-test2` for
//! test2.gunnar.rs) — the wire the gunnar console speaks, replayed against the real server.
//!
//! The console (gunnar-ui `auth::signup`, and the shared relay monetize-site-buy it speaks
//! through) names NO vendor — [`CONSOLE_VENDOR`], `""` (A32-15: the payment core is no vendor's
//! shape) — on `Signup.Start`, `Signup.Status` and `Signup.Rename`. It never knows the site's
//! instance: which account takes the money is monetize's decision (`[estates.<e>] vendor`), the
//! calling estate's own instance answers, and the console only learns the session id in its
//! return URL. (Until A32-15 it named the KIND, `"stripe"`; the server still maps a kind.)
//!
//! i54a run30/31 (2026-10-12): the console's `Signup.Start` with `vendor: "stripe"` met exact
//! equality against the routed instance `stripe/gunnar-test2`, found no vendor and answered
//! UNIMPLEMENTED — "An account is not sold here right now." on test2's signup page. A unit test
//! of the registry was added with the fix (monetize 7f9446fa); THIS file holds the console's
//! side of the contract over the wire, including the poll the welcome page makes after the
//! card: the webhook keeps the paid signup under the INSTANCE (`monetize_pay::instance` renames
//! a `PaidSignup`'s vendor), and the console asks naming no vendor.
//!
//! gunnar-ui's `tests/console_signup_contract.rs` reads [`CONSOLE_VENDOR`] out of THIS file
//! (its strong half, with a monetize checkout beside it) and holds its own console to it.
use std::sync::Arc;
use monetize_pay::signup::{PaidSignup, SignupCheckout, SignupOrder};
use monetize_pay::{CallbackEvent, ChargeHandle, ChargeRequest, ChargeStatus, Currency, Market, Money, PayError, RawCallback, Settlement};
use monetize_proto as pb;
use monetize_proto::signup_client::SignupClient;
use monetize_proto::signup_status_response::State;
use monetize_server::config::{EstateConfig, SignupPlan};
use monetize_server::registry::Registry;
use monetize_server::state::AppState;
use monetize_server::testing::TestServer;
use tonic::metadata::MetadataValue;
use tonic::transport::Channel;
use tonic::{Code, Request};
/// **THE CONTRACT**: the vendor name the gunnar console sends on every `Signup.*` call: none
/// (A32-15), never an instance (no `/`). gunnar-ui's contract test reads this line.
pub const CONSOLE_VENDOR: &str = "";
const SERVICE: &str = "test-bearer-token-of-sufficient-length";
const HOME: &str = "gunnar-rs";
const TEST2: &str = "test2-gunnar-rs";
const HOME_BEARER: &str = "estate-bearer-of-gunnar-rs-0123456789";
const TEST2_BEARER: &str = "estate-bearer-of-test2-0123456789abcd";
const PLAN: &str = "gunnar/10gb-1m";
/// One site's Stripe account: a vendor INSTANCE that mints its own checkout pages.
struct Site(&'static str);
#[allow(dead_code)]
impl Site {
pub fn vendor(&self) -> &'static str {
self.0
}
pub fn markets(&self) -> &[Market] {
&[Market::Se]
}
pub fn reserve(&self, r: &ChargeRequest) -> Result<ChargeHandle, PayError> {
Ok(ChargeHandle { vendor: self.0.into(), id: format!("cs_{}", r.reference.len()) })
}
pub fn draw(&self, _: &ChargeHandle) -> Result<ChargeStatus, PayError> {
Err(PayError::Unavailable("not here".into()))
}
pub fn unlock(&self, _: &ChargeHandle) -> Result<ChargeStatus, PayError> {
Ok(ChargeStatus::Released)
}
pub fn charge_status(&self, _: &ChargeHandle) -> Result<ChargeStatus, PayError> {
Ok(ChargeStatus::Reserved)
}
pub fn parse_callback(&self, _: RawCallback<'_>) -> Result<CallbackEvent, PayError> {
Err(PayError::Ignored(format!("seen by {}", self.0)))
}
pub fn give_back(&self, _: &ChargeHandle, _: Money) -> Result<ChargeStatus, PayError> {
Err(PayError::Unavailable("not here".into()))
}
pub fn settled(&self, _: &ChargeHandle) -> Option<Settlement> {
None
}
pub fn redirect(&self, _: &ChargeHandle) -> Option<String> {
None
}
pub fn start_signup(&self, o: &SignupOrder) -> Result<Option<SignupCheckout>, PayError> {
let session = format!("cs_{}_{}", self.0.replace('/', "_"), o.username);
Ok(Some(SignupCheckout { url: format!("https://checkout.example/{}/{session}", self.0), session }))
}
}
#[allow(unused_variables)]
impl monetize_pay::payment::Payment for Site {
fn manifest(&self) -> monetize_pay::payment::PayManifest {
monetize_pay::payment::PayManifest { vendor: self.vendor().to_string(), markets: self.markets().to_vec(), settings: Vec::new(), holds: true, test_payer: None }
}
fn page(&self, charge: &monetize_pay::payment::Charge<'_>, back: &monetize_pay::payment::Return) -> Result<monetize_pay::payment::Page, monetize_pay::PayError> {
Ok(match charge {
monetize_pay::payment::Charge::Order(req) => monetize_pay::payment::Page::Order { handle: self.reserve(req)?, url: None },
monetize_pay::payment::Charge::Signup(o) => monetize_pay::payment::Page::Signup(self.start_signup(o)?),
monetize_pay::payment::Charge::CardCheck(o) => monetize_pay::payment::Page::CardCheck(None),
monetize_pay::payment::Charge::TestSignup(o) => monetize_pay::payment::Page::TestSignup(None),
})
}
fn status(&self, of: &monetize_pay::payment::Of<'_>) -> Result<monetize_pay::payment::Standing, monetize_pay::PayError> {
Ok(match of {
monetize_pay::payment::Of::Charge(h) => monetize_pay::payment::Standing::Charge(self.charge_status(h)?),
monetize_pay::payment::Of::Page(h) => monetize_pay::payment::Standing::Page(self.redirect(h)),
monetize_pay::payment::Of::Signup { session } => monetize_pay::payment::Standing::Signup(None),
monetize_pay::payment::Of::CardCheck { session } => monetize_pay::payment::Standing::CardCheck(None),
})
}
fn capture(&self, h: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
self.draw(h)
}
fn release(&self, h: &monetize_pay::ChargeHandle) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
self.unlock(h)
}
fn refund(&self, h: &monetize_pay::ChargeHandle, amount: monetize_pay::Money) -> Result<monetize_pay::ChargeStatus, monetize_pay::PayError> {
self.give_back(h, amount)
}
fn callback(&self, raw: monetize_pay::RawCallback<'_>) -> Result<monetize_pay::CallbackEvent, monetize_pay::PayError> {
self.parse_callback(raw)
}
fn settlement(&self, h: &monetize_pay::ChargeHandle) -> Option<monetize_pay::Settlement> {
self.settled(h)
}
fn paid_since(&self, since_unix_ms: i64) -> Result<Vec<monetize_pay::reconcile::PaidAtVendor>, monetize_pay::PayError> {
Err(monetize_pay::reconcile::cannot_be_asked())
}
fn livemode(&self) -> Option<bool> {
None
}
}
struct Rig {
state: Arc<AppState>,
_server: TestServer,
channel: Channel,
}
impl Rig {
async fn up() -> Rig {
let site = |vendor: &str, site: &str| EstateConfig { product: "gunnar".into(), vendor: vendor.into(), owner: "ignalina".into(), site: site.into(), ..Default::default() };
let reg = Registry::new()
.vendor(Site("stripe/gunnar"))
.vendor(Site("stripe/gunnar-test2"))
.route("gunnar", "stripe/gunnar")
.home("gunnar", HOME)
.estate(HOME, site("stripe/gunnar", "gunnar.rs"))
.estate(TEST2, site("stripe/gunnar-test2", "test2.gunnar.rs"));
let mut state = AppState::in_memory(reg);
state.signup_plans = [(PLAN.to_string(), SignupPlan { amount_minor: 10_000, currency: Currency::Sek, cap_bytes: 10_737_418_240, period_days: 30 })].into();
state.fleet.know_bearer(&monetize_server::fleet::bearer_digest(HOME_BEARER), HOME);
state.fleet.know_bearer(&monetize_server::fleet::bearer_digest(TEST2_BEARER), TEST2);
let state = Arc::new(state);
let server = TestServer::spawn(state.clone(), SERVICE).await;
let channel = Channel::from_shared(server.endpoint()).unwrap().connect().await.expect("connect");
Rig { state, _server: server, channel }
}
fn console(&self, bearer: &'static str) -> SignupClient<tonic::service::interceptor::InterceptedService<Channel, impl tonic::service::Interceptor>> {
let b: MetadataValue<_> = format!("Bearer {bearer}").parse().unwrap();
SignupClient::with_interceptor(self.channel.clone(), move |mut r: Request<()>| {
r.metadata_mut().insert("authorization", b.clone());
Ok(r)
})
}
/// `Signup.Start` exactly as gunnar-ui's `auth::signup::start` builds it.
async fn start(&self, bearer: &'static str, vendor: &str, user: &str, site: &str) -> Result<pb::StartSignupResponse, tonic::Status> {
self.console(bearer)
.start(pb::StartSignupRequest {
username: user.into(),
product: "gunnar".into(),
plan: PLAN.into(),
vendor: vendor.into(),
success_url: format!("https://{site}/welcome?session={{session}}"),
cancel_url: format!("https://{site}/signup"),
email: format!("{user}@example.com"),
market: 0,
})
.await
.map(tonic::Response::into_inner)
}
/// What the vendor's webhook leaves behind: the paid signup kept under the INSTANCE that
/// took the money (`monetize_pay::instance` renames `stripe` to its instance id).
fn paid_at(&self, instance: &str, session: &str, user: &str) {
let paid = PaidSignup {
vendor: instance.into(),
session: session.into(),
reference: format!("pi_{user}"),
email: format!("{user}@example.com"),
username: user.into(),
product: "gunnar".into(),
plan: PLAN.into(),
amount: Money { minor: 10_000, currency: Currency::Sek },
tax: Money { minor: 0, currency: Currency::Sek },
paid_at_unix_ms: 1_791_700_000_000,
customer_country: None,
};
self.state.signups.record_pending(&paid, 1_791_700_000_000).expect("the webhook's row is kept");
}
}
#[test]
fn the_console_names_no_vendor_never_an_instance() {
assert!(CONSOLE_VENDOR.is_empty(), "A32-15: a site names no vendor; the shop routes it: {CONSOLE_VENDOR:?}");
}
/// ★ i54a run30/31: each site's console asks with the KIND and gets ITS OWN site's account.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_consoles_start_is_minted_by_the_calling_sites_own_instance() {
let rig = Rig::up().await;
let t2 = rig.start(TEST2_BEARER, CONSOLE_VENDOR, "nina", "test2.gunnar.rs").await.expect("test2's console sells an account (run30: UNIMPLEMENTED 'not sold here')");
assert!(t2.url.starts_with("https://checkout.example/stripe/gunnar-test2/"), "test2's money goes to test2's account: {}", t2.url);
let home = rig.start(HOME_BEARER, CONSOLE_VENDOR, "olle", "gunnar.rs").await.expect("gunnar.rs's console sells an account");
assert!(home.url.starts_with("https://checkout.example/stripe/gunnar/"), "gunnar.rs's money goes to gunnar.rs's account: {}", home.url);
}
/// Another site's instance, or another kind, is never substituted for the calling site's.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn another_sites_instance_or_another_kind_is_never_substituted() {
let rig = Rig::up().await;
let other = rig.start(TEST2_BEARER, "stripe/gunnar", "nina", "test2.gunnar.rs").await.expect_err("test2 naming gunnar.rs's account");
assert_eq!(other.code(), Code::Unimplemented, "{other:?}");
let kind = rig.start(TEST2_BEARER, "klarna", "nina", "test2.gunnar.rs").await.expect_err("another kind");
assert_eq!(kind.code(), Code::Unimplemented, "{kind:?}");
}
/// ★ The welcome page after the card: the console polls `Signup.Status` with the KIND and the
/// session from its return URL; the webhook kept the row under the INSTANCE. The customer must
/// read the signup's state, never UNKNOWN for ever.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_welcome_pages_poll_by_kind_finds_the_signup_the_instance_took() {
let rig = Rig::up().await;
let t2 = rig.start(TEST2_BEARER, CONSOLE_VENDOR, "nina", "test2.gunnar.rs").await.expect("minted");
let before = rig.console(TEST2_BEARER).status(pb::SignupStatusRequest { vendor: CONSOLE_VENDOR.into(), session: t2.session.clone() }).await.expect("polled").into_inner();
assert_eq!(before.state, State::Unknown as i32, "before the webhook nothing is known: {before:?}");
rig.paid_at("stripe/gunnar-test2", &t2.session, "nina");
let after = rig.console(TEST2_BEARER).status(pb::SignupStatusRequest { vendor: CONSOLE_VENDOR.into(), session: t2.session.clone() }).await.expect("polled").into_inner();
assert_ne!(after.state, State::Unknown as i32, "the welcome page polls by kind and must find test2's paid signup: {after:?}");
// The other site's console asking for test2's session learns nothing.
let foreign = rig.console(HOME_BEARER).status(pb::SignupStatusRequest { vendor: CONSOLE_VENDOR.into(), session: t2.session.clone() }).await.expect("polled").into_inner();
assert_eq!(foreign.state, State::Unknown as i32, "gunnar.rs's console reads nothing of test2's signup: {foreign:?}");
}
/// ★ The rename after the card (a name taken in between) is asked by KIND too.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_consoles_rename_by_kind_reaches_the_signup_the_instance_took() {
let rig = Rig::up().await;
let t2 = rig.start(TEST2_BEARER, CONSOLE_VENDOR, "nina", "test2.gunnar.rs").await.expect("minted");
rig.paid_at("stripe/gunnar-test2", &t2.session, "nina");
let renamed = rig.console(TEST2_BEARER).rename(pb::RenameSignupRequest { vendor: CONSOLE_VENDOR.into(), session: t2.session.clone(), username: "nina2".into() }).await;
if let Err(e) = &renamed {
assert_ne!(e.code(), Code::NotFound, "the rename by kind must reach test2's signup, not 'no signup here': {e:?}");
}
}