Files
vmall/apps/api/tests/catalog.rs
T
james e0e833d0e5 feat(mall): serve catalog and currency from the live API
Wave 1 of replacing the fixed-data mock adapter. The mall now selects its API
adapter per domain, with catalog and currency served live while auth, cart,
orders, shipments and invoices stay on fixed data.

Backend:
- seed the 6 x 2 x 2 category tree as reference data (migration 0005). The API
  exposes no category write route, so this cannot come from the seed script
- filter public product listing by the category subtree with a recursive CTE,
  matching the mock's existing behaviour instead of exact-match
- add sort=price with order=asc|desc, validated by hand so an unsupported value
  returns the project's ApiError 400 shape rather than axum's own rejection

Mall:
- replace the all-or-nothing mockApi boolean with a liveDomains list composed
  through a typed per-domain pick map
- source home floors, the category menu, search and product detail from the
  catalog API; banners, promos, quick links, store card and comment/coupon
  content stay local display-only content
- drop the brand facet and the sales/comments sorts: no backend model backs them
- fix salesOf/commentCountOf, which parsed digits out of the product id and so
  rendered "NaN sold" for live UUID ids; they now hash the id

Seed: 24 products across 4 shops, idempotent on re-run.

Note: the mall defaults to a live catalog, so pnpm dev:mall now expects the API
to be running; set NUXT_PUBLIC_LIVE_DOMAINS to an empty array for all-mock work.

OpenSpec change: openspec/changes/replace-mock-api-wave-1
2026-09-17 15:15:25 +00:00

308 lines
10 KiB
Rust

mod common;
use common::{
category_id_by_slug, client, create_product_with_sku, create_product_with_sku_in_category,
create_shop, login_admin, make_shop_owner, publish_product, 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");
// midpoint rounds half-up (away from zero), not banker's rounding:
// 1999 minor USD ($19.99) -> JPY at rate 150 => 2998.5 -> 2999
let res = client()
.get(app.url("/api/currencies/convert?amount_minor=1999&from=USD&to=JPY"))
.send()
.await
.unwrap();
assert_eq!(res.json::<serde_json::Value>().await.unwrap()["amount_minor"], 2999);
// 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 category_subtree_listing_and_price_sort() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let shop_id = create_shop(&app, &admin, "shop-browse").await;
let owner = make_shop_owner(&app, &admin, &shop_id).await;
let electronics = category_id_by_slug(&app, "electronics").await;
let phones = category_id_by_slug(&app, "electronics-phones").await;
let flagship = category_id_by_slug(&app, "electronics-flagship").await;
let audio = category_id_by_slug(&app, "electronics-audio").await;
let fashion = category_id_by_slug(&app, "fashion").await;
// A grandchild, a sibling branch under the same root, and an unrelated root.
let (leaf, _) =
create_product_with_sku_in_category(&app, &owner, "leaf", 5000, 5, Some(&flagship)).await;
let (sibling, _) =
create_product_with_sku_in_category(&app, &owner, "sib", 1000, 5, Some(&audio)).await;
let (unrelated, _) =
create_product_with_sku_in_category(&app, &owner, "other", 3000, 5, Some(&fashion)).await;
for id in [&leaf, &sibling, &unrelated] {
publish_product(&app, &owner, id).await;
}
fn listed_ids(body: &serde_json::Value) -> Vec<String> {
body["items"]
.as_array()
.unwrap()
.iter()
.map(|p| p["id"].as_str().unwrap().to_string())
.collect()
}
// A root category must include its children's and grandchildren's products.
let res = client()
.get(app.url(&format!(
"/api/products?category_id={electronics}&shop_id={shop_id}&per_page=50"
)))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let body: serde_json::Value = res.json().await.unwrap();
let listed = listed_ids(&body);
assert!(listed.contains(&leaf), "grandchild product missing from root listing");
assert!(listed.contains(&sibling), "child product missing from root listing");
assert!(!listed.contains(&unrelated), "product from another root category leaked in");
assert_eq!(body["total"], 2, "total must count the subtree, not only the root");
// A mid-level category covers its own subtree and nothing else.
let res = client()
.get(app.url(&format!(
"/api/products?category_id={phones}&shop_id={shop_id}"
)))
.send()
.await
.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(listed_ids(&body), vec![leaf.clone()]);
// Price sort orders by the product's lowest active SKU price.
let res = client()
.get(app.url(&format!(
"/api/products?category_id={electronics}&shop_id={shop_id}&per_page=50&sort=price&order=asc"
)))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(listed_ids(&body), vec![sibling.clone(), leaf.clone()]);
let res = client()
.get(app.url(&format!(
"/api/products?category_id={electronics}&shop_id={shop_id}&per_page=50&sort=price&order=desc"
)))
.send()
.await
.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(listed_ids(&body), vec![leaf.clone(), sibling.clone()]);
// An unsupported sort is a client error rather than being silently ignored.
let res = client()
.get(app.url("/api/products?sort=bogus"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 400);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(body["error"]["code"], "BAD_REQUEST");
}
#[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));
}