Prevent oversell by conditioning stock updates and locking SKUs in primary-key order, and record the same concurrent-counter rule in the API spec and agent guide. Co-authored-by: Cursor <cursoragent@cursor.com>
158 lines
5.0 KiB
Rust
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())
|
|
.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())
|
|
.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())
|
|
.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())
|
|
.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(_)));
|
|
}
|