437 lines
16 KiB
Rust
437 lines
16 KiB
Rust
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");
|
|
}
|