feat: backend MVP (auth/rbac, catalog, orders, fulfillment, invoices) + specs + scaffolds
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
mod common;
|
||||
|
||||
use common::{client, login_admin, register_customer, spawn_app};
|
||||
use serial_test::serial;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn register_login_me_happy_path() {
|
||||
let app = spawn_app().await;
|
||||
let (token, user_id) = register_customer(&app, "alice").await;
|
||||
|
||||
let res = client()
|
||||
.get(app.url("/api/auth/me"))
|
||||
.bearer_auth(&token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
let me: serde_json::Value = res.json().await.unwrap();
|
||||
assert_eq!(me["id"], user_id);
|
||||
assert_eq!(me["role"], "customer");
|
||||
assert!(me.get("password_hash").is_none(), "hash must not leak");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn duplicate_email_conflict() {
|
||||
let app = spawn_app().await;
|
||||
let email = format!("dup-{}@test.local", uuid::Uuid::new_v4());
|
||||
for expected in [201, 409] {
|
||||
let res = client()
|
||||
.post(app.url("/api/auth/register"))
|
||||
.json(&serde_json::json!({
|
||||
"email": email,
|
||||
"password": "password123",
|
||||
"display_name": "dup",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn wrong_password_unauthorized() {
|
||||
let app = spawn_app().await;
|
||||
let email = format!("wp-{}@test.local", uuid::Uuid::new_v4());
|
||||
client()
|
||||
.post(app.url("/api/auth/register"))
|
||||
.json(&serde_json::json!({
|
||||
"email": email,
|
||||
"password": "password123",
|
||||
"display_name": "wp",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let res = client()
|
||||
.post(app.url("/api/auth/login"))
|
||||
.json(&serde_json::json!({ "email": email, "password": "wrong-password" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 401);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn me_requires_token() {
|
||||
let app = spawn_app().await;
|
||||
let res = client().get(app.url("/api/auth/me")).send().await.unwrap();
|
||||
assert_eq!(res.status(), 401);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn seeded_admin_can_login() {
|
||||
let app = spawn_app().await;
|
||||
let token = login_admin(&app).await;
|
||||
let res = client()
|
||||
.get(app.url("/api/auth/me"))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let me: serde_json::Value = res.json().await.unwrap();
|
||||
assert_eq!(me["role"], "platform_admin");
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
mod common;
|
||||
|
||||
use common::{
|
||||
client, create_product_with_sku, create_shop, login_admin, make_shop_owner, register_customer,
|
||||
spawn_app,
|
||||
};
|
||||
use serial_test::serial;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn customer_forbidden_on_admin_routes() {
|
||||
let app = spawn_app().await;
|
||||
let (customer_token, _) = register_customer(&app, "cust").await;
|
||||
let res = client()
|
||||
.get(app.url("/api/admin/users"))
|
||||
.bearer_auth(&customer_token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 403);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn publish_lifecycle_and_public_visibility() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let shop_id = create_shop(&app, &admin, "shop-life").await;
|
||||
let owner = make_shop_owner(&app, &admin, &shop_id).await;
|
||||
|
||||
// draft with no SKU cannot be published
|
||||
let res = client()
|
||||
.post(app.url("/api/shop/products"))
|
||||
.bearer_auth(&owner)
|
||||
.json(&serde_json::json!({
|
||||
"slug": "nosku",
|
||||
"name": {"en": "NoSku", "zh": "无SKU"},
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let no_sku_id = res.json::<serde_json::Value>().await.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/shop/products/{no_sku_id}/publish")))
|
||||
.bearer_auth(&owner)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 400);
|
||||
|
||||
// product with SKU: publish → listed publicly → unpublish → gone
|
||||
let (product_id, mug_slug) = create_product_with_sku(&app, &owner, "mug", 1299, 10).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);
|
||||
assert_eq!(res.json::<serde_json::Value>().await.unwrap()["status"], "published");
|
||||
|
||||
let res = client().get(app.url("/api/products")).send().await.unwrap();
|
||||
let list: serde_json::Value = res.json().await.unwrap();
|
||||
assert!(list["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|p| p["id"] == product_id));
|
||||
|
||||
// detail by slug works and carries i18n fields
|
||||
let res = client()
|
||||
.get(app.url(&format!("/api/products/{mug_slug}")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
let detail: serde_json::Value = res.json().await.unwrap();
|
||||
assert_eq!(detail["name"]["en"], format!("Product {mug_slug}"));
|
||||
assert_eq!(detail["name"]["zh"], format!("商品 {mug_slug}"));
|
||||
assert_eq!(detail["skus"][0]["price_minor"], 1299);
|
||||
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/shop/products/{product_id}/unpublish")))
|
||||
.bearer_auth(&owner)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
let res = client()
|
||||
.get(app.url(&format!("/api/products/{product_id}")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 404);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn shop_isolation_enforced() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let shop_a = create_shop(&app, &admin, "shop-iso-a").await;
|
||||
let shop_b = create_shop(&app, &admin, "shop-iso-b").await;
|
||||
let owner_a = make_shop_owner(&app, &admin, &shop_a).await;
|
||||
let owner_b = make_shop_owner(&app, &admin, &shop_b).await;
|
||||
|
||||
let (product_b, _) = create_product_with_sku(&app, &owner_b, "b-item", 500, 3).await;
|
||||
let res = client()
|
||||
.get(app.url(&format!("/api/shop/products/{product_b}")))
|
||||
.bearer_auth(&owner_a)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 404);
|
||||
|
||||
// owner A's product list contains none of shop B's products
|
||||
let res = client()
|
||||
.get(app.url("/api/shop/products"))
|
||||
.bearer_auth(&owner_a)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let list: serde_json::Value = res.json().await.unwrap();
|
||||
assert!(!list["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|p| p["id"] == product_b));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn currency_conversion_math() {
|
||||
let app = spawn_app().await;
|
||||
// 1000 minor USD ($10.00) -> JPY at rate 150 => 1500 minor (¥1500)
|
||||
let res = client()
|
||||
.get(app.url("/api/currencies/convert?amount_minor=1000&from=USD&to=JPY"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
let body: serde_json::Value = res.json().await.unwrap();
|
||||
assert_eq!(body["amount_minor"], 1500);
|
||||
assert_eq!(body["currency"], "JPY");
|
||||
|
||||
// disabled currency rejected
|
||||
let admin = login_admin(&app).await;
|
||||
let res = client()
|
||||
.post(app.url("/api/admin/currencies"))
|
||||
.bearer_auth(&admin)
|
||||
.json(&serde_json::json!({
|
||||
"code": "XXX",
|
||||
"name": {"en": "Test", "zh": "测试"},
|
||||
"symbol": "X",
|
||||
"exponent": 2,
|
||||
"rate_to_base": "2.0",
|
||||
"enabled": false,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
let res = client()
|
||||
.get(app.url("/api/currencies/convert?amount_minor=100&from=USD&to=XXX"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 400);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn suspended_shop_hidden_from_public_catalog() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let shop_id = create_shop(&app, &admin, "shop-susp").await;
|
||||
let owner = make_shop_owner(&app, &admin, &shop_id).await;
|
||||
let (product_id, _) = create_product_with_sku(&app, &owner, "susp-item", 100, 1).await;
|
||||
client()
|
||||
.post(app.url(&format!("/api/shop/products/{product_id}/publish")))
|
||||
.bearer_auth(&owner)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
client()
|
||||
.put(app.url(&format!("/api/admin/shops/{shop_id}/status")))
|
||||
.bearer_auth(&admin)
|
||||
.json(&serde_json::json!({ "status": "suspended" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let res = client().get(app.url("/api/products")).send().await.unwrap();
|
||||
let list: serde_json::Value = res.json().await.unwrap();
|
||||
assert!(!list["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|p| p["id"] == product_id));
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
use vmall_api::{build_router, config::Config, seed::ensure_platform_admin, state::build_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,
|
||||
}
|
||||
|
||||
impl TestApp {
|
||||
pub fn url(&self, path: &str) -> String {
|
||||
format!("{}{}", self.base, path)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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,
|
||||
};
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&sqlx::PgPool::connect(TEST_DB_URL).await.unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let state = build_state(&config).await.unwrap();
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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": "描述"},
|
||||
}))
|
||||
.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)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
.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);
|
||||
res.json::<Vec<serde_json::Value>>().await.unwrap()
|
||||
}
|
||||
|
||||
pub async fn pay(app: &TestApp, token: &str, order_id: &str) {
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/orders/{order_id}/pay")))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200, "pay: {:?}", res.text().await);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
mod common;
|
||||
|
||||
use common::{
|
||||
add_to_cart, checkout, client, login_admin, pay, register_customer, setup_sellable, spawn_app,
|
||||
};
|
||||
use serial_test::serial;
|
||||
|
||||
async fn stock_of(app: &common::TestApp, sku_id: &str) -> i32 {
|
||||
sqlx::query_scalar("SELECT stock FROM skus WHERE id = $1::uuid")
|
||||
.bind(sku_id)
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn checkout_splits_orders_per_shop_and_clears_cart() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let (_, _, _, sku_a) = setup_sellable(&app, &admin, "split-a", 1000, 5).await;
|
||||
let (_, _, _, sku_b) = setup_sellable(&app, &admin, "split-b", 2000, 5).await;
|
||||
let (buyer, _) = register_customer(&app, "buyer").await;
|
||||
|
||||
add_to_cart(&app, &buyer, &sku_a, 2).await;
|
||||
add_to_cart(&app, &buyer, &sku_b, 1).await;
|
||||
|
||||
let orders = checkout(&app, &buyer).await;
|
||||
assert_eq!(orders.len(), 2, "one order per shop");
|
||||
let totals: Vec<i64> = orders
|
||||
.iter()
|
||||
.map(|o| o["total_minor"].as_i64().unwrap())
|
||||
.collect();
|
||||
assert!(totals.contains(&2000) && totals.contains(&2000)); // 2×1000 and 1×2000
|
||||
assert!(orders.iter().all(|o| o["status"] == "pending_payment"));
|
||||
|
||||
// stock decremented
|
||||
assert_eq!(stock_of(&app, &sku_a).await, 3);
|
||||
assert_eq!(stock_of(&app, &sku_b).await, 4);
|
||||
|
||||
// cart cleared
|
||||
let res = client()
|
||||
.get(app.url("/api/cart"))
|
||||
.bearer_auth(&buyer)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.json::<serde_json::Value>().await.unwrap()["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn checkout_insufficient_stock_rolls_back() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let (_, _, _, sku) = setup_sellable(&app, &admin, "lowstock", 500, 1).await;
|
||||
let (buyer, _) = register_customer(&app, "buyer2").await;
|
||||
add_to_cart(&app, &buyer, &sku, 2).await;
|
||||
|
||||
let res = client()
|
||||
.post(app.url("/api/orders/checkout"))
|
||||
.bearer_auth(&buyer)
|
||||
.json(&serde_json::json!({
|
||||
"shipping_address": {
|
||||
"recipient": "R", "phone": "1", "country": "US", "region": "CA",
|
||||
"city": "SJ", "line1": "1 Way", "postal_code": "95131"
|
||||
},
|
||||
"currency": "USD"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 409);
|
||||
assert_eq!(stock_of(&app, &sku).await, 1, "stock unchanged");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn cancel_rules_and_stock_restore() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let (_, _, _, sku) = setup_sellable(&app, &admin, "cancelme", 800, 4).await;
|
||||
let (buyer, _) = register_customer(&app, "buyer3").await;
|
||||
add_to_cart(&app, &buyer, &sku, 2).await;
|
||||
let orders = checkout(&app, &buyer).await;
|
||||
let order_id = orders[0]["id"].as_str().unwrap();
|
||||
assert_eq!(stock_of(&app, &sku).await, 2);
|
||||
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/orders/{order_id}/cancel")))
|
||||
.bearer_auth(&buyer)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
assert_eq!(stock_of(&app, &sku).await, 4, "stock restored");
|
||||
|
||||
// paid orders cannot be cancelled
|
||||
add_to_cart(&app, &buyer, &sku, 1).await;
|
||||
let orders = checkout(&app, &buyer).await;
|
||||
let order_id = orders[0]["id"].as_str().unwrap();
|
||||
pay(&app, &buyer, order_id).await;
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/orders/{order_id}/cancel")))
|
||||
.bearer_auth(&buyer)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 409);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn fulfillment_flow_partial_then_complete() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let (owner, _, _, sku) = setup_sellable(&app, &admin, "fulfil", 1500, 10).await;
|
||||
let (buyer, _) = register_customer(&app, "buyer4").await;
|
||||
add_to_cart(&app, &buyer, &sku, 3).await;
|
||||
let orders = checkout(&app, &buyer).await;
|
||||
let order = &orders[0];
|
||||
let order_id = order["id"].as_str().unwrap();
|
||||
let item_id = order["items"][0]["id"].as_str().unwrap();
|
||||
pay(&app, &buyer, order_id).await;
|
||||
|
||||
// over-shipping the remainder is rejected
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/shop/orders/{order_id}/shipments")))
|
||||
.bearer_auth(&owner)
|
||||
.json(&serde_json::json!({
|
||||
"carrier": "UPS", "tracking_no": "T1",
|
||||
"items": [{ "order_item_id": item_id, "qty": 4 }]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 400);
|
||||
|
||||
// partial shipment of 2 → order fulfilling
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/shop/orders/{order_id}/shipments")))
|
||||
.bearer_auth(&owner)
|
||||
.json(&serde_json::json!({
|
||||
"carrier": "UPS", "tracking_no": "T1",
|
||||
"items": [{ "order_item_id": item_id, "qty": 2 }]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 201);
|
||||
let shipment1: serde_json::Value = res.json().await.unwrap();
|
||||
let shipment1_id = shipment1["id"].as_str().unwrap().to_string();
|
||||
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/shop/shipments/{shipment1_id}/ship")))
|
||||
.bearer_auth(&owner)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
let res = client()
|
||||
.get(app.url(&format!("/api/orders/{order_id}")))
|
||||
.bearer_auth(&buyer)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.json::<serde_json::Value>().await.unwrap()["status"], "fulfilling");
|
||||
|
||||
// second shipment covers remainder → shipped after mark
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/shop/orders/{order_id}/shipments")))
|
||||
.bearer_auth(&owner)
|
||||
.json(&serde_json::json!({
|
||||
"carrier": "UPS", "tracking_no": "T2",
|
||||
"items": [{ "order_item_id": item_id, "qty": 1 }]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let shipment2_id = res.json::<serde_json::Value>().await.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
client()
|
||||
.post(app.url(&format!("/api/shop/shipments/{shipment2_id}/ship")))
|
||||
.bearer_auth(&owner)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let res = client()
|
||||
.get(app.url(&format!("/api/orders/{order_id}")))
|
||||
.bearer_auth(&buyer)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.json::<serde_json::Value>().await.unwrap()["status"], "shipped");
|
||||
|
||||
// confirm both deliveries → completed
|
||||
for sid in [&shipment1_id, &shipment2_id] {
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/shipments/{sid}/confirm-delivered")))
|
||||
.bearer_auth(&buyer)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
}
|
||||
let res = client()
|
||||
.get(app.url(&format!("/api/orders/{order_id}")))
|
||||
.bearer_auth(&buyer)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.json::<serde_json::Value>().await.unwrap()["status"], "completed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn invoice_lifecycle() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let (owner, _, _, sku) = setup_sellable(&app, &admin, "invc", 3000, 2).await;
|
||||
let (buyer, _) = register_customer(&app, "buyer5").await;
|
||||
add_to_cart(&app, &buyer, &sku, 1).await;
|
||||
let orders = checkout(&app, &buyer).await;
|
||||
let order_id = orders[0]["id"].as_str().unwrap();
|
||||
|
||||
// pending_payment orders cannot be invoiced
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/orders/{order_id}/invoice")))
|
||||
.bearer_auth(&buyer)
|
||||
.json(&serde_json::json!({ "title": "Me", "kind": "personal" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 400);
|
||||
|
||||
pay(&app, &buyer, order_id).await;
|
||||
|
||||
// company invoice requires tax_no
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/orders/{order_id}/invoice")))
|
||||
.bearer_auth(&buyer)
|
||||
.json(&serde_json::json!({ "title": "ACME", "kind": "company" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 400);
|
||||
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/orders/{order_id}/invoice")))
|
||||
.bearer_auth(&buyer)
|
||||
.json(&serde_json::json!({ "title": "ACME", "tax_no": "US-123", "kind": "company" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 201);
|
||||
let invoice: serde_json::Value = res.json().await.unwrap();
|
||||
assert_eq!(invoice["status"], "requested");
|
||||
assert_eq!(invoice["amount_minor"], 3000);
|
||||
|
||||
// duplicate rejected
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/orders/{order_id}/invoice")))
|
||||
.bearer_auth(&buyer)
|
||||
.json(&serde_json::json!({ "title": "Again", "kind": "personal" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 409);
|
||||
|
||||
// shop issues it
|
||||
let invoice_id = invoice["id"].as_str().unwrap();
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/shop/invoices/{invoice_id}/issue")))
|
||||
.bearer_auth(&owner)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
let issued: serde_json::Value = res.json().await.unwrap();
|
||||
assert_eq!(issued["status"], "issued");
|
||||
assert!(issued["invoice_no"].as_str().unwrap().starts_with("INV"));
|
||||
|
||||
// customer sees issued invoice
|
||||
let res = client()
|
||||
.get(app.url("/api/invoices"))
|
||||
.bearer_auth(&buyer)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let invoices: serde_json::Value = res.json().await.unwrap();
|
||||
assert_eq!(invoices[0]["status"], "issued");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn order_ownership_enforced() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let (_, _, _, sku) = setup_sellable(&app, &admin, "ownerchk", 100, 1).await;
|
||||
let (buyer, _) = register_customer(&app, "buyer6").await;
|
||||
let (other, _) = register_customer(&app, "buyer7").await;
|
||||
add_to_cart(&app, &buyer, &sku, 1).await;
|
||||
let orders = checkout(&app, &buyer).await;
|
||||
let order_id = orders[0]["id"].as_str().unwrap();
|
||||
|
||||
let res = client()
|
||||
.get(app.url(&format!("/api/orders/{order_id}")))
|
||||
.bearer_auth(&other)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 404);
|
||||
}
|
||||
Reference in New Issue
Block a user