mod common; use common::{ checkout, client, create_shop, login_admin, make_shop_owner, pay, register_customer, setup_sellable, spawn_app, TestApp, }; use serial_test::serial; /// Review suite: one completed order line per test unless stated otherwise. /// A paid→shipped→completed order; returns (customer, order_id, order_item_id, product_id, shop_id). async fn completed_line( app: &TestApp, label: &str, ) -> (String, String, String, String, String) { let admin = login_admin(app).await; let (owner, shop_id, _product, sku_id) = setup_sellable(app, &admin, label, 1000, 50).await; let (customer, _) = register_customer(app, label).await; common::add_to_cart(app, &customer, &sku_id, 1).await; let orders = checkout(app, &customer).await; let order = &orders[0]; let order_id = order["id"].as_str().unwrap().to_string(); pay(app, &customer, &order_id).await; // Ship and confirm to reach completed. let detail: serde_json::Value = client() .get(app.url(&format!("/api/shop/orders/{order_id}"))) .bearer_auth(&owner) .send() .await .unwrap() .json() .await .unwrap(); let item_id = detail["items"][0]["id"].as_str().unwrap().to_string(); let res = client() .post(app.url(&format!("/api/shop/orders/{order_id}/shipments"))) .bearer_auth(&owner) .json(&serde_json::json!({ "carrier": "SF", "tracking_no": "T1", "items": [{"order_item_id": item_id, "qty": 1}] })) .send() .await .unwrap(); assert_eq!(res.status(), 201, "{:?}", res.text().await); let shipment: serde_json::Value = res.json().await.unwrap(); let ship_id = shipment["id"].as_str().unwrap().to_string(); assert_eq!( client() .post(app.url(&format!("/api/shop/shipments/{ship_id}/ship"))) .bearer_auth(&owner) .send() .await .unwrap() .status(), 200 ); let res = client() .post(app.url(&format!("/api/shipments/{ship_id}/confirm-delivered"))) .bearer_auth(&customer) .send() .await .unwrap(); assert_eq!(res.status(), 200, "{:?}", res.text().await); (customer, order_id, item_id, detail["items"][0]["sku_id"].as_str().unwrap().to_string(), shop_id) } async fn submit(app: &TestApp, customer: &str, item_id: &str, rating: i64) -> reqwest::Response { client() .post(app.url("/api/reviews")) .bearer_auth(customer) .json(&serde_json::json!({ "order_item_id": item_id, "rating": rating, "content": {"en": "Great product", "zh": "很棒的产品"}, "images": ["https://example.com/r.png"] })) .send() .await .unwrap() } #[tokio::test] #[serial] async fn review_completed_line_and_uniqueness() { let app = spawn_app().await; let (customer, _o, item_id, _sku, _shop) = completed_line(&app, "rv-basic").await; let res = submit(&app, &customer, &item_id, 5).await; assert_eq!(res.status(), 201, "{:?}", res.text().await); let review: serde_json::Value = res.json().await.unwrap(); assert_eq!(review["rating"], 5); assert_eq!(review["reviewer_name"].is_string(), true); assert_eq!(review["status"], "visible"); // Second review of the same line conflicts; exactly one row exists. let res = submit(&app, &customer, &item_id, 4).await; assert_eq!(res.status(), 409); let count: i64 = sqlx::query_scalar("SELECT count(*) FROM product_reviews WHERE order_item_id = $1::uuid") .bind(&item_id) .fetch_one(&app.db) .await .unwrap(); assert_eq!(count, 1); } #[tokio::test] #[serial] async fn uncompleted_or_foreign_lines_are_rejected() { let app = spawn_app().await; let (customer, _o, item_id, _sku, _shop) = completed_line(&app, "rv-scope").await; let (other, _) = register_customer(&app, "rv-scope-other").await; // Another customer's completed line is not reviewable by me. let res = submit(&app, &other, &item_id, 3).await; assert_eq!(res.status(), 409); // A line from an unpaid order is not reviewable. let admin = login_admin(&app).await; let (_owner, _s2, _p2, sku2) = setup_sellable(&app, &admin, "rv-scope-2", 500, 10).await; common::add_to_cart(&app, &customer, &sku2, 1).await; let orders = checkout(&app, &customer).await; let unpaid = &orders[0]; let detail: serde_json::Value = client() .get(app.url(&format!("/api/orders/{}", unpaid["id"].as_str().unwrap()))) .bearer_auth(&customer) .send() .await .unwrap() .json() .await .unwrap(); let unpaid_item = detail["items"][0]["id"].as_str().unwrap(); let res = submit(&app, &customer, unpaid_item, 3).await; assert_eq!(res.status(), 409); } #[tokio::test] #[serial] async fn rating_bounds_and_content_validation() { let app = spawn_app().await; let (customer, _o, item_id, _sku, _shop) = completed_line(&app, "rv-valid").await; for rating in [0, 6] { let res = submit(&app, &customer, &item_id, rating).await; assert_eq!(res.status(), 400, "rating {rating} must be rejected"); } let res = client() .post(app.url("/api/reviews")) .bearer_auth(&customer) .json(&serde_json::json!({ "order_item_id": item_id, "rating": 4, "content": {"en": " ", "zh": ""} })) .send() .await .unwrap(); assert_eq!(res.status(), 400, "empty bilingual content must be rejected"); } #[tokio::test] #[serial] async fn merchant_reply_once_and_scoped() { let app = spawn_app().await; let (customer, _o, item_id, _sku, shop_id) = completed_line(&app, "rv-reply").await; let res = submit(&app, &customer, &item_id, 2).await; let review_id = res.json::().await.unwrap()["id"] .as_str() .unwrap() .to_string(); let admin = login_admin(&app).await; let owner = make_shop_owner(&app, &admin, &shop_id).await; let other_shop = create_shop(&app, &admin, "rv-reply-other").await; let other_owner = make_shop_owner(&app, &admin, &other_shop).await; let reply = |token: &str| { let app_url = app.url(&format!("/api/shop/reviews/{review_id}/reply")); let token = token.to_string(); async move { client() .post(app_url) .bearer_auth(token) .json(&serde_json::json!({ "content": {"en": "Sorry, fixing it"} })) .send() .await .unwrap() } }; assert_eq!(reply(&other_owner).await.status(), 409, "cross-shop reply rejected"); assert_eq!(reply(&owner).await.status(), 200); assert_eq!(reply(&owner).await.status(), 409, "second reply rejected"); } #[tokio::test] #[serial] async fn moderation_hides_and_deletes() { let app = spawn_app().await; let (customer, _o, item_id, sku_id, _shop) = completed_line(&app, "rv-mod").await; let res = submit(&app, &customer, &item_id, 1).await; let review: serde_json::Value = res.json().await.unwrap(); let review_id = review["id"].as_str().unwrap().to_string(); let product_id = review["product_id"].as_str().unwrap().to_string(); let admin = login_admin(&app).await; // Visible in the public list and summary. let summary: serde_json::Value = client() .get(app.url(&format!("/api/products/{product_id}/review-summary"))) .send() .await .unwrap() .json() .await .unwrap(); assert_eq!(summary["count"], 1); assert_eq!(summary["avg_rating"], 1.0); assert_eq!(summary["distribution"]["1"], 1); // Hide: public list and summary exclude it; admin list still shows it. let res = client() .post(app.url(&format!("/api/admin/reviews/{review_id}/hide"))) .bearer_auth(&admin) .send() .await .unwrap(); assert_eq!(res.status(), 200); assert_eq!( client() .post(app.url(&format!("/api/admin/reviews/{review_id}/hide"))) .bearer_auth(&admin) .send() .await .unwrap() .status(), 409, "hiding twice must conflict" ); let summary: serde_json::Value = client() .get(app.url(&format!("/api/products/{product_id}/review-summary"))) .send() .await .unwrap() .json() .await .unwrap(); assert_eq!(summary["count"], 0); assert_eq!(summary["avg_rating"], 0.0); let public: serde_json::Value = client() .get(app.url(&format!("/api/products/{product_id}/reviews"))) .send() .await .unwrap() .json() .await .unwrap(); assert!(!public["items"].as_array().unwrap().iter().any(|r| r["id"] == review_id)); let admin_list: serde_json::Value = client() .get(app.url("/api/admin/reviews")) .bearer_auth(&admin) .send() .await .unwrap() .json() .await .unwrap(); let row = admin_list["items"] .as_array() .unwrap() .iter() .find(|r| r["id"] == review_id) .unwrap(); assert_eq!(row["status"], "hidden"); // Delete removes the row entirely. let res = client() .delete(app.url(&format!("/api/admin/reviews/{review_id}"))) .bearer_auth(&admin) .send() .await .unwrap(); assert_eq!(res.status(), 204); let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM product_reviews WHERE id = $1::uuid)") .bind(&review_id) .fetch_one(&app.db) .await .unwrap(); assert!(!exists); let _ = sku_id; } #[tokio::test] #[serial] async fn reviewable_listing_shrinks_after_submission() { let app = spawn_app().await; let (customer, _o, item_id, _sku, _shop) = completed_line(&app, "rv-pending").await; let list: serde_json::Value = client() .get(app.url("/api/me/reviewable")) .bearer_auth(&customer) .send() .await .unwrap() .json() .await .unwrap(); assert!(list.as_array().unwrap().iter().any(|i| i["order_item_id"] == item_id)); assert_eq!(submit(&app, &customer, &item_id, 5).await.status(), 201); let list: serde_json::Value = client() .get(app.url("/api/me/reviewable")) .bearer_auth(&customer) .send() .await .unwrap() .json() .await .unwrap(); assert!(!list.as_array().unwrap().iter().any(|i| i["order_item_id"] == item_id)); }