Files
vmall/apps/api/tests/order_service.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

158 lines
5.0 KiB
Rust

mod common;
use serial_test::serial;
use uuid::Uuid;
use vmall_api::error::ApiError;
use vmall_api::modules::cart;
use vmall_api::modules::identity::service::{self as identity, RegisterInput};
use vmall_api::modules::order::{self, AddressBody};
use vmall_api::state::AppState;
fn address() -> AddressBody {
AddressBody {
recipient: "Test".into(),
phone: "123".into(),
country: "US".into(),
region: "CA".into(),
city: "SJ".into(),
line1: "1 Way".into(),
postal_code: "95131".into(),
}
}
async fn register_user(state: &AppState, label: &str) -> Uuid {
let email = format!("{label}-{}@test.local", Uuid::new_v4());
let (user, _) = identity::register(
state,
RegisterInput {
email,
password: "password123".into(),
display_name: label.into(),
},
)
.await
.unwrap();
user.id
}
async fn sellable_sku(state: &AppState, slug: &str, price_minor: i64, stock: i32) -> Uuid {
let slug = format!("{slug}-{}", &Uuid::new_v4().simple().to_string()[..8]);
let shop_id: Uuid = sqlx::query_scalar(
"INSERT INTO shops (name, slug) VALUES ($1, $2) RETURNING id",
)
.bind(serde_json::json!({"en": slug, "zh": slug}))
.bind(&slug)
.fetch_one(&state.db)
.await
.unwrap();
let product_id: Uuid = sqlx::query_scalar(
"INSERT INTO products (shop_id, slug, name, status)
VALUES ($1, $2, $3, 'published') RETURNING id",
)
.bind(shop_id)
.bind(&slug)
.bind(serde_json::json!({"en": slug, "zh": slug}))
.fetch_one(&state.db)
.await
.unwrap();
sqlx::query_scalar(
"INSERT INTO skus (product_id, sku_code, price_minor, currency, stock, active)
VALUES ($1, $2, $3, 'USD', $4, TRUE) RETURNING id",
)
.bind(product_id)
.bind(format!("{slug}-sku"))
.bind(price_minor)
.bind(stock)
.fetch_one(&state.db)
.await
.unwrap()
}
#[tokio::test]
#[serial]
async fn checkout_rejects_empty_cart() {
let state = common::spawn_state().await;
let user_id = register_user(&state, "empty-cart").await;
let err = order::checkout(&state, user_id, address(), "USD".into(), Default::default())
.await
.unwrap_err();
assert!(matches!(err, ApiError::BadRequest(m) if m.contains("empty")));
}
#[tokio::test]
#[serial]
async fn decrement_stock_rejects_when_guard_fails() {
let state = common::spawn_state().await;
let sku_id = sellable_sku(&state, "guard", 100, 1).await;
let mut tx = state.db.begin().await.unwrap();
let err = order::repo::decrement_stock(&mut tx, sku_id, 2)
.await
.unwrap_err();
tx.rollback().await.unwrap();
assert!(matches!(err, ApiError::Conflict(m) if m.contains("insufficient stock")));
let left: i32 = sqlx::query_scalar("SELECT stock FROM skus WHERE id = $1")
.bind(sku_id)
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(left, 1);
}
#[tokio::test]
#[serial]
async fn checkout_rejects_insufficient_stock() {
let state = common::spawn_state().await;
let user_id = register_user(&state, "low-stock").await;
let sku_id = sellable_sku(&state, "low", 1000, 1).await;
cart::service::add_item(&state, user_id, sku_id, 2)
.await
.unwrap();
let err = order::checkout(&state, user_id, address(), "USD".into(), Default::default())
.await
.unwrap_err();
assert!(matches!(err, ApiError::Conflict(m) if m.contains("insufficient stock")));
}
#[tokio::test]
#[serial]
async fn checkout_splits_per_shop() {
let state = common::spawn_state().await;
let user_id = register_user(&state, "split").await;
let sku_a = sellable_sku(&state, "sa", 1000, 5).await;
let sku_b = sellable_sku(&state, "sb", 2000, 5).await;
cart::service::add_item(&state, user_id, sku_a, 2)
.await
.unwrap();
cart::service::add_item(&state, user_id, sku_b, 1)
.await
.unwrap();
let orders = order::checkout(&state, user_id, address(), "USD".into(), Default::default())
.await
.unwrap();
assert_eq!(orders.len(), 2);
let totals: Vec<i64> = orders.iter().map(|o| o.order.total_minor).collect();
assert!(totals.contains(&2000) && totals.contains(&2000));
}
#[tokio::test]
#[serial]
async fn pay_and_cancel_require_pending_payment() {
let state = common::spawn_state().await;
let user_id = register_user(&state, "status").await;
let sku_id = sellable_sku(&state, "st", 500, 3).await;
cart::service::add_item(&state, user_id, sku_id, 1)
.await
.unwrap();
let orders = order::checkout(&state, user_id, address(), "USD".into(), Default::default())
.await
.unwrap();
let id = orders[0].order.id;
order::service::pay(&state, user_id, id).await.unwrap();
let err = order::service::pay(&state, user_id, id).await.unwrap_err();
assert!(matches!(err, ApiError::Conflict(_)));
let err = order::service::cancel(&state, user_id, id)
.await
.unwrap_err();
assert!(matches!(err, ApiError::Conflict(_)));
}