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(), None) .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(), None) .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(), None) .await .unwrap(); assert_eq!(orders.len(), 2); let totals: Vec = 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(), None) .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(_))); }