@86d-app/reviews 0.0.4 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/AGENTS.md +54 -18
  3. package/README.md +150 -41
  4. package/dist/__tests__/controllers.test.d.ts +2 -0
  5. package/dist/__tests__/controllers.test.d.ts.map +1 -0
  6. package/dist/__tests__/endpoint-security.test.d.ts +2 -0
  7. package/dist/__tests__/endpoint-security.test.d.ts.map +1 -0
  8. package/dist/__tests__/new-features.test.d.ts +2 -0
  9. package/dist/__tests__/new-features.test.d.ts.map +1 -0
  10. package/dist/__tests__/service-impl.test.d.ts +2 -0
  11. package/dist/__tests__/service-impl.test.d.ts.map +1 -0
  12. package/dist/admin/components/index.d.ts +4 -0
  13. package/dist/admin/components/index.d.ts.map +1 -0
  14. package/dist/admin/components/review-analytics.d.ts +2 -0
  15. package/dist/admin/components/review-analytics.d.ts.map +1 -0
  16. package/dist/admin/components/review-list.d.ts +2 -0
  17. package/dist/admin/components/review-list.d.ts.map +1 -0
  18. package/dist/admin/components/review-moderation.d.ts +6 -0
  19. package/dist/admin/components/review-moderation.d.ts.map +1 -0
  20. package/dist/admin/endpoints/approve-review.d.ts +16 -0
  21. package/dist/admin/endpoints/approve-review.d.ts.map +1 -0
  22. package/dist/admin/endpoints/delete-review.d.ts +16 -0
  23. package/dist/admin/endpoints/delete-review.d.ts.map +1 -0
  24. package/dist/admin/endpoints/get-review.d.ts +16 -0
  25. package/dist/admin/endpoints/get-review.d.ts.map +1 -0
  26. package/dist/admin/endpoints/index.d.ts +167 -0
  27. package/dist/admin/endpoints/index.d.ts.map +1 -0
  28. package/dist/admin/endpoints/list-reports.d.ts +17 -0
  29. package/dist/admin/endpoints/list-reports.d.ts.map +1 -0
  30. package/dist/admin/endpoints/list-review-requests.d.ts +11 -0
  31. package/dist/admin/endpoints/list-review-requests.d.ts.map +1 -0
  32. package/dist/admin/endpoints/list-reviews.d.ts +18 -0
  33. package/dist/admin/endpoints/list-reviews.d.ts.map +1 -0
  34. package/dist/admin/endpoints/reject-review.d.ts +16 -0
  35. package/dist/admin/endpoints/reject-review.d.ts.map +1 -0
  36. package/dist/admin/endpoints/respond-review.d.ts +19 -0
  37. package/dist/admin/endpoints/respond-review.d.ts.map +1 -0
  38. package/dist/admin/endpoints/review-analytics.d.ts +6 -0
  39. package/dist/admin/endpoints/review-analytics.d.ts.map +1 -0
  40. package/dist/admin/endpoints/review-request-stats.d.ts +6 -0
  41. package/dist/admin/endpoints/review-request-stats.d.ts.map +1 -0
  42. package/dist/admin/endpoints/send-review-request.d.ts +23 -0
  43. package/dist/admin/endpoints/send-review-request.d.ts.map +1 -0
  44. package/dist/admin/endpoints/update-report.d.ts +22 -0
  45. package/dist/admin/endpoints/update-report.d.ts.map +1 -0
  46. package/dist/index.d.ts +8 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/schema.d.ts +136 -0
  49. package/dist/schema.d.ts.map +1 -0
  50. package/dist/service-impl.d.ts +6 -0
  51. package/dist/service-impl.d.ts.map +1 -0
  52. package/dist/service.d.ts +149 -0
  53. package/dist/service.d.ts.map +1 -0
  54. package/dist/store/components/_hooks.d.ts +6 -0
  55. package/dist/store/components/_hooks.d.ts.map +1 -0
  56. package/dist/store/components/_utils.d.ts +3 -0
  57. package/dist/store/components/_utils.d.ts.map +1 -0
  58. package/dist/store/components/distribution-bars.d.ts +5 -0
  59. package/dist/store/components/distribution-bars.d.ts.map +1 -0
  60. package/dist/store/components/index.d.ts +18 -0
  61. package/dist/store/components/index.d.ts.map +1 -0
  62. package/dist/store/components/product-reviews.d.ts +5 -0
  63. package/dist/store/components/product-reviews.d.ts.map +1 -0
  64. package/dist/store/components/review-card.d.ts +18 -0
  65. package/dist/store/components/review-card.d.ts.map +1 -0
  66. package/dist/store/components/review-form.d.ts +5 -0
  67. package/dist/store/components/review-form.d.ts.map +1 -0
  68. package/dist/store/components/reviews-summary.d.ts +4 -0
  69. package/dist/store/components/reviews-summary.d.ts.map +1 -0
  70. package/dist/store/components/star-display.d.ts +5 -0
  71. package/dist/store/components/star-display.d.ts.map +1 -0
  72. package/dist/store/components/star-picker.d.ts +5 -0
  73. package/dist/store/components/star-picker.d.ts.map +1 -0
  74. package/dist/store/endpoints/index.d.ts +116 -0
  75. package/dist/store/endpoints/index.d.ts.map +1 -0
  76. package/dist/store/endpoints/list-my-reviews.d.ts +30 -0
  77. package/dist/store/endpoints/list-my-reviews.d.ts.map +1 -0
  78. package/dist/store/endpoints/list-product-reviews.d.ts +23 -0
  79. package/dist/store/endpoints/list-product-reviews.d.ts.map +1 -0
  80. package/dist/store/endpoints/mark-helpful.d.ts +18 -0
  81. package/dist/store/endpoints/mark-helpful.d.ts.map +1 -0
  82. package/dist/store/endpoints/report-review.d.ts +27 -0
  83. package/dist/store/endpoints/report-review.d.ts.map +1 -0
  84. package/dist/store/endpoints/submit-review.d.ts +25 -0
  85. package/dist/store/endpoints/submit-review.d.ts.map +1 -0
  86. package/package.json +3 -3
  87. package/src/__tests__/controllers.test.ts +1074 -0
  88. package/src/__tests__/endpoint-security.test.ts +559 -0
  89. package/src/__tests__/new-features.test.ts +618 -0
  90. package/src/__tests__/service-impl.test.ts +1 -1
  91. package/src/admin/endpoints/index.ts +4 -0
  92. package/src/admin/endpoints/list-reports.ts +25 -0
  93. package/src/admin/endpoints/update-report.ts +22 -0
  94. package/src/index.ts +7 -1
  95. package/src/schema.ts +28 -0
  96. package/src/service-impl.ts +151 -3
  97. package/src/service.ts +61 -0
  98. package/src/store/endpoints/index.ts +2 -0
  99. package/src/store/endpoints/list-my-reviews.ts +1 -1
  100. package/src/store/endpoints/list-product-reviews.ts +6 -2
  101. package/src/store/endpoints/mark-helpful.ts +21 -2
  102. package/src/store/endpoints/report-review.ts +38 -0
  103. package/src/store/endpoints/submit-review.ts +33 -7
  104. package/COMPONENTS.md +0 -34
@@ -0,0 +1,559 @@
1
+ import { createMockDataService } from "@86d-app/core/test-utils";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import type { Review } from "../service";
4
+ import { createReviewController } from "../service-impl";
5
+
6
+ /**
7
+ * Security regression tests for reviews endpoints.
8
+ *
9
+ * Covers: identity derivation, trust elevation prevention, ownership isolation,
10
+ * nonexistent resource guards, moderation integrity, review request deduplication,
11
+ * analytics accuracy, and data isolation.
12
+ */
13
+
14
+ describe("reviews endpoint security", () => {
15
+ let mockData: ReturnType<typeof createMockDataService>;
16
+ let controller: ReturnType<typeof createReviewController>;
17
+
18
+ beforeEach(() => {
19
+ mockData = createMockDataService();
20
+ controller = createReviewController(mockData);
21
+ });
22
+
23
+ /** Helper to create a review with sensible defaults. */
24
+ async function seedReview(
25
+ overrides: Partial<Parameters<typeof controller.createReview>[0]> = {},
26
+ ): Promise<Review> {
27
+ return controller.createReview({
28
+ productId: "prod_1",
29
+ authorName: "Test User",
30
+ authorEmail: "test@example.com",
31
+ rating: 4,
32
+ body: "Test review body",
33
+ isVerifiedPurchase: false,
34
+ ...overrides,
35
+ });
36
+ }
37
+
38
+ // ── Identity & Trust Elevation ──────────────────────────────────────
39
+
40
+ describe("customerId server-derivation", () => {
41
+ it("creates review with customerId=undefined for guest users", async () => {
42
+ const review = await seedReview({ customerId: undefined });
43
+ expect(review.id).toBeDefined();
44
+ expect(review.customerId).toBeUndefined();
45
+ expect(review.isVerifiedPurchase).toBe(false);
46
+ });
47
+
48
+ it("creates review with server-provided customerId", async () => {
49
+ const review = await seedReview({ customerId: "session_user_id" });
50
+ expect(review.customerId).toBe("session_user_id");
51
+ });
52
+
53
+ it("isVerifiedPurchase defaults to false from endpoint", async () => {
54
+ const review = await seedReview({ isVerifiedPurchase: false });
55
+ expect(review.isVerifiedPurchase).toBe(false);
56
+ });
57
+
58
+ it("isVerifiedPurchase cannot be elevated by passing true when autoApprove is off", async () => {
59
+ // Even if the controller receives isVerifiedPurchase=true, the endpoint
60
+ // should always pass false. Here we verify the controller stores whatever
61
+ // it receives, proving the endpoint is the trust boundary.
62
+ const review = await seedReview({ isVerifiedPurchase: true });
63
+ expect(review.isVerifiedPurchase).toBe(true); // controller stores it
64
+ // The ENDPOINT never passes true — this test documents the controller's role.
65
+ });
66
+ });
67
+
68
+ // ── Review Status & Default Behavior ────────────────────────────────
69
+
70
+ describe("review status defaults", () => {
71
+ it("new reviews default to pending status", async () => {
72
+ const review = await seedReview();
73
+ expect(review.status).toBe("pending");
74
+ });
75
+
76
+ it("new reviews with autoApprove enabled default to approved", async () => {
77
+ const autoController = createReviewController(mockData, {
78
+ autoApprove: true,
79
+ });
80
+ const review = await autoController.createReview({
81
+ productId: "prod_1",
82
+ authorName: "Auto User",
83
+ authorEmail: "auto@example.com",
84
+ rating: 5,
85
+ body: "Great!",
86
+ isVerifiedPurchase: false,
87
+ });
88
+ expect(review.status).toBe("approved");
89
+ });
90
+
91
+ it("helpfulCount starts at zero", async () => {
92
+ const review = await seedReview();
93
+ expect(review.helpfulCount).toBe(0);
94
+ });
95
+
96
+ it("merchantResponse starts as undefined", async () => {
97
+ const review = await seedReview();
98
+ expect(review.merchantResponse).toBeUndefined();
99
+ });
100
+ });
101
+
102
+ // ── Ownership Isolation ─────────────────────────────────────────────
103
+
104
+ describe("customer review isolation", () => {
105
+ it("listReviewsByCustomer returns only that customer's reviews", async () => {
106
+ await seedReview({
107
+ customerId: "customer_A",
108
+ authorEmail: "a@example.com",
109
+ });
110
+ await seedReview({
111
+ customerId: "customer_B",
112
+ authorEmail: "b@example.com",
113
+ });
114
+ await seedReview({
115
+ customerId: "customer_A",
116
+ authorEmail: "a@example.com",
117
+ productId: "prod_2",
118
+ });
119
+
120
+ const { reviews, total } =
121
+ await controller.listReviewsByCustomer("customer_A");
122
+ expect(total).toBe(2);
123
+ for (const r of reviews) {
124
+ expect(r.customerId).toBe("customer_A");
125
+ }
126
+ });
127
+
128
+ it("listReviewsByCustomer returns empty for unknown customer", async () => {
129
+ await seedReview({ customerId: "customer_A" });
130
+ const { reviews, total } =
131
+ await controller.listReviewsByCustomer("nonexistent");
132
+ expect(total).toBe(0);
133
+ expect(reviews).toHaveLength(0);
134
+ });
135
+
136
+ it("listReviewsByCustomer respects status filter", async () => {
137
+ const r1 = await seedReview({
138
+ customerId: "cust_1",
139
+ productId: "p1",
140
+ });
141
+ await seedReview({ customerId: "cust_1", productId: "p2" });
142
+ await controller.updateReviewStatus(r1.id, "approved");
143
+
144
+ const { reviews } = await controller.listReviewsByCustomer("cust_1", {
145
+ status: "approved",
146
+ });
147
+ expect(reviews).toHaveLength(1);
148
+ expect(reviews[0]?.productId).toBe("p1");
149
+ });
150
+ });
151
+
152
+ // ── Product Review Isolation ────────────────────────────────────────
153
+
154
+ describe("product review isolation", () => {
155
+ it("listReviewsByProduct returns only that product's reviews", async () => {
156
+ await seedReview({ productId: "prod_A" });
157
+ await seedReview({ productId: "prod_B" });
158
+ await seedReview({ productId: "prod_A", rating: 3 });
159
+
160
+ const reviews = await controller.listReviewsByProduct("prod_A");
161
+ expect(reviews).toHaveLength(2);
162
+ for (const r of reviews) {
163
+ expect(r.productId).toBe("prod_A");
164
+ }
165
+ });
166
+
167
+ it("listReviewsByProduct with approvedOnly excludes pending/rejected", async () => {
168
+ const r1 = await seedReview({ productId: "prod_X" });
169
+ const r2 = await seedReview({
170
+ productId: "prod_X",
171
+ authorEmail: "b@test.com",
172
+ });
173
+ await seedReview({
174
+ productId: "prod_X",
175
+ authorEmail: "c@test.com",
176
+ });
177
+
178
+ await controller.updateReviewStatus(r1.id, "approved");
179
+ await controller.updateReviewStatus(r2.id, "rejected");
180
+
181
+ const reviews = await controller.listReviewsByProduct("prod_X", {
182
+ approvedOnly: true,
183
+ });
184
+ expect(reviews).toHaveLength(1);
185
+ expect(reviews[0]?.id).toBe(r1.id);
186
+ });
187
+
188
+ it("rating summary only includes approved reviews", async () => {
189
+ const r1 = await seedReview({ productId: "sum_p", rating: 5 });
190
+ await seedReview({
191
+ productId: "sum_p",
192
+ rating: 1,
193
+ authorEmail: "x@test.com",
194
+ });
195
+
196
+ await controller.updateReviewStatus(r1.id, "approved");
197
+ // Second review stays pending
198
+
199
+ const summary = await controller.getProductRatingSummary("sum_p");
200
+ expect(summary.count).toBe(1);
201
+ expect(summary.average).toBe(5);
202
+ });
203
+ });
204
+
205
+ // ── Nonexistent Resource Guards ─────────────────────────────────────
206
+
207
+ describe("nonexistent resource handling", () => {
208
+ it("getReview returns null for fabricated ID", async () => {
209
+ const result = await controller.getReview("nonexistent_id");
210
+ expect(result).toBeNull();
211
+ });
212
+
213
+ it("updateReviewStatus returns null for fabricated ID", async () => {
214
+ const result = await controller.updateReviewStatus(
215
+ "nonexistent_id",
216
+ "approved",
217
+ );
218
+ expect(result).toBeNull();
219
+ });
220
+
221
+ it("deleteReview returns false for fabricated ID", async () => {
222
+ const result = await controller.deleteReview("nonexistent_id");
223
+ expect(result).toBe(false);
224
+ });
225
+
226
+ it("addMerchantResponse returns null for fabricated ID", async () => {
227
+ const result = await controller.addMerchantResponse(
228
+ "nonexistent_id",
229
+ "Thanks!",
230
+ );
231
+ expect(result).toBeNull();
232
+ });
233
+
234
+ it("markHelpful returns null for fabricated ID", async () => {
235
+ const result = await controller.markHelpful("nonexistent_id");
236
+ expect(result).toBeNull();
237
+ });
238
+ });
239
+
240
+ // ── Moderation Integrity ────────────────────────────────────────────
241
+
242
+ describe("moderation integrity", () => {
243
+ it("approve sets status to approved and updates timestamp", async () => {
244
+ const review = await seedReview();
245
+ const approved = await controller.updateReviewStatus(
246
+ review.id,
247
+ "approved",
248
+ );
249
+ expect(approved?.status).toBe("approved");
250
+ expect(approved?.updatedAt.getTime()).toBeGreaterThanOrEqual(
251
+ review.updatedAt.getTime(),
252
+ );
253
+ });
254
+
255
+ it("reject sets status to rejected", async () => {
256
+ const review = await seedReview();
257
+ const rejected = await controller.updateReviewStatus(
258
+ review.id,
259
+ "rejected",
260
+ );
261
+ expect(rejected?.status).toBe("rejected");
262
+ });
263
+
264
+ it("reject with moderation note preserves the note", async () => {
265
+ const review = await seedReview();
266
+ const rejected = await controller.updateReviewStatus(
267
+ review.id,
268
+ "rejected",
269
+ "Spam content detected",
270
+ );
271
+ expect(rejected?.moderationNote).toBe("Spam content detected");
272
+ });
273
+
274
+ it("re-approving a rejected review changes status back", async () => {
275
+ const review = await seedReview();
276
+ await controller.updateReviewStatus(review.id, "rejected");
277
+ const reapproved = await controller.updateReviewStatus(
278
+ review.id,
279
+ "approved",
280
+ );
281
+ expect(reapproved?.status).toBe("approved");
282
+ });
283
+
284
+ it("delete removes the review permanently", async () => {
285
+ const review = await seedReview();
286
+ const deleted = await controller.deleteReview(review.id);
287
+ expect(deleted).toBe(true);
288
+ const found = await controller.getReview(review.id);
289
+ expect(found).toBeNull();
290
+ });
291
+
292
+ it("merchant response sets response text and timestamp", async () => {
293
+ const review = await seedReview();
294
+ const responded = await controller.addMerchantResponse(
295
+ review.id,
296
+ "Thank you for your feedback!",
297
+ );
298
+ expect(responded?.merchantResponse).toBe("Thank you for your feedback!");
299
+ expect(responded?.merchantResponseAt).toBeDefined();
300
+ });
301
+
302
+ it("merchant response can be overwritten", async () => {
303
+ const review = await seedReview();
304
+ await controller.addMerchantResponse(review.id, "First response");
305
+ const updated = await controller.addMerchantResponse(
306
+ review.id,
307
+ "Updated response",
308
+ );
309
+ expect(updated?.merchantResponse).toBe("Updated response");
310
+ });
311
+ });
312
+
313
+ // ── Helpful Count Integrity ─────────────────────────────────────────
314
+
315
+ describe("helpful count integrity", () => {
316
+ it("markHelpful increments count by exactly one", async () => {
317
+ const review = await seedReview();
318
+ const after = await controller.markHelpful(review.id);
319
+ expect(after?.helpfulCount).toBe(1);
320
+ });
321
+
322
+ it("multiple markHelpful calls accumulate correctly", async () => {
323
+ const review = await seedReview();
324
+ await controller.markHelpful(review.id);
325
+ await controller.markHelpful(review.id);
326
+ const after = await controller.markHelpful(review.id);
327
+ expect(after?.helpfulCount).toBe(3);
328
+ });
329
+
330
+ it("markHelpful on one review does not affect another", async () => {
331
+ const r1 = await seedReview({ productId: "p1" });
332
+ const r2 = await seedReview({
333
+ productId: "p2",
334
+ authorEmail: "other@test.com",
335
+ });
336
+
337
+ await controller.markHelpful(r1.id);
338
+ await controller.markHelpful(r1.id);
339
+
340
+ const r2Updated = await controller.getReview(r2.id);
341
+ expect(r2Updated?.helpfulCount).toBe(0);
342
+ });
343
+ });
344
+
345
+ // ── Review Request Deduplication ────────────────────────────────────
346
+
347
+ describe("review request deduplication", () => {
348
+ it("createReviewRequest succeeds for new orderId", async () => {
349
+ const request = await controller.createReviewRequest({
350
+ orderId: "order_1",
351
+ orderNumber: "ORD-001",
352
+ email: "customer@example.com",
353
+ customerName: "John",
354
+ items: [{ productId: "prod_1", name: "Widget" }],
355
+ });
356
+ expect(request.id).toBeDefined();
357
+ expect(request.orderId).toBe("order_1");
358
+ });
359
+
360
+ it("getReviewRequest finds existing request by orderId", async () => {
361
+ await controller.createReviewRequest({
362
+ orderId: "order_dup",
363
+ orderNumber: "ORD-002",
364
+ email: "dup@example.com",
365
+ customerName: "Jane",
366
+ items: [{ productId: "prod_1", name: "Gadget" }],
367
+ });
368
+
369
+ const found = await controller.getReviewRequest("order_dup");
370
+ expect(found).not.toBeNull();
371
+ expect(found?.orderId).toBe("order_dup");
372
+ });
373
+
374
+ it("getReviewRequest returns null for unknown orderId", async () => {
375
+ const found = await controller.getReviewRequest("no_such_order");
376
+ expect(found).toBeNull();
377
+ });
378
+ });
379
+
380
+ // ── Analytics Accuracy ──────────────────────────────────────────────
381
+
382
+ describe("analytics accuracy", () => {
383
+ it("empty store returns zeroed analytics", async () => {
384
+ const analytics = await controller.getReviewAnalytics();
385
+ expect(analytics.totalReviews).toBe(0);
386
+ expect(analytics.pendingCount).toBe(0);
387
+ expect(analytics.approvedCount).toBe(0);
388
+ expect(analytics.rejectedCount).toBe(0);
389
+ expect(analytics.averageRating).toBe(0);
390
+ expect(analytics.withMerchantResponse).toBe(0);
391
+ });
392
+
393
+ it("counts reflect correct status distribution after moderation", async () => {
394
+ const r1 = await seedReview({ rating: 5 });
395
+ const r2 = await seedReview({
396
+ rating: 3,
397
+ authorEmail: "a@test.com",
398
+ });
399
+ await seedReview({ rating: 4, authorEmail: "b@test.com" });
400
+
401
+ await controller.updateReviewStatus(r1.id, "approved");
402
+ await controller.updateReviewStatus(r2.id, "rejected");
403
+
404
+ const analytics = await controller.getReviewAnalytics();
405
+ expect(analytics.totalReviews).toBe(3);
406
+ expect(analytics.approvedCount).toBe(1);
407
+ expect(analytics.rejectedCount).toBe(1);
408
+ expect(analytics.pendingCount).toBe(1);
409
+ });
410
+
411
+ it("average rating computed correctly", async () => {
412
+ await seedReview({ rating: 5, authorEmail: "a@test.com" });
413
+ await seedReview({ rating: 3, authorEmail: "b@test.com" });
414
+ await seedReview({ rating: 4, authorEmail: "c@test.com" });
415
+
416
+ const analytics = await controller.getReviewAnalytics();
417
+ expect(analytics.averageRating).toBe(4); // (5+3+4)/3 = 4.0
418
+ });
419
+
420
+ it("ratings distribution tracks per-star counts", async () => {
421
+ await seedReview({ rating: 5, authorEmail: "a@test.com" });
422
+ await seedReview({ rating: 5, authorEmail: "b@test.com" });
423
+ await seedReview({ rating: 3, authorEmail: "c@test.com" });
424
+ await seedReview({ rating: 1, authorEmail: "d@test.com" });
425
+
426
+ const analytics = await controller.getReviewAnalytics();
427
+ expect(analytics.ratingsDistribution["5"]).toBe(2);
428
+ expect(analytics.ratingsDistribution["3"]).toBe(1);
429
+ expect(analytics.ratingsDistribution["1"]).toBe(1);
430
+ expect(analytics.ratingsDistribution["2"]).toBe(0);
431
+ expect(analytics.ratingsDistribution["4"]).toBe(0);
432
+ });
433
+
434
+ it("withMerchantResponse counts reviews that have responses", async () => {
435
+ const r1 = await seedReview({ authorEmail: "a@test.com" });
436
+ await seedReview({ authorEmail: "b@test.com" });
437
+
438
+ await controller.addMerchantResponse(r1.id, "Thanks!");
439
+
440
+ const analytics = await controller.getReviewAnalytics();
441
+ expect(analytics.withMerchantResponse).toBe(1);
442
+ });
443
+
444
+ it("deleted reviews excluded from analytics", async () => {
445
+ const r1 = await seedReview({ authorEmail: "a@test.com" });
446
+ await seedReview({ authorEmail: "b@test.com" });
447
+
448
+ await controller.deleteReview(r1.id);
449
+
450
+ const analytics = await controller.getReviewAnalytics();
451
+ expect(analytics.totalReviews).toBe(1);
452
+ });
453
+ });
454
+
455
+ // ── Review Request Stats ────────────────────────────────────────────
456
+
457
+ describe("review request stats", () => {
458
+ it("empty stats returns zeroes", async () => {
459
+ const stats = await controller.getReviewRequestStats();
460
+ expect(stats.totalSent).toBe(0);
461
+ expect(stats.uniqueOrders).toBe(0);
462
+ });
463
+
464
+ it("counts unique orders correctly", async () => {
465
+ await controller.createReviewRequest({
466
+ orderId: "o1",
467
+ orderNumber: "ORD-1",
468
+ email: "a@test.com",
469
+ customerName: "A",
470
+ items: [{ productId: "p1", name: "Item 1" }],
471
+ });
472
+ await controller.createReviewRequest({
473
+ orderId: "o2",
474
+ orderNumber: "ORD-2",
475
+ email: "b@test.com",
476
+ customerName: "B",
477
+ items: [{ productId: "p2", name: "Item 2" }],
478
+ });
479
+
480
+ const stats = await controller.getReviewRequestStats();
481
+ expect(stats.totalSent).toBe(2);
482
+ expect(stats.uniqueOrders).toBe(2);
483
+ });
484
+ });
485
+
486
+ // ── Product Rating Summary Edge Cases ───────────────────────────────
487
+
488
+ describe("product rating summary edge cases", () => {
489
+ it("empty product returns zero summary", async () => {
490
+ const summary =
491
+ await controller.getProductRatingSummary("no_reviews_product");
492
+ expect(summary.average).toBe(0);
493
+ expect(summary.count).toBe(0);
494
+ });
495
+
496
+ it("single approved review produces correct summary", async () => {
497
+ const r = await seedReview({ productId: "single_p", rating: 3 });
498
+ await controller.updateReviewStatus(r.id, "approved");
499
+
500
+ const summary = await controller.getProductRatingSummary("single_p");
501
+ expect(summary.average).toBe(3);
502
+ expect(summary.count).toBe(1);
503
+ expect(summary.distribution["3"]).toBe(1);
504
+ });
505
+
506
+ it("only approved reviews counted in summary", async () => {
507
+ const r1 = await seedReview({
508
+ productId: "mixed_p",
509
+ rating: 5,
510
+ authorEmail: "a@test.com",
511
+ });
512
+ await seedReview({
513
+ productId: "mixed_p",
514
+ rating: 1,
515
+ authorEmail: "b@test.com",
516
+ });
517
+
518
+ await controller.updateReviewStatus(r1.id, "approved");
519
+
520
+ const summary = await controller.getProductRatingSummary("mixed_p");
521
+ expect(summary.count).toBe(1);
522
+ expect(summary.average).toBe(5);
523
+ expect(summary.distribution["1"]).toBe(0);
524
+ });
525
+ });
526
+
527
+ // ── Pagination ──────────────────────────────────────────────────────
528
+
529
+ describe("pagination bounds", () => {
530
+ it("listReviewsByCustomer respects take and skip", async () => {
531
+ for (let i = 0; i < 5; i++) {
532
+ await seedReview({
533
+ customerId: "paginated_cust",
534
+ authorEmail: `u${i}@test.com`,
535
+ productId: `p${i}`,
536
+ });
537
+ }
538
+
539
+ const { reviews, total } = await controller.listReviewsByCustomer(
540
+ "paginated_cust",
541
+ { take: 2, skip: 1 },
542
+ );
543
+ expect(total).toBe(5);
544
+ expect(reviews).toHaveLength(2);
545
+ });
546
+
547
+ it("listReviews respects take parameter", async () => {
548
+ for (let i = 0; i < 5; i++) {
549
+ await seedReview({
550
+ authorEmail: `list${i}@test.com`,
551
+ productId: `lp${i}`,
552
+ });
553
+ }
554
+
555
+ const reviews = await controller.listReviews({ take: 3 });
556
+ expect(reviews.length).toBeLessThanOrEqual(3);
557
+ });
558
+ });
559
+ });