1use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use stateset_primitives::{CustomerId, ProductId, ReviewId};
8use strum::{Display, EnumString};
9
10#[derive(
12 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
13)]
14#[serde(rename_all = "snake_case")]
15#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
16#[non_exhaustive]
17pub enum ReviewStatus {
18 #[default]
20 Pending,
21 Approved,
23 Rejected,
25 Flagged,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct Review {
32 pub id: ReviewId,
34 pub product_id: ProductId,
36 pub customer_id: CustomerId,
38 pub rating: u8,
40 pub title: Option<String>,
42 pub body: Option<String>,
44 pub status: ReviewStatus,
46 pub verified_purchase: bool,
48 pub helpful_count: u32,
50 pub reported_count: u32,
52 pub created_at: DateTime<Utc>,
54 pub updated_at: DateTime<Utc>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ReviewSummary {
61 pub product_id: ProductId,
63 pub average_rating: f64,
65 pub total_reviews: u64,
67 pub rating_distribution: [u32; 5],
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct CreateReview {
74 pub product_id: ProductId,
76 pub customer_id: CustomerId,
78 pub rating: u8,
80 pub title: Option<String>,
82 pub body: Option<String>,
84 pub verified_purchase: bool,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, Default)]
90pub struct UpdateReview {
91 pub rating: Option<u8>,
93 pub title: Option<Option<String>>,
95 pub body: Option<Option<String>>,
97 pub status: Option<ReviewStatus>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, Default)]
103pub struct ReviewFilter {
104 pub product_id: Option<ProductId>,
106 pub customer_id: Option<CustomerId>,
108 pub status: Option<ReviewStatus>,
110 pub min_rating: Option<u8>,
112 pub verified_only: Option<bool>,
114 pub limit: Option<u32>,
116 pub offset: Option<u32>,
118}
119
120impl Review {
121 #[must_use]
123 pub fn is_visible(&self) -> bool {
124 self.status == ReviewStatus::Approved
125 }
126
127 #[must_use]
129 pub fn is_valid_rating(rating: u8) -> bool {
130 (1..=5).contains(&rating)
131 }
132}
133
134impl ReviewSummary {
135 #[must_use]
137 pub const fn empty(product_id: ProductId) -> Self {
138 Self { product_id, average_rating: 0.0, total_reviews: 0, rating_distribution: [0; 5] }
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use chrono::Utc;
146 use stateset_primitives::{CustomerId, ProductId, ReviewId};
147
148 fn make_review(status: ReviewStatus, rating: u8) -> Review {
149 Review {
150 id: ReviewId::new(),
151 product_id: ProductId::new(),
152 customer_id: CustomerId::new(),
153 rating,
154 title: Some("Great product".to_string()),
155 body: Some("Really happy with this.".to_string()),
156 status,
157 verified_purchase: true,
158 helpful_count: 0,
159 reported_count: 0,
160 created_at: Utc::now(),
161 updated_at: Utc::now(),
162 }
163 }
164
165 #[test]
168 fn is_visible_returns_true_when_approved() {
169 let review = make_review(ReviewStatus::Approved, 5);
170 assert!(review.is_visible());
171 }
172
173 #[test]
174 fn is_visible_returns_false_when_pending() {
175 let review = make_review(ReviewStatus::Pending, 5);
176 assert!(!review.is_visible());
177 }
178
179 #[test]
180 fn is_visible_returns_false_when_rejected() {
181 let review = make_review(ReviewStatus::Rejected, 5);
182 assert!(!review.is_visible());
183 }
184
185 #[test]
186 fn is_visible_returns_false_when_flagged() {
187 let review = make_review(ReviewStatus::Flagged, 5);
188 assert!(!review.is_visible());
189 }
190
191 #[test]
194 fn is_valid_rating_accepts_one_through_five() {
195 for r in 1u8..=5 {
196 assert!(Review::is_valid_rating(r), "rating {r} should be valid");
197 }
198 }
199
200 #[test]
201 fn is_valid_rating_rejects_zero() {
202 assert!(!Review::is_valid_rating(0));
203 }
204
205 #[test]
206 fn is_valid_rating_rejects_six() {
207 assert!(!Review::is_valid_rating(6));
208 }
209
210 #[test]
213 fn review_summary_empty_has_zero_totals() {
214 let product_id = ProductId::new();
215 let summary = ReviewSummary::empty(product_id);
216 assert_eq!(summary.total_reviews, 0);
217 assert_eq!(summary.average_rating, 0.0);
218 assert_eq!(summary.rating_distribution, [0u32; 5]);
219 }
220
221 #[test]
224 fn review_status_display_fromstr_roundtrip() {
225 for status in [
226 ReviewStatus::Pending,
227 ReviewStatus::Approved,
228 ReviewStatus::Rejected,
229 ReviewStatus::Flagged,
230 ] {
231 let s = status.to_string();
232 let parsed: ReviewStatus = s.parse().unwrap();
233 assert_eq!(parsed, status, "round-trip failed for {s}");
234 }
235 }
236
237 #[test]
240 fn review_status_default_is_pending() {
241 assert_eq!(ReviewStatus::default(), ReviewStatus::Pending);
242 }
243}