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
423 lines
14 KiB
Rust
423 lines
14 KiB
Rust
mod common;
|
|
|
|
use common::{
|
|
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;
|
|
|
|
#[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 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() {
|
|
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));
|
|
}
|