@86d-app/reviews 0.0.3

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 (53) hide show
  1. package/AGENTS.md +41 -0
  2. package/COMPONENTS.md +34 -0
  3. package/README.md +192 -0
  4. package/package.json +46 -0
  5. package/src/__tests__/service-impl.test.ts +1436 -0
  6. package/src/admin/components/index.ts +3 -0
  7. package/src/admin/components/index.tsx +3 -0
  8. package/src/admin/components/review-analytics.mdx +3 -0
  9. package/src/admin/components/review-analytics.tsx +221 -0
  10. package/src/admin/components/review-list.mdx +89 -0
  11. package/src/admin/components/review-list.tsx +308 -0
  12. package/src/admin/components/review-moderation.mdx +3 -0
  13. package/src/admin/components/review-moderation.tsx +447 -0
  14. package/src/admin/endpoints/approve-review.ts +19 -0
  15. package/src/admin/endpoints/delete-review.ts +17 -0
  16. package/src/admin/endpoints/get-review.ts +16 -0
  17. package/src/admin/endpoints/index.ts +23 -0
  18. package/src/admin/endpoints/list-review-requests.ts +23 -0
  19. package/src/admin/endpoints/list-reviews.ts +25 -0
  20. package/src/admin/endpoints/reject-review.ts +19 -0
  21. package/src/admin/endpoints/respond-review.ts +22 -0
  22. package/src/admin/endpoints/review-analytics.ts +14 -0
  23. package/src/admin/endpoints/review-request-stats.ts +12 -0
  24. package/src/admin/endpoints/send-review-request.ts +41 -0
  25. package/src/index.ts +73 -0
  26. package/src/mdx.d.ts +5 -0
  27. package/src/schema.ts +37 -0
  28. package/src/service-impl.ts +263 -0
  29. package/src/service.ts +126 -0
  30. package/src/store/components/_hooks.ts +13 -0
  31. package/src/store/components/_utils.ts +16 -0
  32. package/src/store/components/distribution-bars.mdx +21 -0
  33. package/src/store/components/distribution-bars.tsx +13 -0
  34. package/src/store/components/index.tsx +20 -0
  35. package/src/store/components/product-reviews.mdx +52 -0
  36. package/src/store/components/product-reviews.tsx +172 -0
  37. package/src/store/components/review-card.mdx +32 -0
  38. package/src/store/components/review-card.tsx +87 -0
  39. package/src/store/components/review-form.mdx +111 -0
  40. package/src/store/components/review-form.tsx +68 -0
  41. package/src/store/components/reviews-summary.mdx +6 -0
  42. package/src/store/components/reviews-summary.tsx +30 -0
  43. package/src/store/components/star-display.mdx +18 -0
  44. package/src/store/components/star-display.tsx +28 -0
  45. package/src/store/components/star-picker.mdx +21 -0
  46. package/src/store/components/star-picker.tsx +23 -0
  47. package/src/store/endpoints/index.ts +11 -0
  48. package/src/store/endpoints/list-my-reviews.ts +38 -0
  49. package/src/store/endpoints/list-product-reviews.ts +26 -0
  50. package/src/store/endpoints/mark-helpful.ts +16 -0
  51. package/src/store/endpoints/submit-review.ts +33 -0
  52. package/tsconfig.json +9 -0
  53. package/vitest.config.ts +2 -0
@@ -0,0 +1,1436 @@
1
+ import {
2
+ createMockDataService,
3
+ createMockModuleContext,
4
+ } from "@86d-app/core/test-utils";
5
+ import { beforeEach, describe, expect, it } from "vitest";
6
+ import reviewsModule from "../index";
7
+ import type { ReviewController } from "../service";
8
+ import { createReviewController } from "../service-impl";
9
+
10
+ // ── Helper ──────────────────────────────────────────────────────────────────
11
+
12
+ function makeReview(overrides: Record<string, unknown> = {}) {
13
+ return {
14
+ productId: "prod_1",
15
+ authorName: "Alice",
16
+ authorEmail: "alice@test.com",
17
+ rating: 5,
18
+ body: "Great product!",
19
+ ...overrides,
20
+ } as Parameters<ReturnType<typeof createReviewController>["createReview"]>[0];
21
+ }
22
+
23
+ // ── Controller ──────────────────────────────────────────────────────────────
24
+
25
+ describe("createReviewController", () => {
26
+ let mockData: ReturnType<typeof createMockDataService>;
27
+ let controller: ReturnType<typeof createReviewController>;
28
+
29
+ beforeEach(() => {
30
+ mockData = createMockDataService();
31
+ controller = createReviewController(mockData);
32
+ });
33
+
34
+ // ── createReview ─────────────────────────────────────────────────────
35
+
36
+ describe("createReview", () => {
37
+ it("creates a review with pending status by default", async () => {
38
+ const review = await controller.createReview(makeReview());
39
+ expect(review.id).toBeDefined();
40
+ expect(review.productId).toBe("prod_1");
41
+ expect(review.authorName).toBe("Alice");
42
+ expect(review.rating).toBe(5);
43
+ expect(review.status).toBe("pending");
44
+ expect(review.isVerifiedPurchase).toBe(false);
45
+ expect(review.helpfulCount).toBe(0);
46
+ });
47
+
48
+ it("creates a review with approved status when autoApprove is true", async () => {
49
+ const ctrl = createReviewController(mockData, {
50
+ autoApprove: true,
51
+ });
52
+ const review = await ctrl.createReview(
53
+ makeReview({ authorName: "Bob", authorEmail: "bob@example.com" }),
54
+ );
55
+ expect(review.status).toBe("approved");
56
+ });
57
+
58
+ it("stores optional fields", async () => {
59
+ const review = await controller.createReview(
60
+ makeReview({
61
+ authorName: "Carol",
62
+ authorEmail: "carol@example.com",
63
+ rating: 3,
64
+ title: "Decent",
65
+ body: "It works okay.",
66
+ customerId: "cust_1",
67
+ isVerifiedPurchase: true,
68
+ }),
69
+ );
70
+ expect(review.title).toBe("Decent");
71
+ expect(review.customerId).toBe("cust_1");
72
+ expect(review.isVerifiedPurchase).toBe(true);
73
+ });
74
+
75
+ it("persists to data store", async () => {
76
+ const review = await controller.createReview(
77
+ makeReview({ authorName: "Dan", authorEmail: "dan@example.com" }),
78
+ );
79
+ const found = await controller.getReview(review.id);
80
+ expect(found).not.toBeNull();
81
+ expect(found?.authorName).toBe("Dan");
82
+ });
83
+
84
+ it("assigns unique ids to each review", async () => {
85
+ const r1 = await controller.createReview(makeReview());
86
+ const r2 = await controller.createReview(
87
+ makeReview({ authorEmail: "b@test.com" }),
88
+ );
89
+ expect(r1.id).not.toBe(r2.id);
90
+ });
91
+
92
+ it("sets createdAt and updatedAt to current time", async () => {
93
+ const before = new Date();
94
+ const review = await controller.createReview(makeReview());
95
+ const after = new Date();
96
+ expect(review.createdAt.getTime()).toBeGreaterThanOrEqual(
97
+ before.getTime(),
98
+ );
99
+ expect(review.createdAt.getTime()).toBeLessThanOrEqual(after.getTime());
100
+ expect(review.updatedAt.getTime()).toBe(review.createdAt.getTime());
101
+ });
102
+
103
+ it("defaults isVerifiedPurchase to false when not provided", async () => {
104
+ const review = await controller.createReview(makeReview());
105
+ expect(review.isVerifiedPurchase).toBe(false);
106
+ });
107
+
108
+ it("defaults title to undefined when not provided", async () => {
109
+ const review = await controller.createReview(makeReview());
110
+ expect(review.title).toBeUndefined();
111
+ });
112
+
113
+ it("defaults customerId to undefined when not provided", async () => {
114
+ const review = await controller.createReview(makeReview());
115
+ expect(review.customerId).toBeUndefined();
116
+ });
117
+
118
+ it("handles autoApprove false explicitly", async () => {
119
+ const ctrl = createReviewController(mockData, {
120
+ autoApprove: false,
121
+ });
122
+ const review = await ctrl.createReview(makeReview());
123
+ expect(review.status).toBe("pending");
124
+ });
125
+
126
+ it("handles each rating value from 1 to 5", async () => {
127
+ for (let rating = 1; rating <= 5; rating++) {
128
+ const review = await controller.createReview(
129
+ makeReview({ rating, authorEmail: `r${rating}@test.com` }),
130
+ );
131
+ expect(review.rating).toBe(rating);
132
+ }
133
+ });
134
+ });
135
+
136
+ // ── getReview ────────────────────────────────────────────────────────
137
+
138
+ describe("getReview", () => {
139
+ it("returns an existing review", async () => {
140
+ const created = await controller.createReview(
141
+ makeReview({ authorName: "Eve", authorEmail: "eve@example.com" }),
142
+ );
143
+ const found = await controller.getReview(created.id);
144
+ expect(found?.id).toBe(created.id);
145
+ });
146
+
147
+ it("returns null for non-existent review", async () => {
148
+ const found = await controller.getReview("missing");
149
+ expect(found).toBeNull();
150
+ });
151
+
152
+ it("returns all fields from the created review", async () => {
153
+ const created = await controller.createReview(
154
+ makeReview({
155
+ title: "Excellent",
156
+ customerId: "cust_99",
157
+ isVerifiedPurchase: true,
158
+ }),
159
+ );
160
+ const found = await controller.getReview(created.id);
161
+ expect(found?.productId).toBe("prod_1");
162
+ expect(found?.authorName).toBe("Alice");
163
+ expect(found?.authorEmail).toBe("alice@test.com");
164
+ expect(found?.rating).toBe(5);
165
+ expect(found?.title).toBe("Excellent");
166
+ expect(found?.body).toBe("Great product!");
167
+ expect(found?.customerId).toBe("cust_99");
168
+ expect(found?.isVerifiedPurchase).toBe(true);
169
+ expect(found?.helpfulCount).toBe(0);
170
+ });
171
+
172
+ it("returns null after a review is deleted", async () => {
173
+ const review = await controller.createReview(makeReview());
174
+ await controller.deleteReview(review.id);
175
+ const found = await controller.getReview(review.id);
176
+ expect(found).toBeNull();
177
+ });
178
+ });
179
+
180
+ // ── listReviewsByProduct ─────────────────────────────────────────────
181
+
182
+ describe("listReviewsByProduct", () => {
183
+ it("lists reviews for a product", async () => {
184
+ await controller.createReview(makeReview());
185
+ await controller.createReview(
186
+ makeReview({ authorEmail: "b@test.com", rating: 4 }),
187
+ );
188
+ await controller.createReview(
189
+ makeReview({
190
+ productId: "prod_2",
191
+ authorEmail: "c@test.com",
192
+ rating: 3,
193
+ }),
194
+ );
195
+ const reviews = await controller.listReviewsByProduct("prod_1");
196
+ expect(reviews).toHaveLength(2);
197
+ });
198
+
199
+ it("filters to approved only", async () => {
200
+ const r1 = await controller.createReview(makeReview());
201
+ await controller.createReview(
202
+ makeReview({ authorEmail: "b@test.com", rating: 2, body: "Meh" }),
203
+ );
204
+ await controller.updateReviewStatus(r1.id, "approved");
205
+
206
+ const approved = await controller.listReviewsByProduct("prod_1", {
207
+ approvedOnly: true,
208
+ });
209
+ expect(approved).toHaveLength(1);
210
+ expect(approved[0].authorName).toBe("Alice");
211
+ });
212
+
213
+ it("supports take and skip", async () => {
214
+ for (let i = 0; i < 5; i++) {
215
+ await controller.createReview(
216
+ makeReview({
217
+ authorName: `User ${i}`,
218
+ authorEmail: `u${i}@test.com`,
219
+ rating: 3,
220
+ body: `Review ${i}`,
221
+ }),
222
+ );
223
+ }
224
+ const page = await controller.listReviewsByProduct("prod_1", {
225
+ take: 2,
226
+ skip: 1,
227
+ });
228
+ expect(page).toHaveLength(2);
229
+ });
230
+
231
+ it("returns empty array for product with no reviews", async () => {
232
+ const reviews = await controller.listReviewsByProduct(
233
+ "nonexistent_product",
234
+ );
235
+ expect(reviews).toHaveLength(0);
236
+ });
237
+
238
+ it("returns empty when skip exceeds total count", async () => {
239
+ await controller.createReview(makeReview());
240
+ await controller.createReview(makeReview({ authorEmail: "b@test.com" }));
241
+ const reviews = await controller.listReviewsByProduct("prod_1", {
242
+ skip: 100,
243
+ });
244
+ expect(reviews).toHaveLength(0);
245
+ });
246
+
247
+ it("excludes rejected reviews when filtering approved only", async () => {
248
+ const r1 = await controller.createReview(
249
+ makeReview({ authorEmail: "a@test.com" }),
250
+ );
251
+ const r2 = await controller.createReview(
252
+ makeReview({ authorEmail: "b@test.com" }),
253
+ );
254
+ await controller.updateReviewStatus(r1.id, "approved");
255
+ await controller.updateReviewStatus(r2.id, "rejected");
256
+
257
+ const approved = await controller.listReviewsByProduct("prod_1", {
258
+ approvedOnly: true,
259
+ });
260
+ expect(approved).toHaveLength(1);
261
+ expect(approved[0].id).toBe(r1.id);
262
+ });
263
+
264
+ it("returns all statuses when approvedOnly is false", async () => {
265
+ const r1 = await controller.createReview(
266
+ makeReview({ authorEmail: "a@test.com" }),
267
+ );
268
+ await controller.createReview(makeReview({ authorEmail: "b@test.com" }));
269
+ await controller.updateReviewStatus(r1.id, "approved");
270
+
271
+ const all = await controller.listReviewsByProduct("prod_1", {
272
+ approvedOnly: false,
273
+ });
274
+ expect(all).toHaveLength(2);
275
+ });
276
+ });
277
+
278
+ // ── listReviews ──────────────────────────────────────────────────────
279
+
280
+ describe("listReviews", () => {
281
+ it("lists all reviews without filters", async () => {
282
+ await controller.createReview(makeReview());
283
+ await controller.createReview(
284
+ makeReview({ productId: "prod_2", authorEmail: "b@test.com" }),
285
+ );
286
+ const all = await controller.listReviews();
287
+ expect(all).toHaveLength(2);
288
+ });
289
+
290
+ it("filters by productId", async () => {
291
+ await controller.createReview(makeReview());
292
+ await controller.createReview(
293
+ makeReview({ productId: "prod_2", authorEmail: "b@test.com" }),
294
+ );
295
+ const results = await controller.listReviews({
296
+ productId: "prod_1",
297
+ });
298
+ expect(results).toHaveLength(1);
299
+ });
300
+
301
+ it("filters by status", async () => {
302
+ const r = await controller.createReview(makeReview());
303
+ await controller.updateReviewStatus(r.id, "approved");
304
+ await controller.createReview(
305
+ makeReview({ productId: "prod_2", authorEmail: "b@test.com" }),
306
+ );
307
+ const approved = await controller.listReviews({
308
+ status: "approved",
309
+ });
310
+ expect(approved).toHaveLength(1);
311
+ });
312
+
313
+ it("filters by productId and status combined", async () => {
314
+ const r1 = await controller.createReview(makeReview());
315
+ const r2 = await controller.createReview(
316
+ makeReview({ productId: "prod_2", authorEmail: "b@test.com" }),
317
+ );
318
+ await controller.createReview(makeReview({ authorEmail: "c@test.com" }));
319
+ await controller.updateReviewStatus(r1.id, "approved");
320
+ await controller.updateReviewStatus(r2.id, "approved");
321
+
322
+ const results = await controller.listReviews({
323
+ productId: "prod_1",
324
+ status: "approved",
325
+ });
326
+ expect(results).toHaveLength(1);
327
+ expect(results[0].id).toBe(r1.id);
328
+ });
329
+
330
+ it("returns empty array when no reviews match filters", async () => {
331
+ await controller.createReview(makeReview());
332
+ const results = await controller.listReviews({
333
+ status: "approved",
334
+ });
335
+ expect(results).toHaveLength(0);
336
+ });
337
+
338
+ it("supports take pagination", async () => {
339
+ for (let i = 0; i < 10; i++) {
340
+ await controller.createReview(
341
+ makeReview({ authorEmail: `u${i}@test.com` }),
342
+ );
343
+ }
344
+ const page = await controller.listReviews({ take: 3 });
345
+ expect(page).toHaveLength(3);
346
+ });
347
+
348
+ it("supports skip pagination", async () => {
349
+ for (let i = 0; i < 5; i++) {
350
+ await controller.createReview(
351
+ makeReview({ authorEmail: `u${i}@test.com` }),
352
+ );
353
+ }
354
+ const page = await controller.listReviews({ skip: 3 });
355
+ expect(page).toHaveLength(2);
356
+ });
357
+
358
+ it("supports take and skip together", async () => {
359
+ for (let i = 0; i < 10; i++) {
360
+ await controller.createReview(
361
+ makeReview({ authorEmail: `u${i}@test.com` }),
362
+ );
363
+ }
364
+ const page = await controller.listReviews({ take: 3, skip: 2 });
365
+ expect(page).toHaveLength(3);
366
+ });
367
+
368
+ it("returns empty when skip exceeds total", async () => {
369
+ await controller.createReview(makeReview());
370
+ const results = await controller.listReviews({ skip: 100 });
371
+ expect(results).toHaveLength(0);
372
+ });
373
+
374
+ it("filters rejected reviews", async () => {
375
+ const r1 = await controller.createReview(
376
+ makeReview({ authorEmail: "a@test.com" }),
377
+ );
378
+ const r2 = await controller.createReview(
379
+ makeReview({ authorEmail: "b@test.com" }),
380
+ );
381
+ await controller.updateReviewStatus(r1.id, "approved");
382
+ await controller.updateReviewStatus(r2.id, "rejected");
383
+
384
+ const rejected = await controller.listReviews({ status: "rejected" });
385
+ expect(rejected).toHaveLength(1);
386
+ expect(rejected[0].id).toBe(r2.id);
387
+ });
388
+
389
+ it("lists pending reviews", async () => {
390
+ await controller.createReview(makeReview({ authorEmail: "a@test.com" }));
391
+ const r2 = await controller.createReview(
392
+ makeReview({ authorEmail: "b@test.com" }),
393
+ );
394
+ await controller.updateReviewStatus(r2.id, "approved");
395
+
396
+ const pending = await controller.listReviews({ status: "pending" });
397
+ expect(pending).toHaveLength(1);
398
+ });
399
+ });
400
+
401
+ // ── updateReviewStatus ───────────────────────────────────────────────
402
+
403
+ describe("updateReviewStatus", () => {
404
+ it("updates the review status", async () => {
405
+ const review = await controller.createReview(makeReview());
406
+ const updated = await controller.updateReviewStatus(
407
+ review.id,
408
+ "approved",
409
+ );
410
+ expect(updated?.status).toBe("approved");
411
+ });
412
+
413
+ it("returns null for non-existent review", async () => {
414
+ const result = await controller.updateReviewStatus("missing", "approved");
415
+ expect(result).toBeNull();
416
+ });
417
+
418
+ it("can reject a review", async () => {
419
+ const review = await controller.createReview(
420
+ makeReview({
421
+ authorName: "Spam",
422
+ authorEmail: "spam@test.com",
423
+ rating: 1,
424
+ body: "Buy my stuff!",
425
+ }),
426
+ );
427
+ const rejected = await controller.updateReviewStatus(
428
+ review.id,
429
+ "rejected",
430
+ );
431
+ expect(rejected?.status).toBe("rejected");
432
+ });
433
+
434
+ it("transitions from approved to rejected", async () => {
435
+ const review = await controller.createReview(makeReview());
436
+ await controller.updateReviewStatus(review.id, "approved");
437
+ const rejected = await controller.updateReviewStatus(
438
+ review.id,
439
+ "rejected",
440
+ );
441
+ expect(rejected?.status).toBe("rejected");
442
+ });
443
+
444
+ it("transitions from rejected to approved", async () => {
445
+ const review = await controller.createReview(makeReview());
446
+ await controller.updateReviewStatus(review.id, "rejected");
447
+ const approved = await controller.updateReviewStatus(
448
+ review.id,
449
+ "approved",
450
+ );
451
+ expect(approved?.status).toBe("approved");
452
+ });
453
+
454
+ it("can set same status again (idempotent)", async () => {
455
+ const review = await controller.createReview(makeReview());
456
+ await controller.updateReviewStatus(review.id, "approved");
457
+ const again = await controller.updateReviewStatus(review.id, "approved");
458
+ expect(again?.status).toBe("approved");
459
+ });
460
+
461
+ it("updates updatedAt timestamp", async () => {
462
+ const review = await controller.createReview(makeReview());
463
+ const originalUpdatedAt = review.updatedAt;
464
+ // Small delay to ensure timestamp difference
465
+ await new Promise((r) => setTimeout(r, 5));
466
+ const updated = await controller.updateReviewStatus(
467
+ review.id,
468
+ "approved",
469
+ );
470
+ expect(updated?.updatedAt.getTime()).toBeGreaterThan(
471
+ originalUpdatedAt.getTime(),
472
+ );
473
+ });
474
+
475
+ it("preserves other fields when updating status", async () => {
476
+ const review = await controller.createReview(
477
+ makeReview({
478
+ title: "My Title",
479
+ customerId: "cust_1",
480
+ isVerifiedPurchase: true,
481
+ }),
482
+ );
483
+ const updated = await controller.updateReviewStatus(
484
+ review.id,
485
+ "approved",
486
+ );
487
+ expect(updated?.productId).toBe(review.productId);
488
+ expect(updated?.authorName).toBe(review.authorName);
489
+ expect(updated?.authorEmail).toBe(review.authorEmail);
490
+ expect(updated?.rating).toBe(review.rating);
491
+ expect(updated?.title).toBe("My Title");
492
+ expect(updated?.body).toBe(review.body);
493
+ expect(updated?.customerId).toBe("cust_1");
494
+ expect(updated?.isVerifiedPurchase).toBe(true);
495
+ expect(updated?.helpfulCount).toBe(0);
496
+ });
497
+
498
+ it("persists status change to data store", async () => {
499
+ const review = await controller.createReview(makeReview());
500
+ await controller.updateReviewStatus(review.id, "approved");
501
+ const found = await controller.getReview(review.id);
502
+ expect(found?.status).toBe("approved");
503
+ });
504
+ });
505
+
506
+ // ── deleteReview ─────────────────────────────────────────────────────
507
+
508
+ describe("deleteReview", () => {
509
+ it("deletes an existing review", async () => {
510
+ const review = await controller.createReview(makeReview());
511
+ const result = await controller.deleteReview(review.id);
512
+ expect(result).toBe(true);
513
+ const found = await controller.getReview(review.id);
514
+ expect(found).toBeNull();
515
+ });
516
+
517
+ it("returns false for non-existent review", async () => {
518
+ const result = await controller.deleteReview("missing");
519
+ expect(result).toBe(false);
520
+ });
521
+
522
+ it("does not affect other reviews", async () => {
523
+ const r1 = await controller.createReview(
524
+ makeReview({ authorEmail: "a@test.com" }),
525
+ );
526
+ const r2 = await controller.createReview(
527
+ makeReview({ authorEmail: "b@test.com" }),
528
+ );
529
+ await controller.deleteReview(r1.id);
530
+ const remaining = await controller.getReview(r2.id);
531
+ expect(remaining).not.toBeNull();
532
+ expect(remaining?.authorEmail).toBe("b@test.com");
533
+ });
534
+
535
+ it("removes review from listReviews results", async () => {
536
+ const r1 = await controller.createReview(
537
+ makeReview({ authorEmail: "a@test.com" }),
538
+ );
539
+ await controller.createReview(makeReview({ authorEmail: "b@test.com" }));
540
+ await controller.deleteReview(r1.id);
541
+ const all = await controller.listReviews();
542
+ expect(all).toHaveLength(1);
543
+ });
544
+
545
+ it("removes review from product listing", async () => {
546
+ const r1 = await controller.createReview(
547
+ makeReview({ authorEmail: "a@test.com" }),
548
+ );
549
+ await controller.createReview(makeReview({ authorEmail: "b@test.com" }));
550
+ await controller.deleteReview(r1.id);
551
+ const reviews = await controller.listReviewsByProduct("prod_1");
552
+ expect(reviews).toHaveLength(1);
553
+ });
554
+
555
+ it("removes review from rating summary", async () => {
556
+ const ctrl = createReviewController(mockData, { autoApprove: true });
557
+ const r1 = await ctrl.createReview(
558
+ makeReview({ rating: 5, authorEmail: "a@test.com" }),
559
+ );
560
+ await ctrl.createReview(
561
+ makeReview({ rating: 3, authorEmail: "b@test.com" }),
562
+ );
563
+ await ctrl.deleteReview(r1.id);
564
+ const summary = await ctrl.getProductRatingSummary("prod_1");
565
+ expect(summary.count).toBe(1);
566
+ expect(summary.average).toBe(3);
567
+ });
568
+ });
569
+
570
+ // ── addMerchantResponse ─────────────────────────────────────────────
571
+
572
+ describe("addMerchantResponse", () => {
573
+ it("adds a merchant response to an existing review", async () => {
574
+ const review = await controller.createReview(makeReview());
575
+ const updated = await controller.addMerchantResponse(
576
+ review.id,
577
+ "Thank you for your feedback!",
578
+ );
579
+ expect(updated?.merchantResponse).toBe("Thank you for your feedback!");
580
+ expect(updated?.merchantResponseAt).toBeInstanceOf(Date);
581
+ });
582
+
583
+ it("returns null for non-existent review", async () => {
584
+ const result = await controller.addMerchantResponse(
585
+ "missing",
586
+ "response",
587
+ );
588
+ expect(result).toBeNull();
589
+ });
590
+
591
+ it("overwrites existing merchant response", async () => {
592
+ const review = await controller.createReview(makeReview());
593
+ await controller.addMerchantResponse(review.id, "First response");
594
+ const updated = await controller.addMerchantResponse(
595
+ review.id,
596
+ "Updated response",
597
+ );
598
+ expect(updated?.merchantResponse).toBe("Updated response");
599
+ });
600
+
601
+ it("updates updatedAt timestamp", async () => {
602
+ const review = await controller.createReview(makeReview());
603
+ await new Promise((r) => setTimeout(r, 5));
604
+ const updated = await controller.addMerchantResponse(
605
+ review.id,
606
+ "Response",
607
+ );
608
+ expect(updated?.updatedAt.getTime()).toBeGreaterThan(
609
+ review.updatedAt.getTime(),
610
+ );
611
+ });
612
+
613
+ it("preserves other fields", async () => {
614
+ const review = await controller.createReview(
615
+ makeReview({
616
+ title: "Great",
617
+ customerId: "cust_1",
618
+ isVerifiedPurchase: true,
619
+ }),
620
+ );
621
+ const updated = await controller.addMerchantResponse(review.id, "Thanks");
622
+ expect(updated?.title).toBe("Great");
623
+ expect(updated?.customerId).toBe("cust_1");
624
+ expect(updated?.isVerifiedPurchase).toBe(true);
625
+ expect(updated?.status).toBe("pending");
626
+ });
627
+
628
+ it("persists response to data store", async () => {
629
+ const review = await controller.createReview(makeReview());
630
+ await controller.addMerchantResponse(review.id, "Persisted response");
631
+ const found = await controller.getReview(review.id);
632
+ expect(found?.merchantResponse).toBe("Persisted response");
633
+ });
634
+ });
635
+
636
+ // ── markHelpful ─────────────────────────────────────────────────────
637
+
638
+ describe("markHelpful", () => {
639
+ it("increments helpfulCount by 1", async () => {
640
+ const review = await controller.createReview(makeReview());
641
+ expect(review.helpfulCount).toBe(0);
642
+ const updated = await controller.markHelpful(review.id);
643
+ expect(updated?.helpfulCount).toBe(1);
644
+ });
645
+
646
+ it("increments multiple times", async () => {
647
+ const review = await controller.createReview(makeReview());
648
+ await controller.markHelpful(review.id);
649
+ await controller.markHelpful(review.id);
650
+ const updated = await controller.markHelpful(review.id);
651
+ expect(updated?.helpfulCount).toBe(3);
652
+ });
653
+
654
+ it("returns null for non-existent review", async () => {
655
+ const result = await controller.markHelpful("missing");
656
+ expect(result).toBeNull();
657
+ });
658
+
659
+ it("updates updatedAt timestamp", async () => {
660
+ const review = await controller.createReview(makeReview());
661
+ await new Promise((r) => setTimeout(r, 5));
662
+ const updated = await controller.markHelpful(review.id);
663
+ expect(updated?.updatedAt.getTime()).toBeGreaterThan(
664
+ review.updatedAt.getTime(),
665
+ );
666
+ });
667
+
668
+ it("preserves other fields", async () => {
669
+ const review = await controller.createReview(
670
+ makeReview({
671
+ title: "Helpful review",
672
+ rating: 4,
673
+ }),
674
+ );
675
+ const updated = await controller.markHelpful(review.id);
676
+ expect(updated?.title).toBe("Helpful review");
677
+ expect(updated?.rating).toBe(4);
678
+ expect(updated?.status).toBe("pending");
679
+ });
680
+
681
+ it("persists helpful count to data store", async () => {
682
+ const review = await controller.createReview(makeReview());
683
+ await controller.markHelpful(review.id);
684
+ const found = await controller.getReview(review.id);
685
+ expect(found?.helpfulCount).toBe(1);
686
+ });
687
+ });
688
+
689
+ // ── getReviewAnalytics ──────────────────────────────────────────────
690
+
691
+ describe("getReviewAnalytics", () => {
692
+ it("returns zeros when there are no reviews", async () => {
693
+ const analytics = await controller.getReviewAnalytics();
694
+ expect(analytics.totalReviews).toBe(0);
695
+ expect(analytics.pendingCount).toBe(0);
696
+ expect(analytics.approvedCount).toBe(0);
697
+ expect(analytics.rejectedCount).toBe(0);
698
+ expect(analytics.averageRating).toBe(0);
699
+ expect(analytics.withMerchantResponse).toBe(0);
700
+ });
701
+
702
+ it("counts reviews by status", async () => {
703
+ const r1 = await controller.createReview(
704
+ makeReview({ authorEmail: "a@test.com" }),
705
+ );
706
+ const r2 = await controller.createReview(
707
+ makeReview({ authorEmail: "b@test.com" }),
708
+ );
709
+ await controller.createReview(makeReview({ authorEmail: "c@test.com" }));
710
+ await controller.updateReviewStatus(r1.id, "approved");
711
+ await controller.updateReviewStatus(r2.id, "rejected");
712
+ // r3 stays pending
713
+
714
+ const analytics = await controller.getReviewAnalytics();
715
+ expect(analytics.totalReviews).toBe(3);
716
+ expect(analytics.approvedCount).toBe(1);
717
+ expect(analytics.rejectedCount).toBe(1);
718
+ expect(analytics.pendingCount).toBe(1);
719
+ });
720
+
721
+ it("computes average rating across all reviews", async () => {
722
+ await controller.createReview(
723
+ makeReview({ rating: 5, authorEmail: "a@test.com" }),
724
+ );
725
+ await controller.createReview(
726
+ makeReview({ rating: 3, authorEmail: "b@test.com" }),
727
+ );
728
+ await controller.createReview(
729
+ makeReview({ rating: 4, authorEmail: "c@test.com" }),
730
+ );
731
+
732
+ const analytics = await controller.getReviewAnalytics();
733
+ expect(analytics.averageRating).toBe(4); // (5+3+4)/3 = 4.0
734
+ });
735
+
736
+ it("computes ratings distribution", async () => {
737
+ await controller.createReview(
738
+ makeReview({ rating: 5, authorEmail: "a@test.com" }),
739
+ );
740
+ await controller.createReview(
741
+ makeReview({ rating: 5, authorEmail: "b@test.com" }),
742
+ );
743
+ await controller.createReview(
744
+ makeReview({ rating: 3, authorEmail: "c@test.com" }),
745
+ );
746
+ await controller.createReview(
747
+ makeReview({ rating: 1, authorEmail: "d@test.com" }),
748
+ );
749
+
750
+ const analytics = await controller.getReviewAnalytics();
751
+ expect(analytics.ratingsDistribution).toEqual({
752
+ "1": 1,
753
+ "2": 0,
754
+ "3": 1,
755
+ "4": 0,
756
+ "5": 2,
757
+ });
758
+ });
759
+
760
+ it("counts reviews with merchant responses", async () => {
761
+ const r1 = await controller.createReview(
762
+ makeReview({ authorEmail: "a@test.com" }),
763
+ );
764
+ const r2 = await controller.createReview(
765
+ makeReview({ authorEmail: "b@test.com" }),
766
+ );
767
+ await controller.createReview(makeReview({ authorEmail: "c@test.com" }));
768
+
769
+ await controller.addMerchantResponse(r1.id, "Thanks!");
770
+ await controller.addMerchantResponse(r2.id, "We appreciate it");
771
+
772
+ const analytics = await controller.getReviewAnalytics();
773
+ expect(analytics.withMerchantResponse).toBe(2);
774
+ });
775
+
776
+ it("includes reviews from all products", async () => {
777
+ await controller.createReview(
778
+ makeReview({ productId: "prod_1", authorEmail: "a@test.com" }),
779
+ );
780
+ await controller.createReview(
781
+ makeReview({ productId: "prod_2", authorEmail: "b@test.com" }),
782
+ );
783
+ await controller.createReview(
784
+ makeReview({ productId: "prod_3", authorEmail: "c@test.com" }),
785
+ );
786
+
787
+ const analytics = await controller.getReviewAnalytics();
788
+ expect(analytics.totalReviews).toBe(3);
789
+ });
790
+ });
791
+
792
+ // ── updateReviewStatus with moderationNote ──────────────────────────
793
+
794
+ describe("updateReviewStatus with moderationNote", () => {
795
+ it("stores a moderation note when provided", async () => {
796
+ const review = await controller.createReview(makeReview());
797
+ const updated = await controller.updateReviewStatus(
798
+ review.id,
799
+ "rejected",
800
+ "Spam content",
801
+ );
802
+ expect(updated?.moderationNote).toBe("Spam content");
803
+ expect(updated?.status).toBe("rejected");
804
+ });
805
+
806
+ it("does not overwrite moderationNote when not provided", async () => {
807
+ const review = await controller.createReview(makeReview());
808
+ await controller.updateReviewStatus(review.id, "rejected", "First note");
809
+ const updated = await controller.updateReviewStatus(
810
+ review.id,
811
+ "approved",
812
+ );
813
+ // The note from the first call should still be there (we spread existing fields)
814
+ expect(updated?.status).toBe("approved");
815
+ });
816
+
817
+ it("updates moderationNote on subsequent calls", async () => {
818
+ const review = await controller.createReview(makeReview());
819
+ await controller.updateReviewStatus(
820
+ review.id,
821
+ "rejected",
822
+ "Initial note",
823
+ );
824
+ const updated = await controller.updateReviewStatus(
825
+ review.id,
826
+ "rejected",
827
+ "Updated note",
828
+ );
829
+ expect(updated?.moderationNote).toBe("Updated note");
830
+ });
831
+ });
832
+
833
+ // ── getProductRatingSummary ───────────────────────────────────────────
834
+
835
+ describe("getProductRatingSummary", () => {
836
+ it("returns zeros for product with no reviews", async () => {
837
+ const summary = await controller.getProductRatingSummary("prod_empty");
838
+ expect(summary.average).toBe(0);
839
+ expect(summary.count).toBe(0);
840
+ expect(summary.distribution).toEqual({
841
+ "1": 0,
842
+ "2": 0,
843
+ "3": 0,
844
+ "4": 0,
845
+ "5": 0,
846
+ });
847
+ });
848
+
849
+ it("computes average and distribution for approved reviews", async () => {
850
+ const r1 = await controller.createReview(
851
+ makeReview({ rating: 5, authorEmail: "a@test.com", body: "Amazing" }),
852
+ );
853
+ const r2 = await controller.createReview(
854
+ makeReview({ rating: 3, authorEmail: "b@test.com", body: "OK" }),
855
+ );
856
+ // Pending review should be excluded
857
+ await controller.createReview(
858
+ makeReview({ rating: 1, authorEmail: "c@test.com", body: "Bad" }),
859
+ );
860
+
861
+ await controller.updateReviewStatus(r1.id, "approved");
862
+ await controller.updateReviewStatus(r2.id, "approved");
863
+
864
+ const summary = await controller.getProductRatingSummary("prod_1");
865
+ expect(summary.count).toBe(2);
866
+ expect(summary.average).toBe(4); // (5+3)/2 = 4.0
867
+ expect(summary.distribution["5"]).toBe(1);
868
+ expect(summary.distribution["3"]).toBe(1);
869
+ expect(summary.distribution["1"]).toBe(0);
870
+ });
871
+
872
+ it("only counts approved reviews from the target product", async () => {
873
+ const r = await controller.createReview(
874
+ makeReview({ rating: 5, authorEmail: "a@test.com" }),
875
+ );
876
+ await controller.updateReviewStatus(r.id, "approved");
877
+
878
+ const otherR = await controller.createReview(
879
+ makeReview({
880
+ productId: "prod_2",
881
+ rating: 1,
882
+ authorEmail: "b@test.com",
883
+ }),
884
+ );
885
+ await controller.updateReviewStatus(otherR.id, "approved");
886
+
887
+ const summary = await controller.getProductRatingSummary("prod_1");
888
+ expect(summary.count).toBe(1);
889
+ expect(summary.average).toBe(5);
890
+ });
891
+
892
+ it("computes fractional average correctly (4.3)", async () => {
893
+ const ctrl = createReviewController(mockData, { autoApprove: true });
894
+ // Ratings: 5, 4, 4 → avg = 13/3 = 4.333... → rounds to 4.3
895
+ await ctrl.createReview(
896
+ makeReview({ rating: 5, authorEmail: "a@test.com" }),
897
+ );
898
+ await ctrl.createReview(
899
+ makeReview({ rating: 4, authorEmail: "b@test.com" }),
900
+ );
901
+ await ctrl.createReview(
902
+ makeReview({ rating: 4, authorEmail: "c@test.com" }),
903
+ );
904
+
905
+ const summary = await ctrl.getProductRatingSummary("prod_1");
906
+ expect(summary.average).toBe(4.3);
907
+ expect(summary.count).toBe(3);
908
+ });
909
+
910
+ it("computes fractional average correctly (3.7)", async () => {
911
+ const ctrl = createReviewController(mockData, { autoApprove: true });
912
+ // Ratings: 5, 4, 2 → avg = 11/3 = 3.666... → rounds to 3.7
913
+ await ctrl.createReview(
914
+ makeReview({ rating: 5, authorEmail: "a@test.com" }),
915
+ );
916
+ await ctrl.createReview(
917
+ makeReview({ rating: 4, authorEmail: "b@test.com" }),
918
+ );
919
+ await ctrl.createReview(
920
+ makeReview({ rating: 2, authorEmail: "c@test.com" }),
921
+ );
922
+
923
+ const summary = await ctrl.getProductRatingSummary("prod_1");
924
+ expect(summary.average).toBe(3.7);
925
+ });
926
+
927
+ it("handles all reviews with the same rating", async () => {
928
+ const ctrl = createReviewController(mockData, { autoApprove: true });
929
+ for (let i = 0; i < 5; i++) {
930
+ await ctrl.createReview(
931
+ makeReview({ rating: 4, authorEmail: `u${i}@test.com` }),
932
+ );
933
+ }
934
+ const summary = await ctrl.getProductRatingSummary("prod_1");
935
+ expect(summary.average).toBe(4);
936
+ expect(summary.count).toBe(5);
937
+ expect(summary.distribution).toEqual({
938
+ "1": 0,
939
+ "2": 0,
940
+ "3": 0,
941
+ "4": 5,
942
+ "5": 0,
943
+ });
944
+ });
945
+
946
+ it("handles a full distribution across all ratings", async () => {
947
+ const ctrl = createReviewController(mockData, { autoApprove: true });
948
+ for (let rating = 1; rating <= 5; rating++) {
949
+ await ctrl.createReview(
950
+ makeReview({ rating, authorEmail: `r${rating}@test.com` }),
951
+ );
952
+ }
953
+ const summary = await ctrl.getProductRatingSummary("prod_1");
954
+ expect(summary.average).toBe(3); // (1+2+3+4+5)/5 = 3.0
955
+ expect(summary.count).toBe(5);
956
+ expect(summary.distribution).toEqual({
957
+ "1": 1,
958
+ "2": 1,
959
+ "3": 1,
960
+ "4": 1,
961
+ "5": 1,
962
+ });
963
+ });
964
+
965
+ it("handles a single approved review", async () => {
966
+ const r = await controller.createReview(makeReview({ rating: 3 }));
967
+ await controller.updateReviewStatus(r.id, "approved");
968
+ const summary = await controller.getProductRatingSummary("prod_1");
969
+ expect(summary.average).toBe(3);
970
+ expect(summary.count).toBe(1);
971
+ expect(summary.distribution["3"]).toBe(1);
972
+ });
973
+
974
+ it("excludes rejected reviews from summary", async () => {
975
+ const r1 = await controller.createReview(
976
+ makeReview({ rating: 5, authorEmail: "a@test.com" }),
977
+ );
978
+ const r2 = await controller.createReview(
979
+ makeReview({ rating: 1, authorEmail: "b@test.com" }),
980
+ );
981
+ await controller.updateReviewStatus(r1.id, "approved");
982
+ await controller.updateReviewStatus(r2.id, "rejected");
983
+
984
+ const summary = await controller.getProductRatingSummary("prod_1");
985
+ expect(summary.count).toBe(1);
986
+ expect(summary.average).toBe(5);
987
+ expect(summary.distribution["1"]).toBe(0);
988
+ });
989
+
990
+ it("updates summary after status changes", async () => {
991
+ const r1 = await controller.createReview(
992
+ makeReview({ rating: 5, authorEmail: "a@test.com" }),
993
+ );
994
+ const r2 = await controller.createReview(
995
+ makeReview({ rating: 1, authorEmail: "b@test.com" }),
996
+ );
997
+ await controller.updateReviewStatus(r1.id, "approved");
998
+ await controller.updateReviewStatus(r2.id, "approved");
999
+
1000
+ let summary = await controller.getProductRatingSummary("prod_1");
1001
+ expect(summary.average).toBe(3); // (5+1)/2
1002
+
1003
+ // Reject the low-rating review
1004
+ await controller.updateReviewStatus(r2.id, "rejected");
1005
+ summary = await controller.getProductRatingSummary("prod_1");
1006
+ expect(summary.average).toBe(5);
1007
+ expect(summary.count).toBe(1);
1008
+ });
1009
+ });
1010
+
1011
+ // ── listReviewsByCustomer ────────────────────────────────────────────
1012
+
1013
+ describe("listReviewsByCustomer", () => {
1014
+ it("returns reviews for a specific customer", async () => {
1015
+ await controller.createReview(
1016
+ makeReview({ customerId: "cust_1", authorEmail: "a@test.com" }),
1017
+ );
1018
+ await controller.createReview(
1019
+ makeReview({ customerId: "cust_1", authorEmail: "b@test.com" }),
1020
+ );
1021
+ await controller.createReview(
1022
+ makeReview({ customerId: "cust_2", authorEmail: "c@test.com" }),
1023
+ );
1024
+
1025
+ const { reviews, total } =
1026
+ await controller.listReviewsByCustomer("cust_1");
1027
+ expect(reviews).toHaveLength(2);
1028
+ expect(total).toBe(2);
1029
+ });
1030
+
1031
+ it("returns empty array when customer has no reviews", async () => {
1032
+ const { reviews, total } =
1033
+ await controller.listReviewsByCustomer("cust_missing");
1034
+ expect(reviews).toHaveLength(0);
1035
+ expect(total).toBe(0);
1036
+ });
1037
+
1038
+ it("filters by status", async () => {
1039
+ const r1 = await controller.createReview(
1040
+ makeReview({ customerId: "cust_1", authorEmail: "a@test.com" }),
1041
+ );
1042
+ await controller.createReview(
1043
+ makeReview({ customerId: "cust_1", authorEmail: "b@test.com" }),
1044
+ );
1045
+ await controller.updateReviewStatus(r1.id, "approved");
1046
+
1047
+ const { reviews, total } = await controller.listReviewsByCustomer(
1048
+ "cust_1",
1049
+ { status: "approved" },
1050
+ );
1051
+ expect(reviews).toHaveLength(1);
1052
+ expect(total).toBe(1);
1053
+ expect(reviews[0].status).toBe("approved");
1054
+ });
1055
+
1056
+ it("supports take pagination", async () => {
1057
+ for (let i = 0; i < 5; i++) {
1058
+ await controller.createReview(
1059
+ makeReview({
1060
+ customerId: "cust_1",
1061
+ authorEmail: `u${i}@test.com`,
1062
+ }),
1063
+ );
1064
+ }
1065
+ const { reviews, total } = await controller.listReviewsByCustomer(
1066
+ "cust_1",
1067
+ { take: 2 },
1068
+ );
1069
+ expect(reviews).toHaveLength(2);
1070
+ expect(total).toBe(5);
1071
+ });
1072
+
1073
+ it("supports skip + take pagination", async () => {
1074
+ for (let i = 0; i < 5; i++) {
1075
+ await controller.createReview(
1076
+ makeReview({
1077
+ customerId: "cust_1",
1078
+ authorEmail: `u${i}@test.com`,
1079
+ }),
1080
+ );
1081
+ }
1082
+ const { reviews, total } = await controller.listReviewsByCustomer(
1083
+ "cust_1",
1084
+ { take: 2, skip: 3 },
1085
+ );
1086
+ expect(reviews).toHaveLength(2);
1087
+ expect(total).toBe(5);
1088
+ });
1089
+
1090
+ it("returns total count regardless of pagination", async () => {
1091
+ for (let i = 0; i < 8; i++) {
1092
+ await controller.createReview(
1093
+ makeReview({
1094
+ customerId: "cust_1",
1095
+ authorEmail: `u${i}@test.com`,
1096
+ }),
1097
+ );
1098
+ }
1099
+ const { reviews, total } = await controller.listReviewsByCustomer(
1100
+ "cust_1",
1101
+ { take: 3, skip: 0 },
1102
+ );
1103
+ expect(reviews).toHaveLength(3);
1104
+ expect(total).toBe(8);
1105
+ });
1106
+
1107
+ it("does not include reviews from other customers", async () => {
1108
+ await controller.createReview(
1109
+ makeReview({ customerId: "cust_1", authorEmail: "a@test.com" }),
1110
+ );
1111
+ await controller.createReview(
1112
+ makeReview({ customerId: "cust_2", authorEmail: "b@test.com" }),
1113
+ );
1114
+ await controller.createReview(
1115
+ makeReview({ customerId: "cust_3", authorEmail: "c@test.com" }),
1116
+ );
1117
+
1118
+ const { reviews } = await controller.listReviewsByCustomer("cust_1");
1119
+ expect(reviews).toHaveLength(1);
1120
+ expect(reviews[0].customerId).toBe("cust_1");
1121
+ });
1122
+
1123
+ it("includes all review fields", async () => {
1124
+ const created = await controller.createReview(
1125
+ makeReview({
1126
+ customerId: "cust_1",
1127
+ title: "Great product",
1128
+ rating: 5,
1129
+ isVerifiedPurchase: true,
1130
+ }),
1131
+ );
1132
+ await controller.addMerchantResponse(created.id, "Thank you!");
1133
+
1134
+ const { reviews } = await controller.listReviewsByCustomer("cust_1");
1135
+ expect(reviews[0].title).toBe("Great product");
1136
+ expect(reviews[0].rating).toBe(5);
1137
+ expect(reviews[0].isVerifiedPurchase).toBe(true);
1138
+ expect(reviews[0].merchantResponse).toBe("Thank you!");
1139
+ });
1140
+
1141
+ it("filters by status and returns correct total", async () => {
1142
+ const r1 = await controller.createReview(
1143
+ makeReview({ customerId: "cust_1", authorEmail: "a@test.com" }),
1144
+ );
1145
+ const r2 = await controller.createReview(
1146
+ makeReview({ customerId: "cust_1", authorEmail: "b@test.com" }),
1147
+ );
1148
+ await controller.createReview(
1149
+ makeReview({ customerId: "cust_1", authorEmail: "c@test.com" }),
1150
+ );
1151
+ await controller.updateReviewStatus(r1.id, "approved");
1152
+ await controller.updateReviewStatus(r2.id, "rejected");
1153
+
1154
+ const pending = await controller.listReviewsByCustomer("cust_1", {
1155
+ status: "pending",
1156
+ });
1157
+ expect(pending.total).toBe(1);
1158
+
1159
+ const rejected = await controller.listReviewsByCustomer("cust_1", {
1160
+ status: "rejected",
1161
+ });
1162
+ expect(rejected.total).toBe(1);
1163
+ });
1164
+ });
1165
+
1166
+ // ── createReviewRequest ─────────────────────────────────────────────
1167
+
1168
+ describe("createReviewRequest", () => {
1169
+ it("creates a review request with a unique id", async () => {
1170
+ const request = await controller.createReviewRequest({
1171
+ orderId: "ord_1",
1172
+ orderNumber: "ORD-001",
1173
+ email: "alice@test.com",
1174
+ customerName: "Alice",
1175
+ items: [{ productId: "prod_1", name: "Widget" }],
1176
+ });
1177
+ expect(request.id).toBeDefined();
1178
+ expect(request.orderId).toBe("ord_1");
1179
+ expect(request.orderNumber).toBe("ORD-001");
1180
+ expect(request.email).toBe("alice@test.com");
1181
+ expect(request.customerName).toBe("Alice");
1182
+ expect(request.items).toHaveLength(1);
1183
+ expect(request.sentAt).toBeInstanceOf(Date);
1184
+ });
1185
+
1186
+ it("stores multiple items in the request", async () => {
1187
+ const request = await controller.createReviewRequest({
1188
+ orderId: "ord_2",
1189
+ orderNumber: "ORD-002",
1190
+ email: "bob@test.com",
1191
+ customerName: "Bob",
1192
+ items: [
1193
+ { productId: "prod_1", name: "Widget" },
1194
+ { productId: "prod_2", name: "Gadget" },
1195
+ { productId: "prod_3", name: "Gizmo" },
1196
+ ],
1197
+ });
1198
+ expect(request.items).toHaveLength(3);
1199
+ expect(request.items[1].name).toBe("Gadget");
1200
+ });
1201
+
1202
+ it("assigns unique ids to each request", async () => {
1203
+ const r1 = await controller.createReviewRequest({
1204
+ orderId: "ord_1",
1205
+ orderNumber: "ORD-001",
1206
+ email: "a@test.com",
1207
+ customerName: "A",
1208
+ items: [{ productId: "p1", name: "P1" }],
1209
+ });
1210
+ const r2 = await controller.createReviewRequest({
1211
+ orderId: "ord_2",
1212
+ orderNumber: "ORD-002",
1213
+ email: "b@test.com",
1214
+ customerName: "B",
1215
+ items: [{ productId: "p2", name: "P2" }],
1216
+ });
1217
+ expect(r1.id).not.toBe(r2.id);
1218
+ });
1219
+ });
1220
+
1221
+ // ── getReviewRequest ────────────────────────────────────────────────
1222
+
1223
+ describe("getReviewRequest", () => {
1224
+ it("returns a review request by orderId", async () => {
1225
+ await controller.createReviewRequest({
1226
+ orderId: "ord_1",
1227
+ orderNumber: "ORD-001",
1228
+ email: "alice@test.com",
1229
+ customerName: "Alice",
1230
+ items: [{ productId: "prod_1", name: "Widget" }],
1231
+ });
1232
+ const found = await controller.getReviewRequest("ord_1");
1233
+ expect(found).not.toBeNull();
1234
+ expect(found?.orderId).toBe("ord_1");
1235
+ });
1236
+
1237
+ it("returns null for non-existent orderId", async () => {
1238
+ const found = await controller.getReviewRequest("missing");
1239
+ expect(found).toBeNull();
1240
+ });
1241
+ });
1242
+
1243
+ // ── listReviewRequests ──────────────────────────────────────────────
1244
+
1245
+ describe("listReviewRequests", () => {
1246
+ it("lists all review requests", async () => {
1247
+ await controller.createReviewRequest({
1248
+ orderId: "ord_1",
1249
+ orderNumber: "ORD-001",
1250
+ email: "a@test.com",
1251
+ customerName: "A",
1252
+ items: [{ productId: "p1", name: "P1" }],
1253
+ });
1254
+ await controller.createReviewRequest({
1255
+ orderId: "ord_2",
1256
+ orderNumber: "ORD-002",
1257
+ email: "b@test.com",
1258
+ customerName: "B",
1259
+ items: [{ productId: "p2", name: "P2" }],
1260
+ });
1261
+ const requests = await controller.listReviewRequests();
1262
+ expect(requests).toHaveLength(2);
1263
+ });
1264
+
1265
+ it("supports take pagination", async () => {
1266
+ for (let i = 0; i < 5; i++) {
1267
+ await controller.createReviewRequest({
1268
+ orderId: `ord_${i}`,
1269
+ orderNumber: `ORD-${i}`,
1270
+ email: `u${i}@test.com`,
1271
+ customerName: `User ${i}`,
1272
+ items: [{ productId: `p${i}`, name: `P${i}` }],
1273
+ });
1274
+ }
1275
+ const page = await controller.listReviewRequests({ take: 2 });
1276
+ expect(page).toHaveLength(2);
1277
+ });
1278
+
1279
+ it("supports skip pagination", async () => {
1280
+ for (let i = 0; i < 5; i++) {
1281
+ await controller.createReviewRequest({
1282
+ orderId: `ord_${i}`,
1283
+ orderNumber: `ORD-${i}`,
1284
+ email: `u${i}@test.com`,
1285
+ customerName: `User ${i}`,
1286
+ items: [{ productId: `p${i}`, name: `P${i}` }],
1287
+ });
1288
+ }
1289
+ const page = await controller.listReviewRequests({ skip: 3 });
1290
+ expect(page).toHaveLength(2);
1291
+ });
1292
+
1293
+ it("returns empty array when no requests exist", async () => {
1294
+ const requests = await controller.listReviewRequests();
1295
+ expect(requests).toHaveLength(0);
1296
+ });
1297
+ });
1298
+
1299
+ // ── getReviewRequestStats ───────────────────────────────────────────
1300
+
1301
+ describe("getReviewRequestStats", () => {
1302
+ it("returns zeros when no requests exist", async () => {
1303
+ const stats = await controller.getReviewRequestStats();
1304
+ expect(stats.totalSent).toBe(0);
1305
+ expect(stats.uniqueOrders).toBe(0);
1306
+ });
1307
+
1308
+ it("counts total sent and unique orders", async () => {
1309
+ await controller.createReviewRequest({
1310
+ orderId: "ord_1",
1311
+ orderNumber: "ORD-001",
1312
+ email: "a@test.com",
1313
+ customerName: "A",
1314
+ items: [{ productId: "p1", name: "P1" }],
1315
+ });
1316
+ await controller.createReviewRequest({
1317
+ orderId: "ord_2",
1318
+ orderNumber: "ORD-002",
1319
+ email: "b@test.com",
1320
+ customerName: "B",
1321
+ items: [{ productId: "p2", name: "P2" }],
1322
+ });
1323
+ const stats = await controller.getReviewRequestStats();
1324
+ expect(stats.totalSent).toBe(2);
1325
+ expect(stats.uniqueOrders).toBe(2);
1326
+ });
1327
+ });
1328
+ });
1329
+
1330
+ // ── Module Factory ──────────────────────────────────────────────────────────
1331
+
1332
+ describe("reviews module factory", () => {
1333
+ it("returns a module with correct id and version", () => {
1334
+ const mod = reviewsModule();
1335
+ expect(mod.id).toBe("reviews");
1336
+ expect(mod.version).toBe("0.0.1");
1337
+ });
1338
+
1339
+ it("exports the reviews schema", () => {
1340
+ const mod = reviewsModule();
1341
+ expect(mod.schema).toBeDefined();
1342
+ const schema = mod.schema as NonNullable<typeof mod.schema>;
1343
+ expect(schema.review).toBeDefined();
1344
+ expect(schema.review.fields.id).toBeDefined();
1345
+ expect(schema.review.fields.rating).toBeDefined();
1346
+ });
1347
+
1348
+ it("declares exported contract fields", () => {
1349
+ const mod = reviewsModule();
1350
+ expect(mod.exports?.read).toContain("productRating");
1351
+ expect(mod.exports?.read).toContain("reviewCount");
1352
+ });
1353
+
1354
+ it("declares emitted events", () => {
1355
+ const mod = reviewsModule();
1356
+ expect(mod.events?.emits).toContain("review.submitted");
1357
+ expect(mod.events?.emits).toContain("review.approved");
1358
+ expect(mod.events?.emits).toContain("review.rejected");
1359
+ expect(mod.events?.emits).toContain("review.responded");
1360
+ expect(mod.events?.emits).toContain("review.requested");
1361
+ });
1362
+
1363
+ it("registers admin pages", () => {
1364
+ const mod = reviewsModule();
1365
+ expect(mod.admin?.pages).toHaveLength(3);
1366
+ expect(mod.admin?.pages?.[0].path).toBe("/admin/reviews");
1367
+ expect(mod.admin?.pages?.[0].label).toBe("Reviews");
1368
+ expect(mod.admin?.pages?.[0].icon).toBe("Star");
1369
+ expect(mod.admin?.pages?.[1].path).toBe("/admin/reviews/analytics");
1370
+ expect(mod.admin?.pages?.[1].component).toBe("ReviewAnalytics");
1371
+ expect(mod.admin?.pages?.[2].path).toBe("/admin/reviews/:id");
1372
+ expect(mod.admin?.pages?.[2].component).toBe("ReviewModeration");
1373
+ });
1374
+
1375
+ it("provides store and admin endpoints", () => {
1376
+ const mod = reviewsModule();
1377
+ expect(mod.endpoints?.store).toBeDefined();
1378
+ expect(mod.endpoints?.admin).toBeDefined();
1379
+ });
1380
+
1381
+ async function initReviewsController(options?: {
1382
+ autoApprove?: string;
1383
+ }): Promise<ReviewController> {
1384
+ const mod = reviewsModule(options);
1385
+ const ctx = createMockModuleContext();
1386
+ const result = await mod.init?.(ctx);
1387
+ if (!result?.controllers?.reviews) {
1388
+ throw new Error("Module init did not return reviews controller");
1389
+ }
1390
+ return result.controllers.reviews as ReviewController;
1391
+ }
1392
+
1393
+ it("init creates a controller", async () => {
1394
+ const ctrl = await initReviewsController();
1395
+ expect(ctrl).toBeDefined();
1396
+ expect(typeof ctrl.createReview).toBe("function");
1397
+ expect(typeof ctrl.getReview).toBe("function");
1398
+ expect(typeof ctrl.listReviews).toBe("function");
1399
+ });
1400
+
1401
+ it("passes autoApprove option from string to boolean", async () => {
1402
+ const ctrl = await initReviewsController({ autoApprove: "true" });
1403
+ const review = await ctrl.createReview({
1404
+ productId: "p1",
1405
+ authorName: "Test",
1406
+ authorEmail: "test@example.com",
1407
+ rating: 5,
1408
+ body: "Auto approved!",
1409
+ });
1410
+ expect(review.status).toBe("approved");
1411
+ });
1412
+
1413
+ it("does not auto-approve when option is not 'true'", async () => {
1414
+ const ctrl = await initReviewsController({ autoApprove: "false" });
1415
+ const review = await ctrl.createReview({
1416
+ productId: "p1",
1417
+ authorName: "Test",
1418
+ authorEmail: "test@example.com",
1419
+ rating: 5,
1420
+ body: "Not auto approved",
1421
+ });
1422
+ expect(review.status).toBe("pending");
1423
+ });
1424
+
1425
+ it("does not auto-approve when no options provided", async () => {
1426
+ const ctrl = await initReviewsController();
1427
+ const review = await ctrl.createReview({
1428
+ productId: "p1",
1429
+ authorName: "Test",
1430
+ authorEmail: "test@example.com",
1431
+ rating: 4,
1432
+ body: "Default behavior",
1433
+ });
1434
+ expect(review.status).toBe("pending");
1435
+ });
1436
+ });