mod common; use common::{ client, create_product_with_sku, create_shop, login_admin, make_shop_owner, 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 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)); }