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

418 lines
14 KiB
Rust

use vmall_api::{build_router, config::Config, seed::ensure_platform_admin, state};
pub const TEST_DB_URL: &str = "postgres://postgres:postgres@127.0.0.1:5432/vmall_test";
pub const TEST_REDIS_URL: &str = "redis://127.0.0.1:6379/";
pub struct TestApp {
pub base: String,
pub db: sqlx::PgPool,
pub state: vmall_api::state::AppState,
}
impl TestApp {
pub fn url(&self, path: &str) -> String {
format!("{}{}", self.base, path)
}
}
pub async fn spawn_state() -> vmall_api::state::AppState {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let config = Config {
database_url: TEST_DB_URL.into(),
redis_url: TEST_REDIS_URL.into(),
jwt_secret: "test-secret".into(),
port: 0,
jwt_ttl_secs: 3600,
payment: vmall_api::config::PaymentConfig {
mall_origin: Some("http://localhost:3000".into()),
mobile_origin: Some("http://localhost:3001".into()),
..Default::default()
},
};
let db = sqlx::PgPool::connect(TEST_DB_URL).await.unwrap();
sqlx::migrate!("./migrations").run(&db).await.unwrap();
let state = state::assemble(config, db).await.unwrap();
ensure_platform_admin(&state).await.unwrap();
state
}
/// Spin up the full app against the test database on an ephemeral port.
/// Migrations run once per process; domain tables are truncated per app.
pub async fn spawn_app() -> TestApp {
let state = spawn_state().await;
ensure_platform_admin(&state).await.unwrap();
let app = build_router(state.clone());
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();
});
TestApp {
base: format!("http://127.0.0.1:{port}"),
db: state.db.clone(),
state,
}
}
pub fn client() -> reqwest::Client {
reqwest::Client::new()
}
/// Register a fresh customer; returns (token, user_id).
pub async fn register_customer(app: &TestApp, label: &str) -> (String, String) {
let email = format!("{label}-{}@test.local", uuid::Uuid::new_v4());
let res = client()
.post(app.url("/api/auth/register"))
.json(&serde_json::json!({
"email": email,
"password": "password123",
"display_name": label,
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "register failed: {:?}", res.text().await);
let body: serde_json::Value = res.json().await.unwrap();
(
body["token"].as_str().unwrap().to_string(),
body["user"]["id"].as_str().unwrap().to_string(),
)
}
pub async fn login_admin(app: &TestApp) -> String {
let res = client()
.post(app.url("/api/auth/login"))
.json(&serde_json::json!({
"email": "admin@vmall.local",
"password": "admin1234",
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
res.json::<serde_json::Value>().await.unwrap()["token"]
.as_str()
.unwrap()
.to_string()
}
/// Create a shop via admin API; returns shop id.
pub async fn create_shop(app: &TestApp, admin_token: &str, slug: &str) -> String {
let slug = format!("{slug}-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
let res = client()
.post(app.url("/api/admin/shops"))
.bearer_auth(admin_token)
.json(&serde_json::json!({
"name": {"en": format!("Shop {slug}"), "zh": format!("店铺 {slug}")},
"slug": slug,
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "create shop: {:?}", res.text().await);
res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string()
}
/// Register a user and promote them to shop_owner of `shop_id`; returns a
/// fresh token carrying the shop role (issued after role change).
pub async fn make_shop_owner(app: &TestApp, admin_token: &str, shop_id: &str) -> String {
let email = format!("owner-{}@test.local", uuid::Uuid::new_v4());
let res = client()
.post(app.url("/api/auth/register"))
.json(&serde_json::json!({
"email": email,
"password": "password123",
"display_name": "owner",
}))
.send()
.await
.unwrap();
let user_id = res.json::<serde_json::Value>().await.unwrap()["user"]["id"]
.as_str()
.unwrap()
.to_string();
let res = client()
.put(app.url(&format!("/api/admin/users/{user_id}/role")))
.bearer_auth(admin_token)
.json(&serde_json::json!({ "role": "shop_owner", "shop_id": shop_id }))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "set role: {:?}", res.text().await);
let res = client()
.post(app.url("/api/auth/login"))
.json(&serde_json::json!({ "email": email, "password": "password123" }))
.send()
.await
.unwrap();
res.json::<serde_json::Value>().await.unwrap()["token"]
.as_str()
.unwrap()
.to_string()
}
/// Create a draft product with one priced SKU; returns product id.
pub async fn create_product_with_sku(
app: &TestApp,
owner_token: &str,
slug: &str,
price_minor: i64,
stock: i32,
) -> (String, String) {
create_product_full(app, owner_token, slug, price_minor, stock, None, None).await
}
/// Same, but placed in `category_id` so category filtering can be exercised.
pub async fn create_product_with_sku_in_category(
app: &TestApp,
owner_token: &str,
slug: &str,
price_minor: i64,
stock: i32,
category_id: Option<&str>,
) -> (String, String) {
create_product_full(
app,
owner_token,
slug,
price_minor,
stock,
category_id,
None,
)
.await
}
/// Same, with a category and a brand so both filters can be exercised.
pub async fn create_product_full(
app: &TestApp,
owner_token: &str,
slug: &str,
price_minor: i64,
stock: i32,
category_id: Option<&str>,
brand_id: Option<&str>,
) -> (String, String) {
let slug = format!("{slug}-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
let res = client()
.post(app.url("/api/shop/products"))
.bearer_auth(owner_token)
.json(&serde_json::json!({
"slug": slug,
"name": {"en": format!("Product {slug}"), "zh": format!("商品 {slug}")},
"description": {"en": "desc en", "zh": "描述"},
"category_id": category_id,
"brand_id": brand_id,
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "create product: {:?}", res.text().await);
let product_id = res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let res = client()
.post(app.url(&format!("/api/shop/products/{product_id}/skus")))
.bearer_auth(owner_token)
.json(&serde_json::json!({
"sku_code": format!("{slug}-default"),
"price_minor": price_minor,
"currency": "USD",
"stock": stock,
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "upsert sku: {:?}", res.text().await);
(product_id, slug)
}
/// Publish a draft product so it appears in the public catalog.
pub async fn publish_product(app: &TestApp, owner_token: &str, product_id: &str) {
let res = client()
.post(app.url(&format!("/api/shop/products/{product_id}/publish")))
.bearer_auth(owner_token)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "publish: {:?}", res.text().await);
}
/// Look up a seeded reference category id by slug.
pub async fn category_id_by_slug(app: &TestApp, slug: &str) -> String {
let res = client()
.get(app.url("/api/categories"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let cats: serde_json::Value = res.json().await.unwrap();
cats.as_array()
.unwrap()
.iter()
.find(|c| c["slug"] == slug)
.unwrap_or_else(|| panic!("seeded category {slug} missing"))["id"]
.as_str()
.unwrap()
.to_string()
}
/// Full sellable fixture: shop + owner + published product with one SKU.
/// Returns (owner_token, shop_id, product_id, sku_id).
pub async fn setup_sellable(
app: &TestApp,
admin: &str,
slug: &str,
price_minor: i64,
stock: i32,
) -> (String, String, String, String) {
let shop_id = create_shop(app, admin, slug).await;
let owner = make_shop_owner(app, admin, &shop_id).await;
let (product_id, _) = create_product_with_sku(app, &owner, slug, price_minor, stock).await;
let res = client()
.post(app.url(&format!("/api/shop/products/{product_id}/publish")))
.bearer_auth(&owner)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "publish: {:?}", res.text().await);
let sku_id: String =
sqlx::query_scalar("SELECT id::text FROM skus WHERE product_id = $1::uuid")
.bind(&product_id)
.fetch_one(&app.db)
.await
.unwrap();
(owner, shop_id, product_id, sku_id)
}
pub async fn add_to_cart(app: &TestApp, token: &str, sku_id: &str, qty: i32) {
let res = client()
.post(app.url("/api/cart/items"))
.bearer_auth(token)
.json(&serde_json::json!({ "sku_id": sku_id, "qty": qty }))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "add to cart: {:?}", res.text().await);
}
pub async fn checkout(app: &TestApp, token: &str) -> Vec<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": {
"recipient": "Test Recipient",
"phone": "123456",
"country": "US",
"region": "CA",
"city": "San Jose",
"line1": "1 Test Way",
"postal_code": "95131"
},
"currency": "USD"
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "checkout: {:?}", res.text().await);
let body: serde_json::Value = res.json().await.unwrap();
body["orders"].as_array().unwrap().clone()
}
pub async fn pay(app: &TestApp, token: &str, order_id: &str) {
let res = client()
.post(app.url("/api/payments"))
.bearer_auth(token)
.json(&serde_json::json!({ "order_ids": [order_id] }))
.send()
.await
.unwrap();
let payment: serde_json::Value = match res.status() {
reqwest::StatusCode::CREATED => res.json().await.unwrap(),
reqwest::StatusCode::CONFLICT => {
let status: String = sqlx::query_scalar("SELECT status::text FROM orders WHERE id = $1::uuid")
.bind(order_id)
.fetch_one(&app.db)
.await
.unwrap();
if status == "paid" {
return;
}
panic!("resolve payment failed: {:?}", res.text().await);
}
_ => panic!("resolve payment failed: {:?}", res.text().await),
};
if payment["status"] == "applied" {
return;
}
let total_minor = payment["total_minor"].as_i64().unwrap();
let wallet = client()
.get(app.url("/api/wallet"))
.bearer_auth(token)
.send()
.await
.unwrap();
assert_eq!(wallet.status(), 200, "wallet: {:?}", wallet.text().await);
let wallet: serde_json::Value = wallet.json().await.unwrap();
let available_minor = wallet["available_minor"].as_i64().unwrap();
if available_minor < total_minor && payment["currency"] == wallet["currency"] {
let recharge = client()
.post(app.url("/api/wallet/recharges"))
.bearer_auth(token)
.json(&serde_json::json!({ "amount_minor": total_minor - available_minor }))
.send()
.await
.unwrap();
assert_eq!(recharge.status(), 201, "recharge: {:?}", recharge.text().await);
}
let methods = client()
.get(app.url(&format!("/api/payments/{}/methods", payment["id"].as_str().unwrap())))
.bearer_auth(token)
.send()
.await
.unwrap();
assert_eq!(methods.status(), 200, "methods: {:?}", methods.text().await);
let methods: Vec<serde_json::Value> = methods.json().await.unwrap();
let channel_id = methods
.iter()
.find(|m| m["adapter"] == "wallet")
.and_then(|m| m["id"].as_str())
.expect("wallet channel missing");
let attempt = client()
.post(app.url(&format!("/api/payments/{}/attempts", payment["id"].as_str().unwrap())))
.bearer_auth(token)
.header("Idempotency-Key", uuid::Uuid::new_v4().to_string())
.json(&serde_json::json!({
"channel_id": channel_id,
"return_surface": "mall"
}))
.send()
.await
.unwrap();
assert_eq!(attempt.status(), 200, "attempt: {:?}", attempt.text().await);
let attempt: serde_json::Value = attempt.json().await.unwrap();
assert_eq!(attempt["status"], "settled");
}
pub async fn mark_paid_legacy_demo(app: &TestApp, order_id: &str) {
let res = sqlx::query(
"UPDATE orders SET status = 'paid', payment_provenance = 'legacy_demo' WHERE id = $1::uuid",
)
.bind(order_id)
.execute(&app.db)
.await
.unwrap();
assert_eq!(res.rows_affected(), 1);
}