feat(reviews): order-line reviews with merchant reply and platform moderation (add-product-reviews)
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
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::<serde_json::Value>().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));
|
||||
}
|
||||
Reference in New Issue
Block a user