Files
Chengdong Zhang a7cd392bd4 feat(payments): configurable checkout payments — wallet + Stripe crypto, original-method refunds (add-configurable-payments)
- payment domain: payments/memberships/attempts/receipts/refunds/commands/events, legacy-demo provenance (0024), checkout header-hash idempotency (0025)
- wallet collection all-or-none across shop orders; group-wide cancellation; checkout cutover, status-only payOrder removed
- Stripe crypto-only Checkout adapter, raw-body HMAC webhooks, durable command worker with leases
- original-method refunds with receipt/allocation budgets; refund_pending/refund_failed aftersale states
- admin channel CRUD/revisions/audit/verify + operations list/detail/reconcile; secret-ref allowlist (VMALL_PAYMENT_*), env-fixed demo/sandbox/live
- mall/mobile checkout pay surfaces, admin console pages; shared contract updated
- pre-launch leftovers recorded in docs/TBD-payments.md

cargo test -p vmall-api green twice; four frontend builds green; browser smoke: mall wallet checkout paid, admin channels/operations verified
2026-10-15 16:14:28 +08:00

747 lines
26 KiB
Rust

mod common;
use common::{add_to_cart, client, login_admin, register_customer, setup_sellable, spawn_app, TestApp};
use serial_test::serial;
use uuid::Uuid;
fn address() -> serde_json::Value {
serde_json::json!({
"recipient": "Payment Test", "phone": "1", "country": "US",
"region": "CA", "city": "San Jose", "line1": "1 Test Way", "postal_code": "95131"
})
}
async fn checkout_payment(app: &TestApp, token: &str) -> serde_json::Value {
let res = client()
.post(app.url("/api/orders/checkout"))
.bearer_auth(token)
.header("Idempotency-Key", uuid::Uuid::new_v4().to_string())
.json(&serde_json::json!({
"shipping_address": address(),
"currency": "USD",
"coupon_by_shop": {},
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "checkout: {:?}", res.text().await);
res.json().await.unwrap()
}
async fn recharge(app: &TestApp, token: &str, amount_minor: i64) {
let res = client()
.post(app.url("/api/wallet/recharges"))
.bearer_auth(token)
.json(&serde_json::json!({ "amount_minor": amount_minor }))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "recharge: {:?}", res.text().await);
}
async fn wallet_balance(app: &TestApp, token: &str) -> i64 {
let res = client()
.get(app.url("/api/wallet"))
.bearer_auth(token)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
res.json::<serde_json::Value>().await.unwrap()["available_minor"]
.as_i64()
.unwrap()
}
async fn methods(app: &TestApp, token: &str, payment_id: &str) -> Vec<serde_json::Value> {
let res = client()
.get(app.url(&format!("/api/payments/{payment_id}/methods")))
.bearer_auth(token)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "methods: {:?}", res.text().await);
res.json().await.unwrap()
}
fn wallet_method(methods: &[serde_json::Value]) -> serde_json::Value {
methods
.iter()
.find(|m| m["adapter"] == "wallet")
.cloned()
.expect("wallet method must be available")
}
async fn attempt(
app: &TestApp,
token: &str,
payment_id: &str,
channel_id: &str,
key: &str,
) -> reqwest::Response {
client()
.post(app.url(&format!("/api/payments/{payment_id}/attempts")))
.bearer_auth(token)
.header("Idempotency-Key", key)
.json(&serde_json::json!({ "channel_id": channel_id, "return_surface": "mall" }))
.send()
.await
.unwrap()
}
async fn order_status(app: &TestApp, order_id: &str) -> String {
sqlx::query_scalar("SELECT status::text FROM orders WHERE id = $1")
.bind(Uuid::parse_str(order_id).unwrap())
.fetch_one(&app.db)
.await
.unwrap()
}
#[tokio::test]
#[serial]
async fn checkout_returns_one_payment_for_all_shop_orders() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (_, _, _, sku_a) = setup_sellable(&app, &admin, "pm-split-a", 1000, 5).await;
let (_, _, _, sku_b) = setup_sellable(&app, &admin, "pm-split-b", 2000, 5).await;
let (buyer, _) = register_customer(&app, "pm-split").await;
add_to_cart(&app, &buyer, &sku_a, 2).await;
add_to_cart(&app, &buyer, &sku_b, 1).await;
let result = checkout_payment(&app, &buyer).await;
assert!(result.is_object(), "checkout must return a payment wrapper");
let payment_id = result["payment_id"].as_str().unwrap();
let orders = result["orders"].as_array().unwrap();
assert_eq!(orders.len(), 2);
let res = client()
.get(app.url(&format!("/api/payments/{payment_id}")))
.bearer_auth(&buyer)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "detail: {:?}", res.text().await);
let payment: serde_json::Value = res.json().await.unwrap();
assert_eq!(payment["currency"], "USD");
assert_eq!(payment["total_minor"], 4000);
assert_eq!(payment["orders"].as_array().unwrap().len(), 2);
assert_eq!(payment["status"], "unapplied");
let (other, _) = register_customer(&app, "pm-other").await;
let res = client()
.get(app.url(&format!("/api/payments/{payment_id}")))
.bearer_auth(&other)
.send()
.await
.unwrap();
assert_eq!(res.status(), 404);
}
#[tokio::test]
#[serial]
async fn wallet_payment_applies_every_shop_order_and_debits_once() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (_, _, _, sku_a) = setup_sellable(&app, &admin, "pm-wallet-a", 1000, 5).await;
let (_, _, _, sku_b) = setup_sellable(&app, &admin, "pm-wallet-b", 2000, 5).await;
let (buyer, _) = register_customer(&app, "pm-wallet").await;
add_to_cart(&app, &buyer, &sku_a, 1).await;
add_to_cart(&app, &buyer, &sku_b, 1).await;
recharge(&app, &buyer, 4000).await;
let checkout = checkout_payment(&app, &buyer).await;
let payment_id = checkout["payment_id"].as_str().unwrap();
let order_ids: Vec<String> = checkout["orders"]
.as_array()
.unwrap()
.iter()
.map(|o| o["id"].as_str().unwrap().to_string())
.collect();
let methods = methods(&app, &buyer, payment_id).await;
let channel_id = wallet_method(&methods)["id"].as_str().unwrap().to_string();
let res = attempt(&app, &buyer, payment_id, &channel_id, "pm-wallet-1").await;
assert_eq!(res.status(), 200, "pay: {:?}", res.text().await);
let attempt: serde_json::Value = res.json().await.unwrap();
assert_eq!(attempt["status"], "settled");
assert_eq!(attempt["payment"]["status"], "applied");
assert_eq!(wallet_balance(&app, &buyer).await, 1000);
for order_id in &order_ids {
assert_eq!(order_status(&app, order_id).await, "paid");
}
let entries: serde_json::Value = client()
.get(app.url("/api/wallet/entries"))
.bearer_auth(&buyer)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let payment_debits = entries["items"]
.as_array()
.unwrap()
.iter()
.filter(|entry| entry["reason"] == "payment_wallet_debit")
.count();
assert_eq!(payment_debits, 1);
}
#[tokio::test]
#[serial]
async fn insufficient_wallet_payment_rolls_back_all_orders() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (_, _, _, sku_a) = setup_sellable(&app, &admin, "pm-short-a", 1000, 5).await;
let (_, _, _, sku_b) = setup_sellable(&app, &admin, "pm-short-b", 2000, 5).await;
let (buyer, _) = register_customer(&app, "pm-short").await;
add_to_cart(&app, &buyer, &sku_a, 1).await;
add_to_cart(&app, &buyer, &sku_b, 1).await;
recharge(&app, &buyer, 2999).await;
let checkout = checkout_payment(&app, &buyer).await;
let payment_id = checkout["payment_id"].as_str().unwrap();
let order_ids: Vec<String> = checkout["orders"]
.as_array()
.unwrap()
.iter()
.map(|o| o["id"].as_str().unwrap().to_string())
.collect();
let methods = methods(&app, &buyer, payment_id).await;
let channel_id = wallet_method(&methods)["id"].as_str().unwrap().to_string();
let res = attempt(&app, &buyer, payment_id, &channel_id, "pm-short-1").await;
assert_eq!(res.status(), 409);
assert_eq!(wallet_balance(&app, &buyer).await, 2999);
for order_id in &order_ids {
assert_eq!(order_status(&app, order_id).await, "pending_payment");
}
}
#[tokio::test]
#[serial]
async fn payment_attempt_idempotency_reuses_only_identical_requests() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (_, _, _, sku) = setup_sellable(&app, &admin, "pm-idem", 1000, 5).await;
let (buyer, _) = register_customer(&app, "pm-idem").await;
add_to_cart(&app, &buyer, &sku, 1).await;
recharge(&app, &buyer, 1000).await;
let checkout = checkout_payment(&app, &buyer).await;
let payment_id = checkout["payment_id"].as_str().unwrap();
let methods = methods(&app, &buyer, payment_id).await;
let channel_id = wallet_method(&methods)["id"].as_str().unwrap().to_string();
let first = attempt(&app, &buyer, payment_id, &channel_id, "pm-idem-1").await;
assert_eq!(first.status(), 200);
let first: serde_json::Value = first.json().await.unwrap();
let second = attempt(&app, &buyer, payment_id, &channel_id, "pm-idem-1").await;
assert_eq!(second.status(), 200);
let second: serde_json::Value = second.json().await.unwrap();
assert_eq!(first["id"], second["id"]);
assert_eq!(wallet_balance(&app, &buyer).await, 0);
let res = client()
.post(app.url(&format!("/api/payments/{payment_id}/attempts")))
.bearer_auth(&buyer)
.header("Idempotency-Key", "pm-idem-1")
.json(&serde_json::json!({ "channel_id": Uuid::new_v4() }))
.send()
.await
.unwrap();
assert_eq!(res.status(), 409);
}
#[tokio::test]
#[serial]
async fn admin_channel_revisions_are_audited_and_redacted() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let key_ref = format!("VMALL_PAYMENT_TEST_{}", Uuid::new_v4().simple());
let body = serde_json::json!({
"adapter": "stripe_crypto",
"label": {"en": "Crypto", "zh": "加密支付"},
"display_order": 20,
"enabled": false,
"supported_currencies": ["USD"],
"min_amount_minor": 100,
"max_amount_minor": 1000000,
"provider_account_id": "acct_test",
"secret_key_ref": key_ref,
"webhook_secret_ref": format!("{key_ref}_WEBHOOK")
});
let res = client()
.post(app.url("/api/admin/payments/channels"))
.bearer_auth(&admin)
.json(&body)
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "create: {:?}", res.text().await);
let channel: serde_json::Value = res.json().await.unwrap();
assert_eq!(channel["adapter"], "stripe_crypto");
assert_eq!(channel["revision"], 1);
assert!(channel.get("secret_key").is_none());
assert!(channel.get("secret_key_ref").is_some());
assert!(channel.get("webhook_secret_ref").is_some());
let channel_id = channel["id"].as_str().unwrap();
let mut update = body.clone();
update["enabled"] = serde_json::json!(false);
update["display_order"] = serde_json::json!(21);
update["expected_revision"] = serde_json::json!(1);
let res = client()
.put(app.url(&format!("/api/admin/payments/channels/{channel_id}")))
.bearer_auth(&admin)
.json(&update)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "update: {:?}", res.text().await);
assert_eq!(res.json::<serde_json::Value>().await.unwrap()["revision"], 2);
update["expected_revision"] = serde_json::json!(1);
let res = client()
.put(app.url(&format!("/api/admin/payments/channels/{channel_id}")))
.bearer_auth(&admin)
.json(&update)
.send()
.await
.unwrap();
assert_eq!(res.status(), 409);
let res = client()
.get(app.url(&format!("/api/admin/payments/channels/{channel_id}/audit")))
.bearer_auth(&admin)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let audit: serde_json::Value = res.json().await.unwrap();
assert_eq!(audit.as_array().unwrap().len(), 2);
assert!(audit.to_string().contains(&key_ref));
assert!(!audit.to_string().contains("sk_test_"));
}
#[tokio::test]
#[serial]
async fn forged_stripe_webhook_is_rejected_without_payment_effects() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (_, _, _, sku) = setup_sellable(&app, &admin, "pm-hook", 1000, 5).await;
let (buyer, _) = register_customer(&app, "pm-hook").await;
add_to_cart(&app, &buyer, &sku, 1).await;
let checkout = checkout_payment(&app, &buyer).await;
let payment_id = checkout["payment_id"].as_str().unwrap();
let order_id = checkout["orders"][0]["id"].as_str().unwrap();
let res = client()
.post(app.url(&format!(
"/api/payments/webhooks/stripe/{}",
Uuid::new_v4()
)))
.header("stripe-signature", "t=1,v1=forged")
.body(format!(r#"{{"id":"evt_forged","type":"payment_intent.succeeded","data":{{"object":{{"metadata":{{"payment_id":"{payment_id}"}}}}}}}}"#))
.send()
.await
.unwrap();
assert!(res.status().is_client_error());
assert_eq!(order_status(&app, order_id).await, "pending_payment");
}
#[tokio::test]
#[serial]
async fn checkout_idempotency_replays_and_rejects_changed_payload() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (_, _, _, sku) = setup_sellable(&app, &admin, "pm-idem-checkout", 1000, 5).await;
let (buyer, _) = register_customer(&app, "pm-idem-checkout").await;
add_to_cart(&app, &buyer, &sku, 1).await;
let key = Uuid::new_v4().to_string();
let first = client()
.post(app.url("/api/orders/checkout"))
.bearer_auth(&buyer)
.header("Idempotency-Key", &key)
.json(&serde_json::json!({
"shipping_address": address(),
"currency": "USD",
"coupon_by_shop": {},
}))
.send()
.await
.unwrap();
assert_eq!(first.status(), 201, "checkout: {:?}", first.text().await);
let first: serde_json::Value = first.json().await.unwrap();
let payment_id = first["payment_id"].as_str().unwrap();
let first_order_ids: Vec<String> = first["orders"]
.as_array()
.unwrap()
.iter()
.map(|o| o["id"].as_str().unwrap().to_string())
.collect();
// Replay with the same key and payload returns the original result.
let replay = client()
.post(app.url("/api/orders/checkout"))
.bearer_auth(&buyer)
.header("Idempotency-Key", &key)
.json(&serde_json::json!({
"shipping_address": address(),
"currency": "USD",
"coupon_by_shop": {},
}))
.send()
.await
.unwrap();
assert_eq!(replay.status(), 201, "replay: {:?}", replay.text().await);
let replay: serde_json::Value = replay.json().await.unwrap();
assert_eq!(replay["payment_id"], payment_id);
let replay_order_ids: Vec<String> = replay["orders"]
.as_array()
.unwrap()
.iter()
.map(|o| o["id"].as_str().unwrap().to_string())
.collect();
assert_eq!(first_order_ids, replay_order_ids);
// The cart was cleared once; replaying did not decrement stock again.
let stock: i32 = sqlx::query_scalar("SELECT stock FROM skus WHERE id = $1::uuid")
.bind(&sku)
.fetch_one(&app.db)
.await
.unwrap();
assert_eq!(stock, 4);
// Reusing the key with a changed payload is rejected.
let conflict = client()
.post(app.url("/api/orders/checkout"))
.bearer_auth(&buyer)
.header("Idempotency-Key", &key)
.json(&serde_json::json!({
"shipping_address": address(),
"currency": "JPY",
"coupon_by_shop": {},
}))
.send()
.await
.unwrap();
assert_eq!(conflict.status(), 409);
// A checkout without the required idempotency key is rejected.
let missing = client()
.post(app.url("/api/orders/checkout"))
.bearer_auth(&buyer)
.json(&serde_json::json!({
"shipping_address": address(),
"currency": "USD",
"coupon_by_shop": {},
}))
.send()
.await
.unwrap();
assert_eq!(missing.status(), 400);
}
#[tokio::test]
#[serial]
async fn cancelling_one_member_cancels_the_whole_unpaid_payment_group() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (_, _, _, sku_a) = setup_sellable(&app, &admin, "pm-cancel-a", 1000, 5).await;
let (_, _, _, sku_b) = setup_sellable(&app, &admin, "pm-cancel-b", 2000, 5).await;
let (buyer, _) = register_customer(&app, "pm-cancel").await;
add_to_cart(&app, &buyer, &sku_a, 1).await;
add_to_cart(&app, &buyer, &sku_b, 1).await;
let checkout = checkout_payment(&app, &buyer).await;
let payment_id = checkout["payment_id"].as_str().unwrap();
let order_ids: Vec<String> = checkout["orders"]
.as_array()
.unwrap()
.iter()
.map(|o| o["id"].as_str().unwrap().to_string())
.collect();
assert_eq!(order_ids.len(), 2);
// Cancelling any single member cancels the entire group atomically.
let res = client()
.post(app.url(&format!("/api/orders/{}/cancel", order_ids[0])))
.bearer_auth(&buyer)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "cancel: {:?}", res.text().await);
for order_id in &order_ids {
assert_eq!(order_status(&app, order_id).await, "cancelled");
}
// Stock is restored exactly once for both members.
let stock_a: i32 = sqlx::query_scalar("SELECT stock FROM skus WHERE id = $1::uuid")
.bind(&sku_a)
.fetch_one(&app.db)
.await
.unwrap();
let stock_b: i32 = sqlx::query_scalar("SELECT stock FROM skus WHERE id = $1::uuid")
.bind(&sku_b)
.fetch_one(&app.db)
.await
.unwrap();
assert_eq!(stock_a, 5);
assert_eq!(stock_b, 5);
// The payment detail now reports the closed group.
let res = client()
.get(app.url(&format!("/api/payments/{payment_id}")))
.bearer_auth(&buyer)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let payment: serde_json::Value = res.json().await.unwrap();
assert_eq!(payment["status"], "closed");
}
#[tokio::test]
#[serial]
async fn wallet_aftersale_refund_completes_on_original_method() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (_, shop_id, _, sku) = setup_sellable(&app, &admin, "pm-refund", 1000, 5).await;
let (buyer, user_id) = register_customer(&app, "pm-refund").await;
add_to_cart(&app, &buyer, &sku, 1).await;
recharge(&app, &buyer, 1000).await;
let checkout = checkout_payment(&app, &buyer).await;
let payment_id = checkout["payment_id"].as_str().unwrap();
let order = checkout["orders"][0].clone();
let order_id = order["id"].as_str().unwrap();
let methods = methods(&app, &buyer, payment_id).await;
let channel_id = wallet_method(&methods)["id"].as_str().unwrap().to_string();
let res = attempt(&app, &buyer, payment_id, &channel_id, "pm-refund-1").await;
assert_eq!(res.status(), 200, "pay: {:?}", res.text().await);
assert_eq!(wallet_balance(&app, &buyer).await, 0);
let detail: serde_json::Value = client()
.get(app.url(&format!("/api/orders/{order_id}")))
.bearer_auth(&buyer)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let item_id = detail["items"][0]["id"].as_str().unwrap();
let apply = client()
.post(app.url("/api/aftersales"))
.bearer_auth(&buyer)
.json(&serde_json::json!({
"order_item_id": item_id,
"kind": "refund_only",
"reason": {"en": "not as described", "zh": "与描述不符"},
"amount_minor": 1000,
"evidence": ["https://example.com/evidence.png"]
}))
.send()
.await
.unwrap();
assert_eq!(apply.status(), 201, "apply: {:?}", apply.text().await);
let aftersale_id = apply.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
// The merchant token for this shop.
let admin2 = login_admin(&app).await;
let owner_token = common::make_shop_owner(&app, &admin2, &shop_id).await;
let approve = client()
.post(app.url(&format!("/api/shop/aftersales/{aftersale_id}/approve")))
.bearer_auth(&owner_token)
.send()
.await
.unwrap();
assert_eq!(approve.status(), 200, "approve: {:?}", approve.text().await);
let refund = client()
.post(app.url(&format!("/api/shop/aftersales/{aftersale_id}/refund")))
.bearer_auth(&owner_token)
.send()
.await
.unwrap();
assert_eq!(refund.status(), 200, "refund: {:?}", refund.text().await);
let refund_body: serde_json::Value = refund.json().await.unwrap();
// Wallet refunds complete synchronously on the original method.
assert_eq!(refund_body["status"], "refunded");
assert_eq!(wallet_balance(&app, &buyer).await, 1000);
let refund_total: i64 = sqlx::query_scalar(
"SELECT refund_total_minor FROM orders WHERE id = $1::uuid",
)
.bind(order_id)
.fetch_one(&app.db)
.await
.unwrap();
assert_eq!(refund_total, 1000);
let entries: serde_json::Value = client()
.get(app.url("/api/wallet/entries"))
.bearer_auth(&buyer)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let refund_credits = entries["items"]
.as_array()
.unwrap()
.iter()
.filter(|entry| entry["reason"] == "aftersale_refund")
.count();
assert_eq!(refund_credits, 1);
let _ = user_id;
}
#[tokio::test]
#[serial]
async fn admin_operations_list_detail_and_reconcile_are_exposed() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (_, _, _, sku) = setup_sellable(&app, &admin, "pm-ops", 1000, 5).await;
let (buyer, _) = register_customer(&app, "pm-ops").await;
add_to_cart(&app, &buyer, &sku, 1).await;
recharge(&app, &buyer, 1000).await;
let checkout = checkout_payment(&app, &buyer).await;
let payment_id = checkout["payment_id"].as_str().unwrap();
let methods = methods(&app, &buyer, payment_id).await;
let channel_id = wallet_method(&methods)["id"].as_str().unwrap().to_string();
let res = attempt(&app, &buyer, payment_id, &channel_id, "pm-ops-1").await;
assert_eq!(res.status(), 200, "pay: {:?}", res.text().await);
// Paginated operations listing discriminates payment rows.
let res = client()
.get(app.url("/api/admin/payments"))
.query(&[("kind", "payment"), ("per_page", "50")])
.bearer_auth(&admin)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "ops: {:?}", res.text().await);
let ops: serde_json::Value = res.json().await.unwrap();
let found = ops["items"]
.as_array()
.unwrap()
.iter()
.any(|row| row["payment_id"] == payment_id && row["kind"] == "payment");
assert!(found, "expected a payment operation row for {payment_id}");
// Admin detail matches the customer detail shape plus channel audit.
let res = client()
.get(app.url(&format!("/api/admin/payments/{payment_id}")))
.bearer_auth(&admin)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "admin detail: {:?}", res.text().await);
let detail: serde_json::Value = res.json().await.unwrap();
assert_eq!(detail["status"], "applied");
assert!(detail.get("audit").is_some());
// Reconcile queues a durable evidence-fetch command without collection.
let res = client()
.post(app.url(&format!("/api/admin/payments/{payment_id}/reconcile")))
.bearer_auth(&admin)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "reconcile: {:?}", res.text().await);
let command: serde_json::Value = res.json().await.unwrap();
assert_eq!(command["payment_id"], payment_id);
assert!(matches!(
command["status"].as_str().unwrap(),
"queued" | "running" | "succeeded" | "failed"
));
// Merchants cannot reach platform recovery endpoints.
let owner = common::make_shop_owner(
&app,
&admin,
&detail["orders"][0]["shop_id"].as_str().unwrap(),
)
.await;
let res = client()
.get(app.url("/api/admin/payments"))
.bearer_auth(&owner)
.send()
.await
.unwrap();
assert_eq!(res.status(), 403);
}
#[tokio::test]
#[serial]
async fn demo_recharge_is_refused_outside_demo_environment() {
// A sandbox-configured API refuses the simulated recharge server-side.
let config = vmall_api::config::Config {
database_url: common::TEST_DB_URL.into(),
redis_url: common::TEST_REDIS_URL.into(),
jwt_secret: "test-secret".into(),
port: 0,
jwt_ttl_secs: 3600,
payment: vmall_api::config::PaymentConfig {
environment: vmall_api::modules::payment::PaymentEnvironment::Sandbox,
..Default::default()
},
};
let db = sqlx::PgPool::connect(common::TEST_DB_URL).await.unwrap();
sqlx::migrate!("./migrations").run(&db).await.unwrap();
let state = vmall_api::state::assemble(config, db).await.unwrap();
vmall_api::seed::ensure_platform_admin(&state).await.unwrap();
let app = vmall_api::build_router(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let base = format!("http://127.0.0.1:{port}");
let email = format!("pm-sandbox-{}@test.local", uuid::Uuid::new_v4());
let res = client()
.post(format!("{base}/api/auth/register"))
.json(&serde_json::json!({
"email": email,
"password": "password123",
"display_name": "pm-sandbox",
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201);
let token = res.json::<serde_json::Value>().await.unwrap()["token"]
.as_str()
.unwrap()
.to_string();
let res = client()
.post(format!("{base}/api/wallet/recharges"))
.bearer_auth(&token)
.json(&serde_json::json!({ "amount_minor": 5000 }))
.send()
.await
.unwrap();
assert_eq!(res.status(), 403);
let balance = client()
.get(format!("{base}/api/wallet"))
.bearer_auth(&token)
.send()
.await
.unwrap()
.json::<serde_json::Value>()
.await
.unwrap();
assert_eq!(balance["demo_recharge_enabled"], false);
assert_eq!(balance["environment"], "sandbox");
}