crates/monetize-site-buy/tests/a16_manage_doors.rs
//! ★ **Managing what was bought is shared too** (A16, 2026-10-23): Gunnar's console's doors
//! for an instance (`auth::private` renew, cancel, terminate, restore-front) and for the
//! account's own plan (`api::subscription` offer, renew, history), moved as they are into the
//! relay. Each asks monetize with the product's own ticket, never an e-mail; ending an
//! instance needs a fresh login and its name typed back. 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, BuySite, Buyer, Door, Refusal, Relay, ShopLink, Who};
use serde_json::json;
use tonic::{Request, Response, Status};
/// The tickets the shop heard, each with the door it came through.
type Heard = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
#[derive(Clone, Default)]
struct Shop {
tickets: Heard,
terminate: Arc<Mutex<Option<pb::EstateOrderTerminateRequest>>>,
quote: Arc<Mutex<Option<pb::QuoteRequest>>>,
renew: Arc<Mutex<Option<pb::StartPurchaseRequest>>>,
}
impl Shop {
fn heard(&self, door: &str, ticket: &[u8]) {
self.tickets
.lock()
.unwrap()
.push((door.to_owned(), ticket.to_vec()));
}
}
#[tonic::async_trait]
impl pb::estate_order_server::EstateOrder for Shop {
async fn product_order(
&self,
_: tonic::Request<pb::EstateProductOrderRequest>,
) -> Result<tonic::Response<pb::EstateProductOrderResponse>, tonic::Status> {
Err(tonic::Status::unimplemented("product_order"))
}
async fn product_order_answer(
&self,
_: tonic::Request<pb::EstateProductOrderAnswerRequest>,
) -> Result<tonic::Response<pb::EstateProductOrderAnswerResponse>, tonic::Status> {
Err(tonic::Status::unimplemented("product_order_answer"))
}
async fn product_order_kinds(
&self,
_: tonic::Request<pb::EstateProductOrderKindsRequest>,
) -> Result<tonic::Response<pb::EstateProductOrderKindsResponse>, tonic::Status> {
Err(tonic::Status::unimplemented("product_order_kinds"))
}
async fn ladder(
&self,
_: Request<pb::EstateOrderLadderRequest>,
) -> Result<Response<pb::EstateOrderLadderResponse>, Status> {
Err(Status::unimplemented("ladder"))
}
async fn start(
&self,
_: Request<pb::EstateOrderStartRequest>,
) -> Result<Response<pb::EstateOrderStartResponse>, Status> {
Err(Status::unimplemented("start"))
}
async fn status(
&self,
_: Request<pb::EstateOrderStatusRequest>,
) -> Result<Response<pb::EstateOrderStatusResponse>, Status> {
Err(Status::unimplemented("status"))
}
async fn change_plan(
&self,
_: Request<pb::EstateOrderChangePlanRequest>,
) -> Result<Response<pb::EstateOrderChangePlanResponse>, Status> {
Err(Status::unimplemented("change_plan"))
}
async fn send_sign_in_again(
&self,
_: Request<pb::EstateOrderSignInAgainRequest>,
) -> Result<Response<pb::EstateOrderSignInAgainResponse>, Status> {
Err(Status::unimplemented("send_sign_in_again"))
}
async fn purchases(
&self,
_: Request<pb::EstateOrderPurchasesRequest>,
) -> Result<Response<pb::EstateOrderPurchasesResponse>, Status> {
Err(Status::unimplemented("purchases"))
}
async fn sells(
&self,
_: Request<pb::EstateOrderSellsRequest>,
) -> Result<Response<pb::EstateOrderSellsResponse>, Status> {
Err(Status::unimplemented("sells"))
}
async fn notice(
&self,
_: Request<pb::EstateNoticeRequest>,
) -> Result<Response<pb::EstateNoticeResponse>, Status> {
Err(Status::unimplemented("notice"))
}
async fn settle(
&self,
_: Request<pb::EstateOrderSettleRequest>,
) -> Result<Response<pb::EstateOrderSettleResponse>, Status> {
Err(Status::unimplemented("settle"))
}
async fn first_link(
&self,
_: Request<pb::EstateOrderFirstLinkRequest>,
) -> Result<Response<pb::EstateOrderFirstLinkResponse>, Status> {
Err(Status::unimplemented("first_link"))
}
async fn refeed(
&self,
_: Request<pb::EstateOrderRefeedRequest>,
) -> Result<Response<pb::EstateOrderRefeedResponse>, Status> {
Err(Status::unimplemented("refeed"))
}
async fn rebuild_main(
&self,
_: Request<pb::EstateRebuildMainRequest>,
) -> Result<Response<pb::EstateRebuildMainResponse>, Status> {
Err(Status::unimplemented("rebuild_main"))
}
async fn order_site(
&self,
_: Request<pb::EstateOrderSiteRequest>,
) -> Result<Response<pb::EstateOrderSiteResponse>, Status> {
Err(Status::unimplemented("order_site"))
}
async fn list_sites(
&self,
_: Request<pb::EstateSitesRequest>,
) -> Result<Response<pb::EstateSitesResponse>, Status> {
Err(Status::unimplemented("list_sites"))
}
async fn update_estate(
&self,
_: Request<pb::EstateUpdateRequest>,
) -> Result<Response<pb::EstateUpdateResponse>, Status> {
Err(Status::unimplemented("update_estate"))
}
async fn update_status(
&self,
_: Request<pb::EstateUpdateStatusRequest>,
) -> Result<Response<pb::EstateUpdateResponse>, Status> {
Err(Status::unimplemented("update_status"))
}
async fn cancel_update(
&self,
_: Request<pb::EstateUpdateStatusRequest>,
) -> Result<Response<pb::EstateUpdateResponse>, Status> {
Err(Status::unimplemented("cancel_update"))
}
async fn set_live_update(
&self,
_: Request<pb::EstateSetLiveUpdateRequest>,
) -> Result<Response<pb::EstateUpdateResponse>, Status> {
Err(Status::unimplemented("set_live_update"))
}
async fn adopt_estate(
&self,
_: Request<pb::EstateAdoptRequest>,
) -> Result<Response<pb::EstateAdoptResponse>, Status> {
Err(Status::unimplemented("adopt_estate"))
}
async fn list_code_red(
&self,
_: Request<pb::CodeRedListRequest>,
) -> Result<Response<pb::CodeRedList>, Status> {
Err(Status::unimplemented("list_code_red"))
}
async fn ack_code_red(
&self,
_: Request<pb::CodeRedAckRequest>,
) -> Result<Response<pb::CodeRedList>, Status> {
Err(Status::unimplemented("ack_code_red"))
}
async fn raise_code_red(
&self,
_: Request<pb::CodeRed>,
) -> Result<Response<pb::CodeRedList>, Status> {
Err(Status::unimplemented("raise_code_red"))
}
async fn clear_code_red(
&self,
_: Request<pb::CodeRedClearRequest>,
) -> Result<Response<pb::CodeRedList>, Status> {
Err(Status::unimplemented("clear_code_red"))
}
async fn narrate_log(
&self,
_: Request<pb::EstateNarrateLogRequest>,
) -> Result<Response<pb::EstateNarrateLogResponse>, Status> {
Err(Status::unimplemented("narrate_log"))
}
async fn install_log(
&self,
_: Request<pb::EstateInstallLogRequest>,
) -> Result<Response<pb::EstateInstallLogResponse>, Status> {
Err(Status::unimplemented("install_log"))
}
async fn renew(
&self,
r: Request<pb::EstateOrderRenewRequest>,
) -> Result<Response<pb::EstateOrderRenewResponse>, Status> {
let r = r.into_inner();
self.heard("renew", &r.actor_ticket);
Ok(Response::new(pb::EstateOrderRenewResponse {
reference: format!("estate/{}/renew-1", r.company),
checkout_url: "https://checkout.mock.invalid/c/pay/cs_test_renew".into(),
price_words: "385.20 EUR".into(),
paid_until: "2027-04-23".into(),
words: "Renew acme for 6 months.".into(),
}))
}
async fn cancel(
&self,
r: Request<pb::EstateOrderCancelRequest>,
) -> Result<Response<pb::EstateOrderCancelResponse>, Status> {
let r = r.into_inner();
self.heard("cancel", &r.actor_ticket);
Ok(Response::new(pb::EstateOrderCancelResponse {
words: "acme is not renewed.".into(),
paid_until: "2027-04-23".into(),
delete_on: "2027-06-22".into(),
}))
}
async fn terminate(
&self,
r: Request<pb::EstateOrderTerminateRequest>,
) -> Result<Response<pb::EstateOrderTerminateResponse>, Status> {
let r = r.into_inner();
self.heard("terminate", &r.actor_ticket);
*self.terminate.lock().unwrap() = Some(r);
Ok(Response::new(pb::EstateOrderTerminateResponse {
words: "acme is ended.".into(),
done: true,
}))
}
async fn restore_front(
&self,
r: Request<pb::EstateOrderRestoreFrontRequest>,
) -> Result<Response<pb::EstateOrderRestoreFrontResponse>, Status> {
let r = r.into_inner();
self.heard("restore-front", &r.actor_ticket);
Ok(Response::new(pb::EstateOrderRestoreFrontResponse {
words: "The front is back.".into(),
moved: false,
public_ipv4: "192.0.2.7".into(),
}))
}
}
#[tonic::async_trait]
impl pb::signup_server::Signup for Shop {
async fn status(
&self,
_: Request<pb::SignupStatusRequest>,
) -> Result<Response<pb::SignupStatusResponse>, Status> {
Err(Status::unimplemented("status"))
}
async fn check_name(
&self,
_: Request<pb::CheckNameRequest>,
) -> Result<Response<pb::CheckNameResponse>, Status> {
Err(Status::unimplemented("check_name"))
}
async fn start(
&self,
_: Request<pb::StartSignupRequest>,
) -> Result<Response<pb::StartSignupResponse>, Status> {
Err(Status::unimplemented("start"))
}
async fn rename(
&self,
_: Request<pb::RenameSignupRequest>,
) -> Result<Response<pb::RenameSignupResponse>, Status> {
Err(Status::unimplemented("rename"))
}
async fn send_sign_in_again(
&self,
_: Request<pb::SignupSignInAgainRequest>,
) -> Result<Response<pb::SignupSignInAgainResponse>, Status> {
Err(Status::unimplemented("send_sign_in_again"))
}
async fn plans(
&self,
_: Request<pb::SignupPlansRequest>,
) -> Result<Response<pb::SignupPlansResponse>, Status> {
Ok(Response::new(pb::SignupPlansResponse {
plans: vec![pb::SignupPlanOffer {
id: "holger/5gb-1m".into(),
name: "5 GB".into(),
cap_bytes: 5 << 30,
period_days: 30,
..Default::default()
}],
..Default::default()
}))
}
}
/// The shop verifies this product's tenant renewal tickets (Gunnar's `offered`).
#[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()
}))
}
}
#[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 status(
&self,
_: Request<pb::PurchaseStatusRequest>,
) -> Result<Response<pb::PurchaseStatusResponse>, Status> {
Err(Status::unimplemented("status"))
}
async fn abandon(
&self,
_: Request<pb::AbandonPurchaseRequest>,
) -> Result<Response<pb::AbandonPurchaseResponse>, Status> {
Err(Status::unimplemented("abandon"))
}
async fn cancel(
&self,
_: Request<pb::CancelPurchaseRequest>,
) -> Result<Response<pb::CancelPurchaseResponse>, Status> {
Err(Status::unimplemented("cancel"))
}
async fn quote(
&self,
r: Request<pb::QuoteRequest>,
) -> Result<Response<pb::QuoteResponse>, Status> {
let r = r.into_inner();
self.heard("quote", &r.actor_ticket);
let tenant = r.tenant.clone();
*self.quote.lock().unwrap() = Some(r);
if tenant == "new-repo" {
return Err(Status::failed_precondition(
"nothing to renew for new: this server holds no entitlement for them",
));
}
Ok(Response::new(pb::QuoteResponse {
reference: "holger/x14/renew".into(),
can_serve: true,
period: "month".into(),
price_words: "100.00 SEK".into(),
caps: [("disk_bytes".to_string(), 5u64)].into_iter().collect(),
..Default::default()
}))
}
async fn start(
&self,
r: Request<pb::StartPurchaseRequest>,
) -> Result<Response<pb::StartPurchaseResponse>, Status> {
let r = r.into_inner();
self.heard("start", &r.actor_ticket);
*self.renew.lock().unwrap() = Some(r);
Ok(Response::new(pb::StartPurchaseResponse {
reference: "holger/x14/renew-1".into(),
state: pb::PurchaseState::Pending as i32,
redirect_url: "https://checkout.mock.invalid/c/pay/cs_test_prolong".into(),
..Default::default()
}))
}
async fn history(
&self,
r: Request<pb::PurchaseHistoryRequest>,
) -> Result<Response<pb::PurchaseHistoryResponse>, Status> {
let r = r.into_inner();
self.heard("history", &r.actor_ticket);
Ok(Response::new(pb::PurchaseHistoryResponse {
attempts: vec![pb::PurchaseAttempt {
reference: "holger/x14/p-1".into(),
outcome: "granted".into(),
at_unix_ms: 7,
steps: 6,
price_words: "100.00 SEK".into(),
..Default::default()
}],
}))
}
}
/// `x-test-user`: absent a visitor; `fresh:<a>` signed in with a fresh login; else the account
/// (logged in a while ago). x14's tenant is its repository; `new` holds nothing.
struct Site;
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(a) => {
let a = a.trim_start_matches("fresh:");
Who::Person(Buyer {
account: a.into(),
tenant: format!("{a}-repo"),
..Default::default()
})
}
},
)
}
async fn stepped_up(&self, headers: &HeaderMap) -> Result<(), String> {
match headers.get("x-test-user").and_then(|v| v.to_str().ok()) {
Some(u) if u.starts_with("fresh:") => Ok(()),
_ => Err("Sign in again with your passkey first.".into()),
}
}
async fn ticket(
&self,
_: &HeaderMap,
buyer: &Buyer,
purpose: &str,
_: &BTreeMap<String, u64>,
) -> Result<Vec<u8>, Refusal> {
Ok(format!("{purpose}:{}", buyer.account).into_bytes())
}
async fn held_caps(
&self,
_: &HeaderMap,
buyer: &Buyer,
) -> Result<BTreeMap<String, u64>, Refusal> {
Ok(if buyer.account == "new" {
BTreeMap::new()
} else {
BTreeMap::from([("disk_bytes".to_string(), 5u64)])
})
}
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::estate_order_server::EstateOrderServer::new(
shop.clone(),
))
.add_service(pb::purchase_server::PurchaseServer::new(shop.clone()))
.add_service(pb::products_server::ProductsServer::new(shop.clone()))
.add_service(pb::signup_server::SignupServer::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 call(
r: &Arc<Relay<Site>>,
method: &str,
uri: &str,
user: Option<&str>,
body: Option<serde_json::Value>,
) -> (StatusCode, serde_json::Value) {
use tower::ServiceExt;
let mut req = axum::http::Request::builder().method(method).uri(uri);
if let Some(u) = user {
req = req.header("x-test-user", u);
}
let req = match body {
Some(b) => req
.header("content-type", "application/json")
.body(axum::body::Body::from(b.to_string()))
.unwrap(),
None => req.body(axum::body::Body::empty()).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 renew_stop_renewing_and_restore_front_are_the_accounts_own_with_its_estate_ticket() {
let (r, shop) = relay().await;
let (st, body) = call(
&r,
"POST",
"/auth/private/renew",
Some("x14"),
Some(json!({"company": "Acme"})),
)
.await;
assert_eq!(st, StatusCode::OK, "{body}");
assert_eq!(
body,
json!({"url": "https://checkout.mock.invalid/c/pay/cs_test_renew", "words": "Renew acme for 6 months.", "price_words": "385.20 EUR", "paid_until": "2027-04-23"})
);
let (st, body) = call(
&r,
"POST",
"/auth/private/cancel",
Some("x14"),
Some(json!({"company": "acme"})),
)
.await;
assert_eq!(st, StatusCode::OK, "{body}");
assert_eq!(
body,
json!({"words": "acme is not renewed.", "paid_until": "2027-04-23", "delete_on": "2027-06-22"})
);
let (st, body) = call(
&r,
"POST",
"/auth/private/restore-front",
Some("x14"),
Some(json!({"company": "acme"})),
)
.await;
assert_eq!(st, StatusCode::OK, "{body}");
assert_eq!(
body,
json!({"words": "The front is back.", "moved": false, "public_ipv4": "192.0.2.7"})
);
for (door, ticket) in shop.tickets.lock().unwrap().iter() {
assert_eq!(
ticket, b"estate:x14",
"{door}: the account's own estate ticket"
);
}
let (st, body) = call(
&r,
"POST",
"/auth/private/renew",
None,
Some(json!({"company": "acme"})),
)
.await;
assert_eq!(st, StatusCode::UNAUTHORIZED);
assert!(
body["error"]
.as_str()
.unwrap()
.starts_with("A private Holger is bought by your account at holger.mock.invalid"),
"{body}"
);
assert_eq!(
call(
&r,
"POST",
"/auth/private/cancel",
Some("x14"),
Some(json!({"company": "../x"}))
)
.await
.0,
StatusCode::BAD_REQUEST
);
}
#[tokio::test]
async fn terminate_needs_the_name_typed_back_and_a_fresh_login_and_offers_no_backup() {
let (r, shop) = relay().await;
let (st, body) = call(
&r,
"POST",
"/auth/private/terminate",
Some("fresh:x14"),
Some(json!({"company": "acme", "confirm": "acm"})),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
assert_eq!(
body["error"],
"Type the instance's name, acme, to terminate it. Nothing was ended."
);
let (st, body) = call(
&r,
"POST",
"/auth/private/terminate",
Some("x14"),
Some(json!({"company": "acme", "confirm": "acme"})),
)
.await;
assert_eq!(st, StatusCode::FORBIDDEN);
assert_eq!(
(body["code"].as_str(), body["error"].as_str()),
(
Some("stepup"),
Some("Sign in again with your passkey first. Nothing was ended.")
)
);
assert!(
shop.terminate.lock().unwrap().is_none(),
"the shop is never asked without a fresh login"
);
// ★ A32-27: the answer carries no backup word; an older page's `backup` is not read.
let (st, body) = call(
&r,
"POST",
"/auth/private/terminate",
Some("fresh:x14"),
Some(json!({"company": "acme", "confirm": "acme"})),
)
.await;
assert_eq!(st, StatusCode::OK, "{body}");
assert_eq!(body["done"], true, "{body}");
assert!(
body.get("backup_offered").is_none() && body.get("backups").is_none(),
"{body}"
);
let (st, body) = call(
&r,
"POST",
"/auth/private/terminate",
Some("fresh:x14"),
Some(json!({"company": "acme", "confirm": " ACME ", "backup": "skip"})),
)
.await;
assert_eq!(
(st, body["done"].as_bool()),
(StatusCode::OK, Some(true)),
"an older page's request still ends it: {body}"
);
let t = shop.terminate.lock().unwrap().clone().unwrap();
assert_eq!(
(
t.company.as_str(),
t.confirm.as_str(),
t.actor_ticket.as_slice()
),
("acme", "ACME", b"estate:x14".as_slice())
);
assert!(
monetize_site_buy::relay::typed_back("acme", "acme.holger.rs")
&& !monetize_site_buy::relay::typed_back("acme", "acmex")
);
}
/// A site that proves no fresh login (the provided default) never ends an instance.
#[tokio::test]
async fn a_site_without_a_step_up_never_terminates() {
assert!(
monetize_site_buy::site::STEP_UP_WORDS.starts_with("This needs a fresh touch of your key")
);
assert_eq!(monetize_site_buy::site::STEP_UP_CODE, "stepup");
}
#[tokio::test]
async fn the_accounts_prolong_is_quoted_and_started_with_its_renew_ticket_and_its_history_read_with_a_read_ticket(
) {
let (r, shop) = relay().await;
let (st, body) = call(&r, "GET", "/api/account/subscription", Some("x14"), None).await;
assert_eq!(st, StatusCode::OK, "{body}");
assert_eq!(
(
body["can_serve"].as_bool(),
body["price_words"].as_str(),
body["period"].as_str(),
body["nothing_to_renew"].as_bool()
),
(Some(true), Some("100.00 SEK"), Some("month"), Some(false))
);
let q = shop.quote.lock().unwrap().clone().unwrap();
assert!(
q.renew && q.caps.is_empty() && q.bundle.is_empty() && q.tenant == "x14-repo",
"a renewal names no caps and no bundle"
);
let (st, body) = call(&r, "GET", "/api/account/subscription", Some("new"), None).await;
assert_eq!(st, StatusCode::OK, "a refusal is an answer: {body}");
assert_eq!(
(
body["can_serve"].as_bool(),
body["nothing_to_renew"].as_bool()
),
(Some(false), Some(true))
);
let (st, body) = call(
&r,
"POST",
"/api/account/subscription/renew",
Some("x14"),
None,
)
.await;
assert_eq!(st, StatusCode::OK, "{body}");
assert_eq!(
(
body["url"].as_str(),
body["action"].as_str(),
body["state"].as_str()
),
(
Some("https://checkout.mock.invalid/c/pay/cs_test_prolong"),
Some("renew"),
Some("pending")
)
);
assert!(shop.renew.lock().unwrap().clone().unwrap().renew);
let (st, body) = call(
&r,
"GET",
"/api/account/subscription/history",
Some("x14"),
None,
)
.await;
assert_eq!(st, StatusCode::OK, "{body}");
assert_eq!(
body,
json!([{"reference": "holger/x14/p-1", "outcome": "granted", "at_unix_ms": 7, "date_words": "1970-01-01", "caps": {}, "delta": {}, "steps": 6, "price_words": "100.00 SEK"}])
);
let tickets: Vec<_> = shop
.tickets
.lock()
.unwrap()
.iter()
.map(|(d, t)| format!("{d}={}", String::from_utf8_lossy(t)))
.collect();
assert_eq!(
tickets,
[
"quote=renew:x14",
"quote=renew:new",
"start=renew:x14",
"history=read:x14"
]
);
assert_eq!(
call(&r, "GET", "/api/account/subscription", None, None)
.await
.0,
StatusCode::UNAUTHORIZED
);
assert!(
monetize_site_buy::relay::is_relay_path("/api/account/subscription")
&& monetize_site_buy::relay::is_relay_path("/auth/private/terminate")
);
}
/// ★ Gunnar's `/auth/status` `account_offer` (auth/routes.rs:440-444): "Account on <host>" is
/// drawn for a VISITOR only where the shop sells an account here, as monetize names it.
#[tokio::test]
async fn the_offer_names_the_shops_account_for_a_visitor_only() {
let (r, _) = relay().await;
let (st, body) = call(&r, "GET", "/auth/buy/offer", None, None).await;
assert_eq!(st, StatusCode::OK, "{body}");
assert_eq!(body["account_offer"], "5GB");
let (_, body) = call(&r, "GET", "/auth/buy/offer", Some("x14"), None).await;
assert!(
body["account_offer"].is_null(),
"signed in: no account pill ({body})"
);
}
/// ★ A32-29: **the Prolong line is the server's**: the renewal answer carries the price line
/// composed once here (`100.00 SEK / month + VAT (…)`) and the customer's sentence when it
/// cannot be prolonged, so neither console composes them (gunnar-ui-web's `price_line` and
/// `renewal_line`, holger-ui's copies).
#[tokio::test]
async fn the_renewal_answer_carries_its_price_line_and_its_customer_line() {
let (r, _shop) = relay().await;
let (_, body) = call(&r, "GET", "/api/account/subscription", Some("x14"), None).await;
assert_eq!(
body["price_line"].as_str(),
Some("100.00 SEK / month + VAT (your country's rate, added at checkout)"),
"{body}"
);
assert_eq!(
body["line"].as_str(),
Some(""),
"it can be prolonged: no refusal line: {body}"
);
let (_, body) = call(&r, "GET", "/api/account/subscription", Some("new"), None).await;
assert_eq!(
(body["price_line"].as_str(), body["line"].as_str()),
(Some(""), Some("")),
"nothing bought, nothing to renew: no line: {body}"
);
use monetize_site_buy::purchase::{price_line, refused_renewal, RENEWING_UNAVAILABLE};
assert_eq!(
refused_renewal("r", None, false).line,
RENEWING_UNAVAILABLE,
"a refusal with no words says the one sentence"
);
assert_eq!(
refused_renewal("r", Some("Ask us.".into()), false).line,
"Ask us."
);
assert_eq!(
price_line("", "month"),
"",
"no amount, no line: a price is never composed from nothing"
);
assert_eq!(
price_line("220.00 SEK", ""),
"220.00 SEK + VAT (your country's rate, added at checkout)"
);
}
/// ★ A32-29: **an offer's price line and an order's are the server's too**: the window's
/// bundle and the attempt a bundle starts carry `price_line` (composed once,
/// [`monetize_site_buy::purchase::price_line`]), so a console never composes the amount, the
/// period and the VAT note itself.
#[test]
fn an_offer_and_its_order_carry_their_price_line() {
use monetize_proto as mpb;
use monetize_site_buy::purchase::{bundle_attempt, render_bundle};
let wire = mpb::Bundle {
id: "b".into(),
label: "10 GiB".into(),
price: "100.00 SEK".into(),
period: "month".into(),
can_serve: true,
..Default::default()
};
let line = "100.00 SEK / month + VAT (your country's rate, added at checkout)";
assert_eq!(render_bundle(wire.clone()).price_line, line);
let a = bundle_attempt(
mpb::StartPurchaseResponse::default(),
&wire,
&Default::default(),
&Default::default(),
String::new(),
);
assert_eq!(a.price_line, line);
assert_eq!(
render_bundle(mpb::Bundle {
price: String::new(),
..wire
})
.price_line,
"",
"no amount, no line"
);
}
/// ★ A32-29: **the window's poll schedule is the server's**: every window answer carries
/// `poll` (ask again every 2 s, past a minute every 10 s, stop at 10 min), so a console keeps
/// no schedule of its own for a window it could read.
#[test]
fn the_window_carries_its_poll_schedule() {
use monetize_site_buy::purchase::{window_of, POLL};
let pending = window_of(monetize_proto::ListBundlesResponse {
reading: true,
..Default::default()
});
let json = serde_json::to_value(&pending).unwrap();
assert_eq!(
json["poll"],
serde_json::json!({"again_ms": 2000, "late_after_ms": 60000, "late_again_ms": 10000, "give_up_ms": 600000}),
"{json}"
);
assert_eq!(
(
POLL.next_ms(0),
POLL.next_ms(59_999),
POLL.next_ms(60_000),
POLL.next_ms(600_000)
),
(Some(2_000), Some(2_000), Some(10_000), None)
);
assert!(!POLL.is_late(59_999) && POLL.is_late(60_000));
let answered = serde_json::to_value(monetize_site_buy::purchase::window(Vec::new())).unwrap();
assert!(
answered.get("poll").is_none(),
"an answered window is not asked again: {answered}"
);
}
/// ★ A32-29: **a plan's paid-until day and a purchase's day are the server's words**
/// (`date_words`, UTC `YYYY-MM-DD`), so no console carries its own calendar arithmetic.
#[test]
fn the_plan_and_the_past_purchases_say_their_days() {
use monetize_site_buy::purchase::{date_words, render_plan};
assert_eq!(
(
date_words(0),
date_words(86_400_000),
date_words(1_799_000_000_000)
),
(
String::new(),
"1970-01-02".to_owned(),
"2027-01-03".to_owned()
)
);
let plan = render_plan(monetize_proto::PlanStatus {
state: "paid".into(),
paid_until_unix_ms: 1_799_000_000_000,
..Default::default()
});
assert_eq!(plan.paid_until_words, "2027-01-03");
}
/// ★ A32-29: **an offer button's words and an order's paid-until day are the server's**: the
/// window's bundle carries `offer_words` (what it adds in customer words, then monetize's amount
/// and period; monetize's label when it sent no delta), and a paid order's attempt carries
/// `paid_until_words` beside the day it names, so a console composes neither.
#[test]
fn an_offer_says_its_button_and_a_paid_order_its_day() {
use monetize_proto as mpb;
use monetize_site_buy::purchase::{render_bundle, status_attempt};
const GIB: u64 = 1 << 30;
let words =
|b: mpb::Bundle| serde_json::to_value(render_bundle(b)).unwrap()["offer_words"].clone();
let wire = mpb::Bundle {
id: "b".into(),
label: "10.0 GiB more room".into(),
price: "100.00 SEK".into(),
period: "month".into(),
delta: [("pack_bytes".to_owned(), 10 * GIB)].into_iter().collect(),
can_serve: true,
..Default::default()
};
assert_eq!(
words(wire.clone()),
json!("+10 GiB storage \u{2014} 100.00 SEK / month")
);
let two = mpb::Bundle {
delta: [
("pack_bytes".to_owned(), 3 * GIB / 2),
("lfs_bytes".to_owned(), GIB),
]
.into_iter()
.collect(),
period: String::new(),
..wire.clone()
};
assert_eq!(
words(two),
json!("+1 GiB large files, +1.5 GiB storage \u{2014} 100.00 SEK")
);
let bare = mpb::Bundle {
label: " 10 GiB more room ".into(),
price: String::new(),
delta: Default::default(),
..wire
};
assert_eq!(
words(bare),
json!("10 GiB more room"),
"no delta: monetize's label; no amount: no price"
);
let at = 1_799_000_000_000u64;
let fact = Some(mpb::EntitlementFact {
paid_until_unix_ms: Some(at),
..Default::default()
});
let paid = status_attempt(
mpb::PurchaseStatusResponse {
reference: "r".into(),
state: mpb::PurchaseState::Paid as i32,
fact: fact.clone(),
..Default::default()
},
String::new(),
);
assert_eq!(
serde_json::to_value(&paid).unwrap()["paid_until_words"],
json!("2027-01-03")
);
let pending = status_attempt(
mpb::PurchaseStatusResponse {
reference: "r".into(),
state: mpb::PurchaseState::Pending as i32,
fact,
..Default::default()
},
String::new(),
);
assert!(
serde_json::to_value(&pending)
.unwrap()
.get("paid_until_words")
.is_none(),
"an unpaid order names no day"
);
}