feat(mall): restore real brand and sales facets

Wave 6, the last substantive piece of the mock-API migration. Wave 1 removed the
brand facet and the sales/comments sorts for want of a model; sales turn out to
be derivable from order_items and a brand model is a table plus a column.

- a `brands` table with a nullable `products.brand_id` and an ordered admin
  replace, mirroring categories and storefront content; a public `GET /api/brands`
  and a `brand_id` filter on the catalog, which the search page's facet uses
- `sold_count` per product, computed from `order_items` joined to orders that
  reached payment, so an abandoned or cancelled checkout cannot count as a sale.
  It is computed per read rather than stored, so it cannot drift from the orders
  that produced it
- `sort=sales` alongside `sort=price`; anything else is still a 400
- merchants can set a product's brand through the existing product upsert
- the review UI is gone: the card's review figure and the product detail page's
  reviews tab, summary and replies. There is no reviews model, and the mall
  attributed invented comments to named shoppers and showed a "good rate". The
  now-unreferenced fabrication helpers went with it (`salesOf`, `commentCountOf`,
  `commentsFor`, `commentStats`, `salesRankFor`, `productDetail`, `storeDetail`)

Two bugs found by checking rather than trusting: the fixed-data `listProducts`
had silently ignored `brand_id`, `sort` and `order`, so the restored facet
rendered but filtered nothing until the rollback check caught it; and the seed's
brand lookup read back through the shared `r` variable the product loop
reassigns, working once and then throwing.

Verified: 29 backend tests green including a new brand-and-sales case; all three
frontends build; searching filters by brand (24 to 6) and sorts by sales with
counts matching the API; a product page offers detail and after-sale tabs only,
with a real sold count; the fixed-data rollback filters by brand too.

OpenSpec change: openspec/changes/replace-mock-api-wave-6
This commit is contained in:
2026-09-17 17:35:28 +00:00
parent 65cea42da6
commit ce3e8db5b1
20 changed files with 487 additions and 193 deletions
+117 -2
View File
@@ -1,8 +1,9 @@
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,
add_to_cart, category_id_by_slug, checkout, client, create_product_full, create_product_with_sku,
create_product_with_sku_in_category, create_shop, login_admin, make_shop_owner, pay,
publish_product, register_customer, spawn_app,
};
use serial_test::serial;
@@ -274,6 +275,120 @@ async fn category_subtree_listing_and_price_sort() {
assert_eq!(body["error"]["code"], "BAD_REQUEST");
}
#[tokio::test]
#[serial]
async fn brand_filter_and_real_sales() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
// Brands are admin-managed reference data; replacing the list is idempotent.
let brands = serde_json::json!([
{"slug": "alpha", "name": {"en": "Alpha", "zh": "阿尔法"}},
{"slug": "beta", "name": {"en": "Beta", "zh": "贝塔"}}
]);
let res = client()
.put(app.url("/api/admin/brands"))
.bearer_auth(&admin)
.json(&brands)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{:?}", res.text().await);
let list: serde_json::Value = res.json().await.unwrap();
let alpha = list[0]["id"].as_str().unwrap().to_string();
let beta = list[1]["id"].as_str().unwrap().to_string();
let shop_id = create_shop(&app, &admin, "shop-brands").await;
let owner = make_shop_owner(&app, &admin, &shop_id).await;
let (p_alpha, _) =
create_product_full(&app, &owner, "branded-a", 1000, 10, None, Some(&alpha)).await;
let (p_beta, _) =
create_product_full(&app, &owner, "branded-b", 2000, 10, None, Some(&beta)).await;
publish_product(&app, &owner, &p_alpha).await;
publish_product(&app, &owner, &p_beta).await;
let ids = |body: &serde_json::Value| -> Vec<String> {
body["items"]
.as_array()
.unwrap()
.iter()
.map(|p| p["id"].as_str().unwrap().to_string())
.collect()
};
let sold = |body: &serde_json::Value, id: &str| -> i64 {
body["items"]
.as_array()
.unwrap()
.iter()
.find(|p| p["id"] == id)
.unwrap()["sold_count"]
.as_i64()
.unwrap()
};
// The brand filter narrows within the shop.
let res = client()
.get(app.url(&format!(
"/api/products?shop_id={shop_id}&brand_id={alpha}&per_page=50"
)))
.send()
.await
.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(ids(&body), vec![p_alpha.clone()]);
assert_eq!(body["total"], 1);
// An unpaid order is not a sale.
let sku_alpha: String =
sqlx::query_scalar("SELECT id::text FROM skus WHERE product_id = $1::uuid")
.bind(&p_alpha)
.fetch_one(&app.db)
.await
.unwrap();
let (buyer, _) = register_customer(&app, "brand-buyer").await;
add_to_cart(&app, &buyer, &sku_alpha, 3).await;
let orders = checkout(&app, &buyer).await;
let res = client()
.get(app.url(&format!("/api/products?shop_id={shop_id}&per_page=50")))
.send()
.await
.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(sold(&body, &p_alpha), 0, "pending payment must not count as sold");
assert_eq!(sold(&body, &p_beta), 0);
// Paying makes the units count, and the sales sort follows them.
for order in &orders {
pay(&app, &buyer, order["id"].as_str().unwrap()).await;
}
let res = client()
.get(app.url(&format!("/api/products?shop_id={shop_id}&per_page=50")))
.send()
.await
.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(sold(&body, &p_alpha), 3);
let res = client()
.get(app.url(&format!(
"/api/products?shop_id={shop_id}&sort=sales&order=desc&per_page=50"
)))
.send()
.await
.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(ids(&body)[0], p_alpha, "sales sort puts the sold product first");
// Comments still have no model, so that sort stays refused.
let res = client()
.get(app.url("/api/products?sort=comments"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 400);
}
#[tokio::test]
#[serial]
async fn suspended_shop_hidden_from_public_catalog() {
+15 -1
View File
@@ -150,7 +150,7 @@ pub async fn create_product_with_sku(
price_minor: i64,
stock: i32,
) -> (String, String) {
create_product_with_sku_in_category(app, owner_token, slug, price_minor, stock, None).await
create_product_full(app, owner_token, slug, price_minor, stock, None, None).await
}
/// Same, but placed in `category_id` so category filtering can be exercised.
@@ -161,6 +161,19 @@ pub async fn create_product_with_sku_in_category(
price_minor: i64,
stock: i32,
category_id: Option<&str>,
) -> (String, String) {
create_product_full(app, owner_token, slug, price_minor, stock, category_id, None).await
}
/// Same, with a category and a brand so both filters can be exercised.
pub async fn create_product_full(
app: &TestApp,
owner_token: &str,
slug: &str,
price_minor: i64,
stock: i32,
category_id: Option<&str>,
brand_id: Option<&str>,
) -> (String, String) {
let slug = format!("{slug}-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
let res = client()
@@ -171,6 +184,7 @@ pub async fn create_product_with_sku_in_category(
"name": {"en": format!("Product {slug}"), "zh": format!("商品 {slug}")},
"description": {"en": "desc en", "zh": "描述"},
"category_id": category_id,
"brand_id": brand_id,
}))
.send()
.await