feat(reviews): order-line reviews with merchant reply and platform moderation (add-product-reviews)
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE product_reviews (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
order_item_id UUID NOT NULL REFERENCES order_items (id),
|
||||
order_id UUID NOT NULL REFERENCES orders (id),
|
||||
product_id UUID NOT NULL REFERENCES products (id),
|
||||
shop_id UUID NOT NULL REFERENCES shops (id),
|
||||
user_id UUID NOT NULL REFERENCES users (id),
|
||||
rating INT NOT NULL CHECK (rating BETWEEN 1 AND 5),
|
||||
content JSONB NOT NULL,
|
||||
images JSONB NOT NULL DEFAULT '[]',
|
||||
reply JSONB,
|
||||
reply_at TIMESTAMPTZ,
|
||||
status TEXT NOT NULL DEFAULT 'visible' CHECK (status IN ('visible', 'hidden')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- At most one review per order line, forever.
|
||||
CREATE UNIQUE INDEX product_reviews_one_per_line ON product_reviews (order_item_id);
|
||||
CREATE INDEX idx_product_reviews_product ON product_reviews (product_id) WHERE status = 'visible';
|
||||
CREATE INDEX idx_product_reviews_shop ON product_reviews (shop_id);
|
||||
CREATE INDEX idx_product_reviews_user ON product_reviews (user_id);
|
||||
@@ -18,6 +18,7 @@ pub mod identity;
|
||||
pub mod order;
|
||||
pub mod points;
|
||||
pub mod product;
|
||||
pub mod review;
|
||||
pub mod shop;
|
||||
|
||||
use axum::Router;
|
||||
@@ -45,6 +46,7 @@ pub fn api_router() -> Router<AppState> {
|
||||
.merge(order::router())
|
||||
.merge(points::router())
|
||||
.merge(shop::router())
|
||||
.merge(review::router())
|
||||
.merge(fulfillment::router())
|
||||
.merge(billing::router())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::error::ApiResult;
|
||||
use crate::http::{PageQuery, Paged};
|
||||
use crate::models::UserRole;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::service::{
|
||||
self, ReplyBody, ReviewBody, ReviewRow, ReviewSummary, ReviewableItem,
|
||||
};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/products/{id}/reviews", get(public_list))
|
||||
.route("/products/{id}/review-summary", get(public_summary))
|
||||
.route("/me/reviewable", get(my_reviewable))
|
||||
.route("/reviews", axum::routing::post(create_review))
|
||||
.route("/shop/reviews", get(shop_list))
|
||||
.route("/shop/reviews/{id}/reply", axum::routing::post(shop_reply))
|
||||
.route("/admin/reviews", get(admin_list))
|
||||
.route(
|
||||
"/admin/reviews/{id}",
|
||||
axum::routing::delete(admin_delete),
|
||||
)
|
||||
.route("/admin/reviews/{id}/hide", axum::routing::post(admin_hide))
|
||||
}
|
||||
|
||||
async fn public_list(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Query(q): Query<PageQuery>,
|
||||
) -> ApiResult<Json<Paged<ReviewRow>>> {
|
||||
Ok(Json(
|
||||
service::list_public(&state, id, q.page, q.per_page).await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn public_summary(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<Json<ReviewSummary>> {
|
||||
Ok(Json(service::summary(&state, id).await?))
|
||||
}
|
||||
|
||||
async fn my_reviewable(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> ApiResult<Json<Vec<ReviewableItem>>> {
|
||||
auth.require(&[UserRole::Customer])?;
|
||||
Ok(Json(service::reviewable(&state, auth.id).await?))
|
||||
}
|
||||
|
||||
async fn create_review(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(body): Json<ReviewBody>,
|
||||
) -> ApiResult<(StatusCode, Json<ReviewRow>)> {
|
||||
auth.require(&[UserRole::Customer])?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(service::create(&state, auth.id, body).await?),
|
||||
))
|
||||
}
|
||||
|
||||
async fn shop_list(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Query(q): Query<PageQuery>,
|
||||
) -> ApiResult<Json<Paged<ReviewRow>>> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
Ok(Json(service::list_for_shop(&state, shop_id, q.page, q.per_page).await?))
|
||||
}
|
||||
|
||||
async fn shop_reply(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<ReplyBody>,
|
||||
) -> ApiResult<Json<ReviewRow>> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
Ok(Json(service::reply(&state, shop_id, id, body).await?))
|
||||
}
|
||||
|
||||
async fn admin_list(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Query(q): Query<PageQuery>,
|
||||
) -> ApiResult<Json<Paged<ReviewRow>>> {
|
||||
auth.require_admin()?;
|
||||
Ok(Json(service::list_all(&state, q.page, q.per_page).await?))
|
||||
}
|
||||
|
||||
async fn admin_hide(State(state): State<AppState>, auth: AuthUser, Path(id): Path<Uuid>) -> ApiResult<Json<ReviewRow>> {
|
||||
auth.require_admin()?;
|
||||
Ok(Json(service::hide(&state, id).await?))
|
||||
}
|
||||
|
||||
async fn admin_delete(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
auth.require_admin()?;
|
||||
service::remove(&state, id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod handlers;
|
||||
pub mod service;
|
||||
|
||||
pub use handlers::router;
|
||||
@@ -0,0 +1,321 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sqlx::PgConnection;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{unique_conflict, ApiError, ApiResult};
|
||||
use crate::http::{clamp_page, clamp_per_page, Paged};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct ReviewRow {
|
||||
pub id: Uuid,
|
||||
pub order_item_id: Uuid,
|
||||
pub order_id: Uuid,
|
||||
pub product_id: Uuid,
|
||||
pub shop_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub rating: i32,
|
||||
pub content: Value,
|
||||
pub images: Value,
|
||||
pub reply: Option<Value>,
|
||||
pub reply_at: Option<DateTime<Utc>>,
|
||||
pub status: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Joined from users for storefront display.
|
||||
pub reviewer_name: String,
|
||||
}
|
||||
|
||||
const COLS: &str = "r.id, r.order_item_id, r.order_id, r.product_id, r.shop_id, r.user_id,
|
||||
r.rating, r.content, r.images, r.reply, r.reply_at, r.status, r.created_at,
|
||||
u.display_name AS reviewer_name";
|
||||
const FROM: &str = "product_reviews r JOIN users u ON u.id = r.user_id";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReviewSummary {
|
||||
pub count: i64,
|
||||
pub avg_rating: f64,
|
||||
/// Star (1-5) -> number of visible reviews.
|
||||
pub distribution: std::collections::BTreeMap<i32, i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct ReviewableItem {
|
||||
pub order_item_id: Uuid,
|
||||
pub order_id: Uuid,
|
||||
pub order_no: String,
|
||||
pub product_id: Uuid,
|
||||
pub product_name: Value,
|
||||
pub sku_code: String,
|
||||
pub image: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReviewBody {
|
||||
pub order_item_id: Uuid,
|
||||
pub rating: i32,
|
||||
pub content: Value,
|
||||
pub images: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReplyBody {
|
||||
pub content: Value,
|
||||
}
|
||||
|
||||
/// Review text needs at least one non-empty locale; display falls back.
|
||||
fn some_locale(value: &Value, field: &str) -> ApiResult<()> {
|
||||
let ok = ["en", "zh"].iter().any(|code| {
|
||||
value
|
||||
.get(code)
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|s| !s.trim().is_empty())
|
||||
});
|
||||
if !ok {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"{field} needs text in at least one locale"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch(db: &mut PgConnection, id: Uuid) -> ApiResult<ReviewRow> {
|
||||
sqlx::query_as::<_, ReviewRow>(&format!("SELECT {COLS} FROM {FROM} WHERE r.id = $1"))
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *db)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("review".into()))
|
||||
}
|
||||
|
||||
pub async fn list_public(
|
||||
state: &AppState,
|
||||
product_id: Uuid,
|
||||
page: Option<i64>,
|
||||
per_page: Option<i64>,
|
||||
) -> ApiResult<Paged<ReviewRow>> {
|
||||
let page = clamp_page(page);
|
||||
let per_page = clamp_per_page(per_page);
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM product_reviews WHERE product_id = $1 AND status = 'visible'",
|
||||
)
|
||||
.bind(product_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let items = sqlx::query_as::<_, ReviewRow>(&format!(
|
||||
"SELECT {COLS} FROM {FROM} WHERE r.product_id = $1 AND r.status = 'visible'
|
||||
ORDER BY r.created_at DESC LIMIT $2 OFFSET $3"
|
||||
))
|
||||
.bind(product_id)
|
||||
.bind(per_page)
|
||||
.bind((page - 1) * per_page)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(Paged {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn summary(state: &AppState, product_id: Uuid) -> ApiResult<ReviewSummary> {
|
||||
let rows: Vec<(i32, i64)> = sqlx::query_as(
|
||||
"SELECT rating, count(*)::bigint FROM product_reviews
|
||||
WHERE product_id = $1 AND status = 'visible' GROUP BY rating",
|
||||
)
|
||||
.bind(product_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
let mut distribution = std::collections::BTreeMap::new();
|
||||
let mut count = 0i64;
|
||||
let mut sum = 0i64;
|
||||
for (rating, n) in rows {
|
||||
distribution.insert(rating, n);
|
||||
count += n;
|
||||
sum += rating as i64 * n;
|
||||
}
|
||||
let avg_rating = if count > 0 {
|
||||
(sum as f64 / count as f64 * 10.0).round() / 10.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
Ok(ReviewSummary {
|
||||
count,
|
||||
avg_rating,
|
||||
distribution,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create(state: &AppState, user_id: Uuid, body: ReviewBody) -> ApiResult<ReviewRow> {
|
||||
if !(1..=5).contains(&body.rating) {
|
||||
return Err(ApiError::BadRequest("rating must be between 1 and 5".into()));
|
||||
}
|
||||
some_locale(&body.content, "content")?;
|
||||
let images = Value::from(body.images.clone().unwrap_or_default());
|
||||
|
||||
// The order line must belong to the customer's own completed order.
|
||||
let line = sqlx::query_as::<_, (Uuid, Uuid, Uuid)>(
|
||||
"SELECT oi.order_id, oi.sku_id, o.shop_id
|
||||
FROM order_items oi JOIN orders o ON o.id = oi.order_id
|
||||
WHERE oi.id = $1 AND o.user_id = $2 AND o.status = 'completed'",
|
||||
)
|
||||
.bind(body.order_item_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::Conflict("order line is not reviewable".into()))?;
|
||||
let product_id: Uuid = sqlx::query_scalar("SELECT product_id FROM skus WHERE id = $1")
|
||||
.bind(line.1)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
let id: Uuid = sqlx::query_scalar(
|
||||
"INSERT INTO product_reviews (order_item_id, order_id, product_id, shop_id, user_id,
|
||||
rating, content, images)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id",
|
||||
)
|
||||
.bind(body.order_item_id)
|
||||
.bind(line.0)
|
||||
.bind(product_id)
|
||||
.bind(line.2)
|
||||
.bind(user_id)
|
||||
.bind(body.rating)
|
||||
.bind(&body.content)
|
||||
.bind(&images)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| unique_conflict(e, "order line already reviewed"))?;
|
||||
|
||||
let mut conn = state.db.acquire().await?;
|
||||
fetch(&mut conn, id).await
|
||||
}
|
||||
|
||||
/// Completed order lines of mine that carry no review yet.
|
||||
pub async fn reviewable(state: &AppState, user_id: Uuid) -> ApiResult<Vec<ReviewableItem>> {
|
||||
Ok(sqlx::query_as::<_, ReviewableItem>(
|
||||
"SELECT oi.id AS order_item_id, o.id AS order_id, o.order_no, sk.product_id,
|
||||
oi.product_name, oi.sku_code, oi.image, o.created_at
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN skus sk ON sk.id = oi.sku_id
|
||||
WHERE o.user_id = $1 AND o.status = 'completed'
|
||||
AND NOT EXISTS (SELECT 1 FROM product_reviews r WHERE r.order_item_id = oi.id)
|
||||
ORDER BY o.created_at DESC",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
// --- merchant ---
|
||||
|
||||
pub async fn list_for_shop(
|
||||
state: &AppState,
|
||||
shop_id: Uuid,
|
||||
page: Option<i64>,
|
||||
per_page: Option<i64>,
|
||||
) -> ApiResult<Paged<ReviewRow>> {
|
||||
let page = clamp_page(page);
|
||||
let per_page = clamp_per_page(per_page);
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM product_reviews WHERE shop_id = $1 AND status = 'visible'",
|
||||
)
|
||||
.bind(shop_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let items = sqlx::query_as::<_, ReviewRow>(&format!(
|
||||
"SELECT {COLS} FROM {FROM} WHERE r.shop_id = $1 AND r.status = 'visible'
|
||||
ORDER BY r.created_at DESC LIMIT $2 OFFSET $3"
|
||||
))
|
||||
.bind(shop_id)
|
||||
.bind(per_page)
|
||||
.bind((page - 1) * per_page)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(Paged {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
})
|
||||
}
|
||||
|
||||
/// Guarded one-time reply: the update only lands while `reply IS NULL`.
|
||||
pub async fn reply(state: &AppState, shop_id: Uuid, id: Uuid, body: ReplyBody) -> ApiResult<ReviewRow> {
|
||||
some_locale(&body.content, "reply")?;
|
||||
let mut tx = state.db.begin().await?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE product_reviews SET reply = $2, reply_at = now(), updated_at = now()
|
||||
WHERE id = $1 AND shop_id = $3 AND reply IS NULL",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&body.content)
|
||||
.bind(shop_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::Conflict(
|
||||
"review not found, not yours, or already replied".into(),
|
||||
));
|
||||
}
|
||||
let out = fetch(&mut tx, id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// --- platform admin ---
|
||||
|
||||
pub async fn list_all(
|
||||
state: &AppState,
|
||||
page: Option<i64>,
|
||||
per_page: Option<i64>,
|
||||
) -> ApiResult<Paged<ReviewRow>> {
|
||||
let page = clamp_page(page);
|
||||
let per_page = clamp_per_page(per_page);
|
||||
let total: i64 = sqlx::query_scalar("SELECT count(*) FROM product_reviews")
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let items = sqlx::query_as::<_, ReviewRow>(&format!(
|
||||
"SELECT {COLS} FROM {FROM} ORDER BY r.created_at DESC LIMIT $1 OFFSET $2"
|
||||
))
|
||||
.bind(per_page)
|
||||
.bind((page - 1) * per_page)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(Paged {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
per_page,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn hide(state: &AppState, id: Uuid) -> ApiResult<ReviewRow> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE product_reviews SET status = 'hidden', updated_at = now()
|
||||
WHERE id = $1 AND status = 'visible'",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::Conflict("review not found or already hidden".into()));
|
||||
}
|
||||
let out = fetch(&mut tx, id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn remove(state: &AppState, id: Uuid) -> ApiResult<()> {
|
||||
let result = sqlx::query("DELETE FROM product_reviews WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::NotFound("review".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -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