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, }; 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::().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::().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::().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 { 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 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)); }