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

nordisk/monetize

crates/monetize-site-buy/tests/a16_cancel_door.rs

//! ★ **"Cancel order" is one shared door** (A16, 2026-10-23): Gunnar's console's
//! `POST /api/account/subscription/cancel` (gunnar-ui-server `api::subscription::cancel`),
//! moved as it is: monetize's `Purchase.Cancel`, scoped to the buyer's tenant, vouched for with
//! the product's `cancel` ticket (it covers no amount), with Gunnar's ledger reason. A visitor
//! and a member cancel nothing. Addresses are `*.mock.invalid`.

use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};

use axum::http::{HeaderMap, StatusCode};
use monetize_proto as pb;
use monetize_site_buy::{Account, Buyer, BuySite, Door, Member, Refusal, Relay, ShopLink, Who};
use serde_json::json;
use tonic::{Request, Response, Status};

#[derive(Clone, Default)]
struct Shop {
    heard: Arc<Mutex<Option<pb::CancelPurchaseRequest>>>,
}

#[tonic::async_trait]
impl pb::purchase_server::Purchase for Shop {
    async fn bundles(&self, _: Request<pb::ListBundlesRequest>) -> Result<Response<pb::ListBundlesResponse>, Status> { Err(Status::unimplemented("Bundles")) }
    async fn start(&self, _: Request<pb::StartPurchaseRequest>) -> Result<Response<pb::StartPurchaseResponse>, Status> { Err(Status::unimplemented("Start")) }
    async fn status(&self, _: Request<pb::PurchaseStatusRequest>) -> Result<Response<pb::PurchaseStatusResponse>, Status> { Err(Status::unimplemented("Status")) }
    async fn quote(&self, _: Request<pb::QuoteRequest>) -> Result<Response<pb::QuoteResponse>, Status> { Err(Status::unimplemented("Quote")) }
    async fn abandon(&self, _: Request<pb::AbandonPurchaseRequest>) -> Result<Response<pb::AbandonPurchaseResponse>, Status> { Err(Status::unimplemented("Abandon")) }
    async fn cancel(&self, r: Request<pb::CancelPurchaseRequest>) -> Result<Response<pb::CancelPurchaseResponse>, Status> {
        let r = r.into_inner();
        *self.heard.lock().unwrap() = Some(r.clone());
        if r.reference == "paid-1" {
            return Err(Status::failed_precondition("paid-1 has been paid, so it cannot be cancelled: it finishes as paid, and ending it now is a refund (Abandon)"));
        }
        Ok(Response::new(pb::CancelPurchaseResponse { reference: r.reference, already_closed: false, state: pb::PurchaseState::Released as i32, phase: pb::PurchasePhase::Cancelled as i32, reason: r.reason, released: vec![] }))
    }
    async fn history(&self, _: Request<pb::PurchaseHistoryRequest>) -> Result<Response<pb::PurchaseHistoryResponse>, Status> { Err(Status::unimplemented("History")) }
}

struct Site;

/// A32-9: every site's grow doors (the cancel among them) ask first whether the shop checks
/// tenant tickets: this shop does.
#[tonic::async_trait]
impl pb::products_server::Products for Shop {
    async fn list(&self, _: Request<pb::ListProductsRequest>) -> Result<Response<pb::ListProductsResponse>, Status> {
        Ok(Response::new(pb::ListProductsResponse { products: vec![pb::ProductInfo { id: "holger".into(), tenant_renewal: true, ..Default::default() }], ..Default::default() }))
    }
}

impl BuySite for Site {
    fn origin(&self) -> String {
        "https://holger.mock.invalid".into()
    }
    fn product_word(&self) -> String {
        "Holger".into()
    }
    async fn who(&self, headers: &HeaderMap) -> Result<Who, Refusal> {
        Ok(match headers.get("x-test-user").and_then(|v| v.to_str().ok()) {
            None => Who::Visitor,
            Some(m) if m.starts_with("member:") => Who::Person(Buyer { account: "ann".into(), member: Some(Member { account: m[7..].into(), admin: "boss".into(), line: format!("You are a member of {}.", &m[7..]) }), ..Default::default() }),
            Some(a) => Who::Person(Buyer { account: a.into(), tenant: format!("{a}-repo"), ..Default::default() }),
        })
    }
    async fn ticket(&self, _: &HeaderMap, buyer: &Buyer, purpose: &str, caps: &BTreeMap<String, u64>) -> Result<Vec<u8>, Refusal> {
        Ok(format!("{purpose}:{}:{caps:?}", buyer.account).into_bytes())
    }
    async fn held_caps(&self, _: &HeaderMap, _: &Buyer) -> Result<BTreeMap<String, u64>, Refusal> {
        Ok(BTreeMap::new())
    }
    async fn signup_account(&self, _: &str) -> Result<Account, Refusal> {
        Ok(Account::NoPasskey)
    }
}

async fn relay() -> (Arc<Relay<Site>>, Shop) {
    let shop = Shop::default();
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let at = listener.local_addr().unwrap();
    tokio::spawn(tonic::transport::Server::builder().add_service(pb::purchase_server::PurchaseServer::new(shop.clone())).add_service(pb::products_server::ProductsServer::new(shop.clone())).serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)));
    let link = ShopLink::connect_lazy(&Door { url: format!("http://{at}"), ..Door::default() }, "site-bearer-for-tests", "holger").unwrap();
    (Arc::new(Relay::new(Site, link)), shop)
}

async fn cancel(r: &Arc<Relay<Site>>, user: Option<&str>, reference: &str) -> (StatusCode, serde_json::Value) {
    use tower::ServiceExt;
    let mut req = axum::http::Request::builder().method("POST").uri("/api/account/subscription/cancel").header("content-type", "application/json");
    if let Some(u) = user {
        req = req.header("x-test-user", u);
    }
    let req = req.body(axum::body::Body::from(json!({ "reference": reference }).to_string())).unwrap();
    let res = monetize_site_buy::router(r.clone()).oneshot(req).await.unwrap();
    let status = res.status();
    let bytes = axum::body::to_bytes(res.into_body(), 1 << 20).await.unwrap();
    (status, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null))
}

#[tokio::test]
async fn cancel_order_closes_the_buyers_own_unpaid_order_at_the_shop() {
    let (r, shop) = relay().await;
    let (st, body) = cancel(&r, Some("x14"), "holger/x14/p-7").await;
    assert_eq!(st, StatusCode::OK, "{body}");
    assert_eq!((body["reference"].as_str(), body["state"].as_str(), body["phase"].as_str(), body["url"].as_str()), (Some("holger/x14/p-7"), Some("released"), Some("cancelled"), Some("")));
    let heard = shop.heard.lock().unwrap().clone().unwrap();
    assert_eq!((heard.product.as_str(), heard.tenant.as_str(), heard.reference.as_str()), ("holger", "x14-repo", "holger/x14/p-7"));
    assert_eq!(heard.actor_ticket, b"cancel:x14:{}".to_vec(), "a cancel ticket, covering no amount");
    assert_eq!(heard.reason, monetize_site_buy::relay::CANCELLED_BY_CUSTOMER);
}

#[tokio::test]
async fn a_paid_order_is_not_cancelled_and_the_shop_says_why() {
    let (r, _) = relay().await;
    let (st, body) = cancel(&r, Some("x14"), "paid-1").await;
    assert_eq!(st, StatusCode::CONFLICT, "{body}");
    assert!(body["error"].as_str().unwrap().contains("has been paid"), "{body}");
}

#[tokio::test]
async fn a_visitor_and_a_member_cancel_nothing() {
    let (r, shop) = relay().await;
    assert_eq!(cancel(&r, None, "p-1").await.0, StatusCode::UNAUTHORIZED);
    assert_eq!(cancel(&r, Some("member:acme"), "p-1").await.0, StatusCode::FORBIDDEN);
    assert!(shop.heard.lock().unwrap().is_none(), "the shop is never asked");
    assert!(monetize_site_buy::relay::is_relay_path("/api/account/subscription/cancel"));
}