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