feat(freight): shop freight templates, server-side checkout fees, company dictionary (add-freight-templates)

This commit is contained in:
Chengdong Zhang
2026-09-24 15:03:51 +08:00
parent 5b426486ac
commit 473d19d089
44 changed files with 2409 additions and 67 deletions
+1
View File
@@ -16,6 +16,7 @@ impl TestApp {
}
pub async fn spawn_state() -> vmall_api::state::AppState {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let config = Config {
database_url: TEST_DB_URL.into(),
redis_url: TEST_REDIS_URL.into(),
+436
View File
@@ -0,0 +1,436 @@
mod common;
use common::{
checkout, client, create_shop, login_admin, make_shop_owner, register_customer, setup_sellable,
spawn_app, TestApp,
};
use serial_test::serial;
/// Freight suite: templates, calculation boundaries, quote, and ship-company
/// dictionary. Each test builds its own shop/products and asserts only on them.
async fn create_template(
app: &TestApp,
owner: &str,
body: serde_json::Value,
) -> reqwest::Response {
client()
.post(app.url("/api/shop/freight-templates"))
.bearer_auth(owner)
.json(&body)
.send()
.await
.unwrap()
}
fn by_piece_body(name: &str, first_fee: i64, additional_fee: i64) -> serde_json::Value {
serde_json::json!({
"name": name,
"pricing_method": "by_piece",
"first_fee_minor": first_fee,
"first_unit": 1,
"additional_fee_minor": additional_fee,
"additional_unit": 1,
})
}
#[tokio::test]
#[serial]
async fn template_crud_is_shop_scoped() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let shop_a = create_shop(&app, &admin, "ft-scope-a").await;
let shop_b = create_shop(&app, &admin, "ft-scope-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 res = create_template(&app, &owner_a, by_piece_body("Standard", 500, 100)).await;
assert_eq!(res.status(), 201, "{:?}", res.text().await);
let created: serde_json::Value = res.json().await.unwrap();
let id = created["id"].as_str().unwrap().to_string();
assert_eq!(created["region_rules"].as_array().unwrap().len(), 0);
// Owner A sees it; owner B cannot touch it.
let list: serde_json::Value = client()
.get(app.url("/api/shop/freight-templates"))
.bearer_auth(&owner_a)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(list.as_array().unwrap().iter().any(|t| t["id"] == id));
let res = client()
.put(app.url(&format!("/api/shop/freight-templates/{id}")))
.bearer_auth(&owner_b)
.json(&by_piece_body("Hijack", 1, 1))
.send()
.await
.unwrap();
assert_eq!(res.status(), 404);
let res = client()
.delete(app.url(&format!("/api/shop/freight-templates/{id}")))
.bearer_auth(&owner_b)
.send()
.await
.unwrap();
assert_eq!(res.status(), 404);
// Delete works for the owner.
let res = client()
.delete(app.url(&format!("/api/shop/freight-templates/{id}")))
.bearer_auth(&owner_a)
.send()
.await
.unwrap();
assert_eq!(res.status(), 204);
}
#[tokio::test]
#[serial]
async fn only_one_default_template_per_shop() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let shop_id = create_shop(&app, &admin, "ft-default").await;
let owner = make_shop_owner(&app, &admin, &shop_id).await;
let mut a = by_piece_body("A", 100, 100);
a["is_default"] = serde_json::json!(true);
assert_eq!(create_template(&app, &owner, a).await.status(), 201);
let mut b = by_piece_body("B", 200, 200);
b["is_default"] = serde_json::json!(true);
let res = create_template(&app, &owner, b).await;
assert_eq!(res.status(), 201, "{:?}", res.text().await);
let list: serde_json::Value = client()
.get(app.url("/api/shop/freight-templates"))
.bearer_auth(&owner)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let defaults = list
.as_array()
.unwrap()
.iter()
.filter(|t| t["is_default"] == true)
.count();
assert_eq!(defaults, 1, "exactly one default survives");
}
/// Buy `qty` of a SKU whose shop has `template` as default; returns the order.
async fn order_with_template(
app: &TestApp,
label: &str,
price_minor: i64,
qty: i32,
template: serde_json::Value,
) -> (String, serde_json::Value) {
let admin = login_admin(app).await;
let (owner, _shop, _product, sku_id) = setup_sellable(app, &admin, label, price_minor, 100).await;
let res = create_template(app, &owner, template).await;
assert_eq!(res.status(), 201, "{:?}", res.text().await);
let (customer, _) = register_customer(app, label).await;
common::add_to_cart(app, &customer, &sku_id, qty).await;
let orders = checkout(app, &customer).await;
(customer, orders.into_iter().next().unwrap())
}
#[tokio::test]
#[serial]
async fn by_piece_fee_merges_quantities_and_rounds_up() {
let app = spawn_app().await;
// first unit 1 @500, additional unit 2 pieces @300
let mut t = by_piece_body("merged", 500, 300);
t["is_default"] = serde_json::json!(true);
t["additional_unit"] = serde_json::json!(2);
let (_customer, order) = order_with_template(&app, "ft-merge", 1000, 5, t).await;
// 5 pieces: 500 + ceil(4/2)*300 = 1100
assert_eq!(order["shipping_fee_minor"], 1100);
assert_eq!(order["total_minor"], 5 * 1000 + 1100);
}
#[tokio::test]
#[serial]
async fn free_threshold_and_always_free_win() {
let app = spawn_app().await;
// Threshold 2000 met by 3x1000 -> fee 0.
let mut t = by_piece_body("threshold", 500, 300);
t["is_default"] = serde_json::json!(true);
t["free_threshold_minor"] = serde_json::json!(2000);
let (_c, order) = order_with_template(&app, "ft-free", 1000, 3, t).await;
assert_eq!(order["shipping_fee_minor"], 0);
assert_eq!(order["total_minor"], 3000);
// Same template, subtotal below threshold -> fee applies.
let mut t2 = by_piece_body("threshold2", 500, 300);
t2["is_default"] = serde_json::json!(true);
t2["free_threshold_minor"] = serde_json::json!(5000);
let (_c2, order2) = order_with_template(&app, "ft-free2", 1000, 1, t2).await;
assert_eq!(order2["shipping_fee_minor"], 500);
// always_free beats everything.
let mut t3 = by_piece_body("free", 999, 999);
t3["is_default"] = serde_json::json!(true);
t3["always_free"] = serde_json::json!(true);
let (_c3, order3) = order_with_template(&app, "ft-always", 1000, 4, t3).await;
assert_eq!(order3["shipping_fee_minor"], 0);
}
#[tokio::test]
#[serial]
async fn region_rule_overrides_default_fees() {
let app = spawn_app().await;
let mut t = by_piece_body("regions", 500, 100);
t["is_default"] = serde_json::json!(true);
// The checkout fixture address has region "CA"; rule matches it.
t["region_rules"] = serde_json::json!([{
"regions": ["CA"],
"first_fee_minor": 900,
"first_unit": 1,
"additional_fee_minor": 50,
"additional_unit": 1
}]);
let (_c, order) = order_with_template(&app, "ft-region", 1000, 3, t).await;
// 900 + 2*50 = 1000 via the rule, not 500 + 2*100 = 700.
assert_eq!(order["shipping_fee_minor"], 1000);
}
#[tokio::test]
#[serial]
async fn by_weight_merges_grams() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (owner, _shop, product_id, sku_id) = setup_sellable(&app, &admin, "ft-weight", 1000, 100).await;
// 250g per unit via SKU update.
sqlx::query("UPDATE skus SET weight_grams = 250 WHERE id = $1::uuid")
.bind(&sku_id)
.execute(&app.db)
.await
.unwrap();
let _ = product_id;
// first 500g @800, additional 500g @400 (partial additional rounds up).
let mut t = by_piece_body("weight", 800, 400);
t["pricing_method"] = serde_json::json!("by_weight");
t["first_unit"] = serde_json::json!(500);
t["additional_unit"] = serde_json::json!(500);
t["is_default"] = serde_json::json!(true);
assert_eq!(create_template(&app, &owner, t).await.status(), 201);
let (customer, _) = register_customer(&app, "ft-weight").await;
common::add_to_cart(&app, &customer, &sku_id, 5).await;
let orders = checkout(&app, &customer).await;
let order = &orders[0];
// 5 * 250g = 1250g: 800 + ceil(750/500)*400 = 1600.
assert_eq!(order["shipping_fee_minor"], 1600);
}
#[tokio::test]
#[serial]
async fn quote_matches_checkout_and_no_template_is_zero() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (owner, _shop, _product, sku_id) = setup_sellable(&app, &admin, "ft-quote", 1000, 100).await;
let mut t = by_piece_body("quoted", 500, 100);
t["is_default"] = serde_json::json!(true);
assert_eq!(create_template(&app, &owner, t).await.status(), 201);
// Second shop without any template ships free.
let (_o2, _s2, _p2, sku2) = setup_sellable(&app, &admin, "ft-quote-b", 2000, 100).await;
let (customer, _) = register_customer(&app, "ft-quote").await;
common::add_to_cart(&app, &customer, &sku_id, 3).await;
common::add_to_cart(&app, &customer, &sku2, 1).await;
let res = client()
.post(app.url("/api/orders/shipping-quote"))
.bearer_auth(&customer)
.json(&serde_json::json!({
"shipping_address": {
"recipient": "Q", "phone": "1", "country": "US", "region": "CA",
"city": "San Jose", "line1": "1 Way", "postal_code": "95131"
},
"currency": "USD"
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{:?}", res.text().await);
let quote: serde_json::Value = res.json().await.unwrap();
// Templated shop: 500 + 2*100 = 700; template-less shop: 0.
assert_eq!(quote["total_minor"], 700);
let shops = quote["shops"].as_array().unwrap();
assert_eq!(shops.len(), 2);
assert!(shops.iter().any(|s| s["fee_minor"] == 700));
assert!(shops.iter().any(|s| s["fee_minor"] == 0));
let orders = checkout(&app, &customer).await;
let by_shop: std::collections::HashMap<String, i64> = orders
.iter()
.map(|o| {
(
o["shop_id"].as_str().unwrap().to_string(),
o["shipping_fee_minor"].as_i64().unwrap(),
)
})
.collect();
assert!(by_shop.values().any(|f| *f == 700));
assert!(by_shop.values().any(|f| *f == 0));
}
#[tokio::test]
#[serial]
async fn product_template_link_and_cross_shop_rejection() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let shop_a = create_shop(&app, &admin, "ft-link-a").await;
let shop_b = create_shop(&app, &admin, "ft-link-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 res = create_template(&app, &owner_b, by_piece_body("B tpl", 700, 100)).await;
let tpl_b = res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
// Shop A cannot point a product at shop B's template.
let res = client()
.post(app.url("/api/shop/products"))
.bearer_auth(&owner_a)
.json(&serde_json::json!({
"slug": format!("ft-link-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]),
"name": {"en": "Linked", "zh": "关联"},
"freight_template_id": tpl_b
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 400, "cross-shop template must be rejected");
// Own template links fine and wins over the shop default at checkout.
let res = create_template(&app, &owner_a, by_piece_body("A default", 100, 100)).await;
let mut default_tpl = res.json::<serde_json::Value>().await.unwrap();
default_tpl["is_default"] = serde_json::json!(true);
let default_id = default_tpl["id"].as_str().unwrap().to_string();
assert_eq!(
client()
.put(app.url(&format!("/api/shop/freight-templates/{default_id}")))
.bearer_auth(&owner_a)
.json(&serde_json::json!({
"name": "A default", "is_default": true, "pricing_method": "by_piece",
"first_fee_minor": 100, "first_unit": 1, "additional_fee_minor": 100,
"additional_unit": 1
}))
.send()
.await
.unwrap()
.status(),
200
);
let res = create_template(&app, &owner_a, by_piece_body("A product tpl", 900, 900)).await;
let product_tpl = res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let (product_id, slug) =
common::create_product_with_sku(&app, &owner_a, "ft-link-own", 1000, 50).await;
let res = client()
.put(app.url(&format!("/api/shop/products/{product_id}")))
.bearer_auth(&owner_a)
.json(&serde_json::json!({
"slug": slug,
"name": {"en": "Linked", "zh": "关联"},
"freight_template_id": product_tpl
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{:?}", res.text().await);
common::publish_product(&app, &owner_a, &product_id).await;
let sku_id: String = sqlx::query_scalar("SELECT id::text FROM skus WHERE product_id = $1::uuid")
.bind(&product_id)
.fetch_one(&app.db)
.await
.unwrap();
let (customer, _) = register_customer(&app, "ft-link-cust").await;
common::add_to_cart(&app, &customer, &sku_id, 1).await;
let orders = checkout(&app, &customer).await;
// Product template (900) wins over the shop default (100).
assert_eq!(orders[0]["shipping_fee_minor"], 900);
// The snapshot rides on the order item.
let detail: serde_json::Value = client()
.get(app.url(&format!("/api/orders/{}", orders[0]["id"].as_str().unwrap())))
.bearer_auth(&customer)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(detail["items"][0]["freight_pricing_method"], "by_piece");
}
#[tokio::test]
#[serial]
async fn shipments_use_the_company_dictionary() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (owner, _shop, _product, sku_id) = setup_sellable(&app, &admin, "ft-ship", 1000, 10).await;
let (customer, _) = register_customer(&app, "ft-ship").await;
common::add_to_cart(&app, &customer, &sku_id, 1).await;
let orders = checkout(&app, &customer).await;
let order = &orders[0];
common::pay(&app, &customer, order["id"].as_str().unwrap()).await;
let detail: serde_json::Value = client()
.get(app.url(&format!("/api/shop/orders/{}", order["id"].as_str().unwrap())))
.bearer_auth(&owner)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let item_id = detail["items"][0]["id"].as_str().unwrap().to_string();
// Dictionary endpoint is public.
let res = client().get(app.url("/api/shipping/companies")).send().await.unwrap();
assert_eq!(res.status(), 200);
let companies: serde_json::Value = res.json().await.unwrap();
assert!(companies.as_array().unwrap().iter().any(|c| c["code"] == "sf-express"));
// Unknown company is rejected.
let res = client()
.post(app.url(&format!("/api/shop/orders/{}/shipments", order["id"].as_str().unwrap())))
.bearer_auth(&owner)
.json(&serde_json::json!({
"carrier": "SF", "tracking_no": "SF123", "shipping_company_code": "nope",
"items": [{"order_item_id": item_id, "qty": 1}]
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 400);
// Dictionary company is recorded.
let res = client()
.post(app.url(&format!("/api/shop/orders/{}/shipments", order["id"].as_str().unwrap())))
.bearer_auth(&owner)
.json(&serde_json::json!({
"carrier": "SF", "tracking_no": "SF123", "shipping_company_code": "sf-express",
"items": [{"order_item_id": item_id, "qty": 1}]
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "{:?}", res.text().await);
assert_eq!(res.json::<serde_json::Value>().await.unwrap()["shipping_company_code"], "sf-express");
}