use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use stateset_primitives::{CustomerId, ProductId, ReviewId};
use strum::{Display, EnumString};
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
#[non_exhaustive]
pub enum ReviewStatus {
#[default]
Pending,
Approved,
Rejected,
Flagged,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Review {
pub id: ReviewId,
pub product_id: ProductId,
pub customer_id: CustomerId,
pub rating: u8,
pub title: Option<String>,
pub body: Option<String>,
pub status: ReviewStatus,
pub verified_purchase: bool,
pub helpful_count: u32,
pub reported_count: u32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewSummary {
pub product_id: ProductId,
pub average_rating: f64,
pub total_reviews: u64,
pub rating_distribution: [u32; 5],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateReview {
pub product_id: ProductId,
pub customer_id: CustomerId,
pub rating: u8,
pub title: Option<String>,
pub body: Option<String>,
pub verified_purchase: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UpdateReview {
pub rating: Option<u8>,
pub title: Option<Option<String>>,
pub body: Option<Option<String>>,
pub status: Option<ReviewStatus>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ReviewFilter {
pub product_id: Option<ProductId>,
pub customer_id: Option<CustomerId>,
pub status: Option<ReviewStatus>,
pub min_rating: Option<u8>,
pub verified_only: Option<bool>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
impl Review {
#[must_use]
pub fn is_visible(&self) -> bool {
self.status == ReviewStatus::Approved
}
#[must_use]
pub fn is_valid_rating(rating: u8) -> bool {
(1..=5).contains(&rating)
}
}
impl ReviewSummary {
#[must_use]
pub const fn empty(product_id: ProductId) -> Self {
Self { product_id, average_rating: 0.0, total_reviews: 0, rating_distribution: [0; 5] }
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use stateset_primitives::{CustomerId, ProductId, ReviewId};
fn make_review(status: ReviewStatus, rating: u8) -> Review {
Review {
id: ReviewId::new(),
product_id: ProductId::new(),
customer_id: CustomerId::new(),
rating,
title: Some("Great product".to_string()),
body: Some("Really happy with this.".to_string()),
status,
verified_purchase: true,
helpful_count: 0,
reported_count: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn is_visible_returns_true_when_approved() {
let review = make_review(ReviewStatus::Approved, 5);
assert!(review.is_visible());
}
#[test]
fn is_visible_returns_false_when_pending() {
let review = make_review(ReviewStatus::Pending, 5);
assert!(!review.is_visible());
}
#[test]
fn is_visible_returns_false_when_rejected() {
let review = make_review(ReviewStatus::Rejected, 5);
assert!(!review.is_visible());
}
#[test]
fn is_visible_returns_false_when_flagged() {
let review = make_review(ReviewStatus::Flagged, 5);
assert!(!review.is_visible());
}
#[test]
fn is_valid_rating_accepts_one_through_five() {
for r in 1u8..=5 {
assert!(Review::is_valid_rating(r), "rating {r} should be valid");
}
}
#[test]
fn is_valid_rating_rejects_zero() {
assert!(!Review::is_valid_rating(0));
}
#[test]
fn is_valid_rating_rejects_six() {
assert!(!Review::is_valid_rating(6));
}
#[test]
fn review_summary_empty_has_zero_totals() {
let product_id = ProductId::new();
let summary = ReviewSummary::empty(product_id);
assert_eq!(summary.total_reviews, 0);
assert_eq!(summary.average_rating, 0.0);
assert_eq!(summary.rating_distribution, [0u32; 5]);
}
#[test]
fn review_status_display_fromstr_roundtrip() {
for status in [
ReviewStatus::Pending,
ReviewStatus::Approved,
ReviewStatus::Rejected,
ReviewStatus::Flagged,
] {
let s = status.to_string();
let parsed: ReviewStatus = s.parse().unwrap();
assert_eq!(parsed, status, "round-trip failed for {s}");
}
}
#[test]
fn review_status_default_is_pending() {
assert_eq!(ReviewStatus::default(), ReviewStatus::Pending);
}
}