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 = 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::().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::().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::().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::().await.unwrap()["shipping_company_code"], "sf-express"); }