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
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
mod common;
|
||||
|
||||
use common::{
|
||||
client, create_product_with_sku, create_shop, login_admin, make_shop_owner, register_customer,
|
||||
spawn_app,
|
||||
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;
|
||||
|
||||
@@ -180,6 +180,100 @@ async fn currency_conversion_math() {
|
||||
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() {
|
||||
|
||||
@@ -149,6 +149,18 @@ pub async fn create_product_with_sku(
|
||||
slug: &str,
|
||||
price_minor: i64,
|
||||
stock: i32,
|
||||
) -> (String, String) {
|
||||
create_product_with_sku_in_category(app, owner_token, slug, price_minor, stock, None).await
|
||||
}
|
||||
|
||||
/// Same, but placed in `category_id` so category filtering can be exercised.
|
||||
pub async fn create_product_with_sku_in_category(
|
||||
app: &TestApp,
|
||||
owner_token: &str,
|
||||
slug: &str,
|
||||
price_minor: i64,
|
||||
stock: i32,
|
||||
category_id: Option<&str>,
|
||||
) -> (String, String) {
|
||||
let slug = format!("{slug}-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
|
||||
let res = client()
|
||||
@@ -158,6 +170,7 @@ pub async fn create_product_with_sku(
|
||||
"slug": slug,
|
||||
"name": {"en": format!("Product {slug}"), "zh": format!("商品 {slug}")},
|
||||
"description": {"en": "desc en", "zh": "描述"},
|
||||
"category_id": category_id,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
@@ -183,6 +196,36 @@ pub async fn create_product_with_sku(
|
||||
(product_id, slug)
|
||||
}
|
||||
|
||||
/// Publish a draft product so it appears in the public catalog.
|
||||
pub async fn publish_product(app: &TestApp, owner_token: &str, product_id: &str) {
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/shop/products/{product_id}/publish")))
|
||||
.bearer_auth(owner_token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200, "publish: {:?}", res.text().await);
|
||||
}
|
||||
|
||||
/// Look up a seeded reference category id by slug.
|
||||
pub async fn category_id_by_slug(app: &TestApp, slug: &str) -> String {
|
||||
let res = client()
|
||||
.get(app.url("/api/categories"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
let cats: serde_json::Value = res.json().await.unwrap();
|
||||
cats.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c["slug"] == slug)
|
||||
.unwrap_or_else(|| panic!("seeded category {slug} missing"))["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Full sellable fixture: shop + owner + published product with one SKU.
|
||||
/// Returns (owner_token, shop_id, product_id, sku_id).
|
||||
pub async fn setup_sellable(
|
||||
|
||||
Reference in New Issue
Block a user