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:
@@ -0,0 +1,95 @@
|
||||
-- Reference taxonomy: extend the three top-level categories seeded in
|
||||
-- 0003_catalog.sql into the full 6 x 2 x 2 tree the storefront is designed
|
||||
-- around (six pinned sidebar rows, six home floors, two grandchildren per
|
||||
-- child link).
|
||||
--
|
||||
-- Categories are reference data, not demo data: `GET /categories` is the only
|
||||
-- category route (there is no write endpoint), so the seed script cannot create
|
||||
-- them, and shop-admin needs a real tree for its category picker.
|
||||
|
||||
-- New top-level categories (positions 4-6 continue 0003's 1-3).
|
||||
INSERT INTO categories (parent_id, name, slug, position)
|
||||
VALUES
|
||||
(NULL, '{"en": "Computers & Office", "zh": "电脑办公"}', 'computers-office', 4),
|
||||
(NULL, '{"en": "Beauty & Care", "zh": "美妆个护"}', 'beauty-care', 5),
|
||||
(NULL, '{"en": "Grocery & Fresh", "zh": "食品生鲜"}', 'grocery-fresh', 6);
|
||||
|
||||
-- Second level: two children under every top-level category.
|
||||
INSERT INTO categories (parent_id, name, slug, position)
|
||||
VALUES
|
||||
((SELECT id FROM categories WHERE slug = 'electronics'),
|
||||
'{"en": "Phones & Accessories", "zh": "手机与配件"}', 'electronics-phones', 1),
|
||||
((SELECT id FROM categories WHERE slug = 'electronics'),
|
||||
'{"en": "Audio", "zh": "影音娱乐"}', 'electronics-audio', 2),
|
||||
((SELECT id FROM categories WHERE slug = 'fashion'),
|
||||
'{"en": "Menswear", "zh": "男装"}', 'fashion-menswear', 1),
|
||||
((SELECT id FROM categories WHERE slug = 'fashion'),
|
||||
'{"en": "Bags & Luggage", "zh": "箱包"}', 'fashion-bags', 2),
|
||||
((SELECT id FROM categories WHERE slug = 'home-living'),
|
||||
'{"en": "Kitchen", "zh": "厨房电器"}', 'home-kitchen', 1),
|
||||
((SELECT id FROM categories WHERE slug = 'home-living'),
|
||||
'{"en": "Cleaning", "zh": "清洁电器"}', 'home-cleaning', 2),
|
||||
((SELECT id FROM categories WHERE slug = 'computers-office'),
|
||||
'{"en": "Laptops", "zh": "笔记本电脑"}', 'computers-laptops', 1),
|
||||
((SELECT id FROM categories WHERE slug = 'computers-office'),
|
||||
'{"en": "Peripherals", "zh": "外设产品"}', 'computers-peripherals', 2),
|
||||
((SELECT id FROM categories WHERE slug = 'beauty-care'),
|
||||
'{"en": "Skincare", "zh": "面部护肤"}', 'beauty-skincare', 1),
|
||||
((SELECT id FROM categories WHERE slug = 'beauty-care'),
|
||||
'{"en": "Grooming", "zh": "个人护理"}', 'beauty-grooming', 2),
|
||||
((SELECT id FROM categories WHERE slug = 'grocery-fresh'),
|
||||
'{"en": "Snacks", "zh": "休闲零食"}', 'grocery-snacks', 1),
|
||||
((SELECT id FROM categories WHERE slug = 'grocery-fresh'),
|
||||
'{"en": "Fresh Produce", "zh": "生鲜果蔬"}', 'grocery-produce', 2);
|
||||
|
||||
-- Third level: two grandchildren under every second-level category.
|
||||
INSERT INTO categories (parent_id, name, slug, position)
|
||||
VALUES
|
||||
((SELECT id FROM categories WHERE slug = 'electronics-phones'),
|
||||
'{"en": "Flagship Phones", "zh": "旗舰机型"}', 'electronics-flagship', 1),
|
||||
((SELECT id FROM categories WHERE slug = 'electronics-phones'),
|
||||
'{"en": "Budget Phones", "zh": "千元机"}', 'electronics-budget', 2),
|
||||
((SELECT id FROM categories WHERE slug = 'electronics-audio'),
|
||||
'{"en": "Earbuds", "zh": "真无线耳机"}', 'electronics-earbuds', 1),
|
||||
((SELECT id FROM categories WHERE slug = 'electronics-audio'),
|
||||
'{"en": "Speakers", "zh": "蓝牙音箱"}', 'electronics-speakers', 2),
|
||||
((SELECT id FROM categories WHERE slug = 'fashion-menswear'),
|
||||
'{"en": "Jackets", "zh": "夹克外套"}', 'fashion-jackets', 1),
|
||||
((SELECT id FROM categories WHERE slug = 'fashion-menswear'),
|
||||
'{"en": "Shirts", "zh": "衬衫"}', 'fashion-shirts', 2),
|
||||
((SELECT id FROM categories WHERE slug = 'fashion-bags'),
|
||||
'{"en": "Backpacks", "zh": "双肩包"}', 'fashion-backpacks', 1),
|
||||
((SELECT id FROM categories WHERE slug = 'fashion-bags'),
|
||||
'{"en": "Luggage", "zh": "旅行箱"}', 'fashion-luggage', 2),
|
||||
((SELECT id FROM categories WHERE slug = 'home-kitchen'),
|
||||
'{"en": "Cookers", "zh": "电饭煲"}', 'home-cookers', 1),
|
||||
((SELECT id FROM categories WHERE slug = 'home-kitchen'),
|
||||
'{"en": "Blenders", "zh": "破壁机"}', 'home-blenders', 2),
|
||||
((SELECT id FROM categories WHERE slug = 'home-cleaning'),
|
||||
'{"en": "Vacuums", "zh": "吸尘器"}', 'home-vacuums', 1),
|
||||
((SELECT id FROM categories WHERE slug = 'home-cleaning'),
|
||||
'{"en": "Air Purifiers", "zh": "空气净化器"}', 'home-purifiers', 2),
|
||||
((SELECT id FROM categories WHERE slug = 'computers-laptops'),
|
||||
'{"en": "Ultrabooks", "zh": "轻薄本"}', 'computers-ultrabooks', 1),
|
||||
((SELECT id FROM categories WHERE slug = 'computers-laptops'),
|
||||
'{"en": "Gaming Laptops", "zh": "游戏本"}', 'computers-gaming', 2),
|
||||
((SELECT id FROM categories WHERE slug = 'computers-peripherals'),
|
||||
'{"en": "Keyboards", "zh": "键盘"}', 'computers-keyboards', 1),
|
||||
((SELECT id FROM categories WHERE slug = 'computers-peripherals'),
|
||||
'{"en": "Monitors", "zh": "显示器"}', 'computers-monitors', 2),
|
||||
((SELECT id FROM categories WHERE slug = 'beauty-skincare'),
|
||||
'{"en": "Serums", "zh": "精华"}', 'beauty-serums', 1),
|
||||
((SELECT id FROM categories WHERE slug = 'beauty-skincare'),
|
||||
'{"en": "Creams", "zh": "面霜"}', 'beauty-creams', 2),
|
||||
((SELECT id FROM categories WHERE slug = 'beauty-grooming'),
|
||||
'{"en": "Shavers", "zh": "剃须刀"}', 'beauty-shavers', 1),
|
||||
((SELECT id FROM categories WHERE slug = 'beauty-grooming'),
|
||||
'{"en": "Hair Care", "zh": "洗发护发"}', 'beauty-haircare', 2),
|
||||
((SELECT id FROM categories WHERE slug = 'grocery-snacks'),
|
||||
'{"en": "Nuts", "zh": "坚果"}', 'grocery-nuts', 1),
|
||||
((SELECT id FROM categories WHERE slug = 'grocery-snacks'),
|
||||
'{"en": "Chocolate", "zh": "巧克力"}', 'grocery-chocolate', 2),
|
||||
((SELECT id FROM categories WHERE slug = 'grocery-produce'),
|
||||
'{"en": "Fruit", "zh": "水果"}', 'grocery-fruit', 1),
|
||||
((SELECT id FROM categories WHERE slug = 'grocery-produce'),
|
||||
'{"en": "Vegetables", "zh": "蔬菜"}', 'grocery-vegetables', 2);
|
||||
@@ -64,8 +64,50 @@ struct ListQuery {
|
||||
category_id: Option<Uuid>,
|
||||
shop_id: Option<Uuid>,
|
||||
q: Option<String>,
|
||||
sort: Option<String>,
|
||||
order: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SortBy {
|
||||
Newest,
|
||||
Price,
|
||||
}
|
||||
|
||||
impl ListQuery {
|
||||
/// Only `price` is a supported sort; anything else is a client error rather
|
||||
/// than being silently ignored. An absent `sort` keeps newest-first.
|
||||
fn sort_by(&self) -> ApiResult<SortBy> {
|
||||
match self.sort.as_deref() {
|
||||
None => Ok(SortBy::Newest),
|
||||
Some("price") => Ok(SortBy::Price),
|
||||
Some(other) => Err(ApiError::BadRequest(format!("unsupported sort: {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn ascending(&self) -> ApiResult<bool> {
|
||||
match self.order.as_deref() {
|
||||
None | Some("asc") => Ok(true),
|
||||
Some("desc") => Ok(false),
|
||||
Some(other) => Err(ApiError::BadRequest(format!("unsupported order: {other}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the requested category to itself plus every descendant, so a parent
|
||||
/// category lists its children's and grandchildren's products too. Shared by the
|
||||
/// count and page queries so `total` cannot drift from `items`.
|
||||
const SUBTREE_CTE: &str = "WITH RECURSIVE subtree AS (
|
||||
SELECT id FROM categories WHERE id = $1::uuid
|
||||
UNION ALL
|
||||
SELECT c.id FROM categories c JOIN subtree st ON c.parent_id = st.id
|
||||
) ";
|
||||
|
||||
/// Lowest active SKU price, used only when sorting by price. Products without a
|
||||
/// sellable SKU sort last in both directions.
|
||||
const MIN_PRICE: &str =
|
||||
"(SELECT MIN(price_minor) FROM skus WHERE product_id = p.id AND active = TRUE)";
|
||||
|
||||
/// Public catalog: only published products of active shops.
|
||||
async fn list_products(
|
||||
State(state): State<AppState>,
|
||||
@@ -74,27 +116,39 @@ async fn list_products(
|
||||
let page = clamp_page(q.page);
|
||||
let per_page = clamp_per_page(q.per_page);
|
||||
let pattern = q.q.as_ref().map(|s| format!("%{s}%"));
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM products p JOIN shops s ON s.id = p.shop_id
|
||||
let sort_by = q.sort_by()?;
|
||||
let ascending = q.ascending()?;
|
||||
|
||||
let total: i64 = sqlx::query_scalar(&format!(
|
||||
"{SUBTREE_CTE}
|
||||
SELECT count(*) FROM products p JOIN shops s ON s.id = p.shop_id
|
||||
WHERE p.status = 'published' AND s.status = 'active'
|
||||
AND ($1::uuid IS NULL OR p.category_id = $1)
|
||||
AND ($1::uuid IS NULL OR p.category_id IN (SELECT id FROM subtree))
|
||||
AND ($2::uuid IS NULL OR p.shop_id = $2)
|
||||
AND ($3::text IS NULL OR p.name::text ILIKE $3)",
|
||||
)
|
||||
AND ($3::text IS NULL OR p.name::text ILIKE $3)"
|
||||
))
|
||||
.bind(q.category_id)
|
||||
.bind(q.shop_id)
|
||||
.bind(&pattern)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let products = sqlx::query_as::<_, Product>(
|
||||
"SELECT p.* FROM products p JOIN shops s ON s.id = p.shop_id
|
||||
|
||||
// Interpolates only from the validated SortBy/order pair, never from input.
|
||||
let order_clause = match (sort_by, ascending) {
|
||||
(SortBy::Newest, _) => "p.created_at DESC".to_string(),
|
||||
(SortBy::Price, true) => format!("{MIN_PRICE} ASC NULLS LAST, p.created_at DESC"),
|
||||
(SortBy::Price, false) => format!("{MIN_PRICE} DESC NULLS LAST, p.created_at DESC"),
|
||||
};
|
||||
let products = sqlx::query_as::<_, Product>(&format!(
|
||||
"{SUBTREE_CTE}
|
||||
SELECT p.* FROM products p JOIN shops s ON s.id = p.shop_id
|
||||
WHERE p.status = 'published' AND s.status = 'active'
|
||||
AND ($1::uuid IS NULL OR p.category_id = $1)
|
||||
AND ($1::uuid IS NULL OR p.category_id IN (SELECT id FROM subtree))
|
||||
AND ($2::uuid IS NULL OR p.shop_id = $2)
|
||||
AND ($3::text IS NULL OR p.name::text ILIKE $3)
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $4 OFFSET $5",
|
||||
)
|
||||
ORDER BY {order_clause}
|
||||
LIMIT $4 OFFSET $5"
|
||||
))
|
||||
.bind(q.category_id)
|
||||
.bind(q.shop_id)
|
||||
.bind(&pattern)
|
||||
@@ -102,6 +156,7 @@ async fn list_products(
|
||||
.bind((page - 1) * per_page)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let items = attach_skus(&state.db, products, true).await?;
|
||||
Ok(Json(Paged {
|
||||
items,
|
||||
|
||||
@@ -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