Files
vmall/apps/api/tests/coupons.rs
T
james 23955434c6 feat(api): shop coupons with per-shop checkout redemption
Templates belong to a shop; claiming copies their terms into a customer-owned
snapshot so a later edit or disable cannot rewrite a held coupon. Claim stock
is taken with a guarded decrement after locking the template, and a unique
(user, template) index makes a duplicate claim a 409. Deleting a template
leaves claimed snapshots standing via ON DELETE SET NULL.

Checkout accepts at most one owned coupon per generated shop order, locks the
selected coupons by primary key after the SKU locks, and resolves eligibility
and the discount server-side (ownership, shop, status, window, converted
threshold). The realized discount and coupon id land on the order, and a
pending-payment cancellation restores the coupon in the same transaction as
stock.

The shared contract gains the coupon types, claim/list/manage methods, and the
checkout coupon map; the fixed-data adapter implements the same surface.

Surfaces (shop-admin management, mall coupon pages, checkout selection) and
seeding still follow in tasks 3.1-4.2.
2026-09-18 12:03:00 +00:00

383 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
mod common;
use std::collections::HashMap;
use common::{
add_to_cart, client, create_shop, login_admin, make_shop_owner, register_customer,
setup_sellable, spawn_app, TestApp,
};
use serial_test::serial;
use uuid::Uuid;
fn template_body(amount: i64, threshold: i64, stock: i32) -> serde_json::Value {
serde_json::json!({
"title": {"en": "Test coupon", "zh": "测试优惠券"},
"amount_minor": amount,
"threshold_minor": threshold,
"currency": "USD",
"stock": stock,
"enabled": true,
"starts_at": "2020-01-01T00:00:00Z",
"ends_at": "2999-01-01T00:00:00Z",
})
}
async fn create_template(app: &TestApp, owner: &str, body: serde_json::Value) -> String {
let res = client()
.post(app.url("/api/shop/coupon-templates"))
.bearer_auth(owner)
.json(&body)
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "create template: {:?}", res.text().await);
res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string()
}
async fn claim(app: &TestApp, token: &str, template_id: &str) -> reqwest::StatusCode {
client()
.post(app.url("/api/me/coupons"))
.bearer_auth(token)
.json(&serde_json::json!({ "template_id": template_id }))
.send()
.await
.unwrap()
.status()
}
async fn claim_ok(app: &TestApp, token: &str, template_id: &str) -> serde_json::Value {
let res = client()
.post(app.url("/api/me/coupons"))
.bearer_auth(token)
.json(&serde_json::json!({ "template_id": template_id }))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "claim: {:?}", res.text().await);
res.json().await.unwrap()
}
async fn checkout_with(
app: &TestApp,
token: &str,
coupon_by_shop: HashMap<String, String>,
) -> reqwest::Response {
client()
.post(app.url("/api/orders/checkout"))
.bearer_auth(token)
.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",
"coupon_by_shop": coupon_by_shop,
}))
.send()
.await
.unwrap()
}
#[tokio::test]
#[serial]
async fn shop_manages_only_its_own_templates() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let shop_a = create_shop(&app, &admin, "cp-own-a").await;
let owner_a = make_shop_owner(&app, &admin, &shop_a).await;
let shop_b = create_shop(&app, &admin, "cp-own-b").await;
let owner_b = make_shop_owner(&app, &admin, &shop_b).await;
let template_id = create_template(&app, &owner_a, template_body(500, 0, 3)).await;
// The issuing shop sees it; another shop does not.
let res = client()
.get(app.url("/api/shop/coupon-templates"))
.bearer_auth(&owner_a)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let mine: Vec<serde_json::Value> = res.json().await.unwrap();
assert_eq!(mine.len(), 1);
let res = client()
.get(app.url("/api/shop/coupon-templates"))
.bearer_auth(&owner_b)
.send()
.await
.unwrap();
let theirs: Vec<serde_json::Value> = res.json().await.unwrap();
assert!(theirs.is_empty(), "cross-shop templates are hidden");
// Cross-shop mutation is a 404, not a 403 with an existence hint.
let res = client()
.put(app.url(&format!("/api/shop/coupon-templates/{template_id}")))
.bearer_auth(&owner_b)
.json(&template_body(100, 0, 1))
.send()
.await
.unwrap();
assert_eq!(res.status(), 404);
let res = client()
.delete(app.url(&format!("/api/shop/coupon-templates/{template_id}")))
.bearer_auth(&owner_b)
.send()
.await
.unwrap();
assert_eq!(res.status(), 404);
// The public claimable listing exposes an active template.
let res = client()
.get(app.url(&format!("/api/shops/{shop_a}/coupon-templates")))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let public: Vec<serde_json::Value> = res.json().await.unwrap();
assert_eq!(public.len(), 1);
assert_eq!(public[0]["amount_minor"], 500);
}
#[tokio::test]
#[serial]
async fn claim_is_unique_and_the_last_one_wins() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let shop = create_shop(&app, &admin, "cp-claim").await;
let owner = make_shop_owner(&app, &admin, &shop).await;
let template = create_template(&app, &owner, template_body(500, 0, 1)).await;
let (token_a, _) = register_customer(&app, "cp-claim-a").await;
let (token_b, _) = register_customer(&app, "cp-claim-b").await;
// Two customers race for the final instance.
let (a, b) = tokio::join!(
claim(&app, &token_a, &template),
claim(&app, &token_b, &template),
);
let wins = [a, b].iter().filter(|s| s.is_success()).count();
assert_eq!(wins, 1, "exactly one claim takes the last stock: {a} {b}");
let loser = if a.is_success() { b } else { a };
assert_eq!(loser, reqwest::StatusCode::CONFLICT);
// The same customer cannot claim the same template twice.
let dupe = create_template(&app, &owner, template_body(500, 0, 5)).await;
let (token_c, _) = register_customer(&app, "cp-claim-c").await;
claim_ok(&app, &token_c, &dupe).await;
assert_eq!(
claim(&app, &token_c, &dupe).await,
reqwest::StatusCode::CONFLICT,
"a duplicate claim is refused"
);
// Stock never went negative.
let stock: i32 = sqlx::query_scalar("SELECT stock FROM coupon_templates WHERE id = $1")
.bind(Uuid::parse_str(&template).unwrap())
.fetch_one(&app.db)
.await
.unwrap();
assert_eq!(stock, 0);
}
#[tokio::test]
#[serial]
async fn checkout_discounts_only_the_issuing_shop() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (owner_a, shop_a, _, sku_a) = setup_sellable(&app, &admin, "cp-multi-a", 1000, 5).await;
let (_owner_b, _shop_b, _, sku_b) = setup_sellable(&app, &admin, "cp-multi-b", 2000, 5).await;
let template = create_template(&app, &owner_a, template_body(500, 0, 5)).await;
let (token, _user_id) = register_customer(&app, "cp-multi").await;
let coupon = claim_ok(&app, &token, &template).await;
let coupon_id = coupon["id"].as_str().unwrap().to_string();
add_to_cart(&app, &token, &sku_a, 1).await;
add_to_cart(&app, &token, &sku_b, 1).await;
let res = checkout_with(
&app,
&token,
HashMap::from([(shop_a.clone(), coupon_id.clone())]),
)
.await;
assert_eq!(res.status(), 201, "checkout: {:?}", res.text().await);
let orders: Vec<serde_json::Value> = res.json().await.unwrap();
assert_eq!(orders.len(), 2);
let discounted = orders
.iter()
.find(|o| o["shop_id"] == shop_a.as_str())
.expect("shop A order");
assert_eq!(discounted["discount_minor"], 500);
assert_eq!(discounted["total_minor"], 500, "1000 500");
assert_eq!(discounted["coupon_id"], coupon_id.as_str());
let untouched = orders
.iter()
.find(|o| o["shop_id"] != shop_a.as_str())
.expect("shop B order");
assert_eq!(untouched["discount_minor"], 0);
assert_eq!(untouched["total_minor"], 2000);
// The coupon is now bound to the order.
let (status, order_ref): (String, Option<Uuid>) =
sqlx::query_as("SELECT status::text, order_id FROM coupons WHERE id = $1")
.bind(Uuid::parse_str(&coupon_id).unwrap())
.fetch_one(&app.db)
.await
.unwrap();
assert_eq!(status, "redeemed");
assert!(order_ref.is_some());
// A redeemed coupon cannot be spent again.
add_to_cart(&app, &token, &sku_a, 1).await;
let res = checkout_with(
&app,
&token,
HashMap::from([(shop_a.clone(), coupon_id.clone())]),
)
.await;
assert_eq!(res.status(), 409, "a spent coupon is not redeemable");
}
#[tokio::test]
#[serial]
async fn rejects_cross_shop_threshold_and_expired_coupons() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (owner_a, shop_a, _, sku_a) = setup_sellable(&app, &admin, "cp-rej-a", 1000, 5).await;
let (_owner_b, shop_b, _, sku_b) = setup_sellable(&app, &admin, "cp-rej-b", 2000, 5).await;
let (token, user_id) = register_customer(&app, "cp-rej").await;
let user = Uuid::parse_str(&user_id).unwrap();
// Cross-shop: a shop A coupon offered against a shop B order.
let cross = create_template(&app, &owner_a, template_body(500, 0, 5)).await;
let cross_coupon = claim_ok(&app, &token, &cross).await;
add_to_cart(&app, &token, &sku_b, 1).await;
let res = checkout_with(
&app,
&token,
HashMap::from([(
shop_b.clone(),
cross_coupon["id"].as_str().unwrap().to_string(),
)]),
)
.await;
assert_eq!(res.status(), 400, "coupon was not issued by that shop");
// Threshold: subtotal below the coupon's threshold.
let high = create_template(&app, &owner_a, template_body(500, 10_000, 5)).await;
let high_coupon = claim_ok(&app, &token, &high).await;
add_to_cart(&app, &token, &sku_a, 1).await;
let res = checkout_with(
&app,
&token,
HashMap::from([(
shop_a.clone(),
high_coupon["id"].as_str().unwrap().to_string(),
)]),
)
.await;
assert_eq!(res.status(), 409, "subtotal does not reach the threshold");
// Expired: claim while active, then let the snapshot window elapse.
let expiring = create_template(&app, &owner_a, template_body(500, 0, 5)).await;
let expiring_coupon = claim_ok(&app, &token, &expiring).await;
sqlx::query("UPDATE coupons SET ends_at = now() - interval '1 day' WHERE id = $1")
.bind(Uuid::parse_str(expiring_coupon["id"].as_str().unwrap()).unwrap())
.execute(&app.db)
.await
.unwrap();
let res = checkout_with(
&app,
&token,
HashMap::from([(
shop_a.clone(),
expiring_coupon["id"].as_str().unwrap().to_string(),
)]),
)
.await;
assert_eq!(res.status(), 409, "an elapsed coupon is not redeemable");
// Every failed checkout rolled back: no order was created.
let orders: i64 = sqlx::query_scalar("SELECT count(*) FROM orders WHERE user_id = $1")
.bind(user)
.fetch_one(&app.db)
.await
.unwrap();
assert_eq!(orders, 0);
}
#[tokio::test]
#[serial]
async fn cancelling_a_pending_order_restores_its_coupon() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "cp-cancel", 1000, 5).await;
let template = create_template(&app, &owner, template_body(500, 0, 5)).await;
let (token, user_id) = register_customer(&app, "cp-cancel").await;
let user = Uuid::parse_str(&user_id).unwrap();
let coupon = claim_ok(&app, &token, &template).await;
let coupon_id = coupon["id"].as_str().unwrap().to_string();
let shop_id = coupon["shop_id"].as_str().unwrap().to_string();
add_to_cart(&app, &token, &sku, 2).await;
let res = checkout_with(
&app,
&token,
HashMap::from([(shop_id, coupon_id.clone())]),
)
.await;
assert_eq!(res.status(), 201);
let orders: Vec<serde_json::Value> = res.json().await.unwrap();
let order_id = orders[0]["id"].as_str().unwrap().to_string();
let res = client()
.post(app.url(&format!("/api/orders/{order_id}/cancel")))
.bearer_auth(&token)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "cancel: {:?}", res.text().await);
let (status, order_ref): (String, Option<Uuid>) =
sqlx::query_as("SELECT status::text, order_id FROM coupons WHERE id = $1")
.bind(Uuid::parse_str(&coupon_id).unwrap())
.fetch_one(&app.db)
.await
.unwrap();
assert_eq!(status, "claimed", "the coupon is claimable again");
assert!(order_ref.is_none(), "and no longer bound to the order");
// Stock came back with it.
let stock: i32 = sqlx::query_scalar("SELECT stock FROM skus WHERE id = $1")
.bind(Uuid::parse_str(&sku).unwrap())
.fetch_one(&app.db)
.await
.unwrap();
assert_eq!(stock, 5);
// The snapshot survived: the customer still holds it.
let held: i64 =
sqlx::query_scalar("SELECT count(*) FROM coupons WHERE user_id = $1 AND template_id = $2")
.bind(user)
.bind(Uuid::parse_str(&template).unwrap())
.fetch_one(&app.db)
.await
.unwrap();
assert_eq!(held, 1);
}