@86d-app/reviews 0.0.22 → 0.0.24
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.
- package/dist/modules/reviews/src/__tests__/controllers.test.js +937 -0
- package/dist/modules/reviews/src/__tests__/endpoint-security.test.js +438 -0
- package/dist/modules/reviews/src/__tests__/new-features.test.js +467 -0
- package/dist/modules/reviews/src/__tests__/service-impl.test.js +1042 -0
- package/dist/modules/reviews/src/__tests__/store-endpoints.test.js +456 -0
- package/dist/{admin/components/index.d.ts → modules/reviews/src/admin/components/index.js} +0 -1
- package/dist/modules/reviews/src/admin/components/review-analytics.jsx +141 -0
- package/dist/modules/reviews/src/admin/components/review-list.jsx +181 -0
- package/dist/modules/reviews/src/admin/components/review-moderation.jsx +297 -0
- package/dist/modules/reviews/src/admin/endpoints/approve-review.js +11 -0
- package/dist/modules/reviews/src/admin/endpoints/delete-review.js +12 -0
- package/dist/modules/reviews/src/admin/endpoints/get-review.js +11 -0
- package/dist/modules/reviews/src/admin/endpoints/index.js +26 -0
- package/dist/modules/reviews/src/admin/endpoints/list-reports.js +19 -0
- package/dist/modules/reviews/src/admin/endpoints/list-review-requests.js +17 -0
- package/dist/modules/reviews/src/admin/endpoints/list-reviews.js +19 -0
- package/dist/modules/reviews/src/admin/endpoints/reject-review.js +11 -0
- package/dist/modules/reviews/src/admin/endpoints/respond-review.js +14 -0
- package/dist/modules/reviews/src/admin/endpoints/review-analytics.js +8 -0
- package/dist/modules/reviews/src/admin/endpoints/review-request-stats.js +6 -0
- package/dist/modules/reviews/src/admin/endpoints/send-review-request.js +31 -0
- package/dist/modules/reviews/src/admin/endpoints/update-report.js +14 -0
- package/dist/modules/reviews/src/index.js +57 -0
- package/dist/modules/reviews/src/schema.js +63 -0
- package/dist/modules/reviews/src/service-impl.js +349 -0
- package/dist/modules/reviews/src/service.js +1 -0
- package/dist/modules/reviews/src/store/components/_hooks.js +10 -0
- package/dist/modules/reviews/src/store/components/_utils.js +17 -0
- package/dist/modules/reviews/src/store/components/distribution-bars.jsx +5 -0
- package/dist/modules/reviews/src/store/components/index.jsx +17 -0
- package/dist/modules/reviews/src/store/components/product-reviews.jsx +94 -0
- package/dist/modules/reviews/src/store/components/review-card.jsx +44 -0
- package/dist/modules/reviews/src/store/components/review-form.jsx +39 -0
- package/dist/modules/reviews/src/store/components/reviews-summary.jsx +15 -0
- package/dist/modules/reviews/src/store/components/star-display.jsx +12 -0
- package/dist/modules/reviews/src/store/components/star-picker.jsx +7 -0
- package/dist/modules/reviews/src/store/endpoints/index.js +12 -0
- package/dist/modules/reviews/src/store/endpoints/list-my-reviews.js +29 -0
- package/dist/modules/reviews/src/store/endpoints/list-product-reviews.js +24 -0
- package/dist/modules/reviews/src/store/endpoints/mark-helpful.js +29 -0
- package/dist/modules/reviews/src/store/endpoints/report-review.js +30 -0
- package/dist/modules/reviews/src/store/endpoints/submit-review.js +46 -0
- package/package.json +1 -1
- package/src/__tests__/store-endpoints.test.ts +648 -0
- package/src/admin/components/review-list.tsx +3 -2
- package/src/admin/components/review-moderation.tsx +3 -2
- package/src/service-impl.ts +17 -26
- package/src/service.ts +18 -18
- package/src/store/components/_utils.ts +3 -2
- package/src/store/components/product-reviews.tsx +38 -6
- package/dist/__tests__/controllers.test.d.ts +0 -2
- package/dist/__tests__/controllers.test.d.ts.map +0 -1
- package/dist/__tests__/endpoint-security.test.d.ts +0 -2
- package/dist/__tests__/endpoint-security.test.d.ts.map +0 -1
- package/dist/__tests__/new-features.test.d.ts +0 -2
- package/dist/__tests__/new-features.test.d.ts.map +0 -1
- package/dist/__tests__/service-impl.test.d.ts +0 -2
- package/dist/__tests__/service-impl.test.d.ts.map +0 -1
- package/dist/admin/components/index.d.ts.map +0 -1
- package/dist/admin/components/review-analytics.d.ts +0 -2
- package/dist/admin/components/review-analytics.d.ts.map +0 -1
- package/dist/admin/components/review-list.d.ts +0 -2
- package/dist/admin/components/review-list.d.ts.map +0 -1
- package/dist/admin/components/review-moderation.d.ts +0 -6
- package/dist/admin/components/review-moderation.d.ts.map +0 -1
- package/dist/admin/endpoints/approve-review.d.ts +0 -16
- package/dist/admin/endpoints/approve-review.d.ts.map +0 -1
- package/dist/admin/endpoints/delete-review.d.ts +0 -16
- package/dist/admin/endpoints/delete-review.d.ts.map +0 -1
- package/dist/admin/endpoints/get-review.d.ts +0 -16
- package/dist/admin/endpoints/get-review.d.ts.map +0 -1
- package/dist/admin/endpoints/index.d.ts +0 -167
- package/dist/admin/endpoints/index.d.ts.map +0 -1
- package/dist/admin/endpoints/list-reports.d.ts +0 -17
- package/dist/admin/endpoints/list-reports.d.ts.map +0 -1
- package/dist/admin/endpoints/list-review-requests.d.ts +0 -11
- package/dist/admin/endpoints/list-review-requests.d.ts.map +0 -1
- package/dist/admin/endpoints/list-reviews.d.ts +0 -18
- package/dist/admin/endpoints/list-reviews.d.ts.map +0 -1
- package/dist/admin/endpoints/reject-review.d.ts +0 -16
- package/dist/admin/endpoints/reject-review.d.ts.map +0 -1
- package/dist/admin/endpoints/respond-review.d.ts +0 -19
- package/dist/admin/endpoints/respond-review.d.ts.map +0 -1
- package/dist/admin/endpoints/review-analytics.d.ts +0 -6
- package/dist/admin/endpoints/review-analytics.d.ts.map +0 -1
- package/dist/admin/endpoints/review-request-stats.d.ts +0 -6
- package/dist/admin/endpoints/review-request-stats.d.ts.map +0 -1
- package/dist/admin/endpoints/send-review-request.d.ts +0 -23
- package/dist/admin/endpoints/send-review-request.d.ts.map +0 -1
- package/dist/admin/endpoints/update-report.d.ts +0 -22
- package/dist/admin/endpoints/update-report.d.ts.map +0 -1
- package/dist/index.d.ts +0 -8
- package/dist/index.d.ts.map +0 -1
- package/dist/schema.d.ts +0 -136
- package/dist/schema.d.ts.map +0 -1
- package/dist/service-impl.d.ts +0 -6
- package/dist/service-impl.d.ts.map +0 -1
- package/dist/service.d.ts +0 -149
- package/dist/service.d.ts.map +0 -1
- package/dist/store/components/_hooks.d.ts +0 -6
- package/dist/store/components/_hooks.d.ts.map +0 -1
- package/dist/store/components/_utils.d.ts +0 -3
- package/dist/store/components/_utils.d.ts.map +0 -1
- package/dist/store/components/distribution-bars.d.ts +0 -5
- package/dist/store/components/distribution-bars.d.ts.map +0 -1
- package/dist/store/components/index.d.ts +0 -18
- package/dist/store/components/index.d.ts.map +0 -1
- package/dist/store/components/product-reviews.d.ts +0 -5
- package/dist/store/components/product-reviews.d.ts.map +0 -1
- package/dist/store/components/review-card.d.ts +0 -18
- package/dist/store/components/review-card.d.ts.map +0 -1
- package/dist/store/components/review-form.d.ts +0 -5
- package/dist/store/components/review-form.d.ts.map +0 -1
- package/dist/store/components/reviews-summary.d.ts +0 -4
- package/dist/store/components/reviews-summary.d.ts.map +0 -1
- package/dist/store/components/star-display.d.ts +0 -5
- package/dist/store/components/star-display.d.ts.map +0 -1
- package/dist/store/components/star-picker.d.ts +0 -5
- package/dist/store/components/star-picker.d.ts.map +0 -1
- package/dist/store/endpoints/index.d.ts +0 -116
- package/dist/store/endpoints/index.d.ts.map +0 -1
- package/dist/store/endpoints/list-my-reviews.d.ts +0 -30
- package/dist/store/endpoints/list-my-reviews.d.ts.map +0 -1
- package/dist/store/endpoints/list-product-reviews.d.ts +0 -23
- package/dist/store/endpoints/list-product-reviews.d.ts.map +0 -1
- package/dist/store/endpoints/mark-helpful.d.ts +0 -18
- package/dist/store/endpoints/mark-helpful.d.ts.map +0 -1
- package/dist/store/endpoints/report-review.d.ts +0 -27
- package/dist/store/endpoints/report-review.d.ts.map +0 -1
- package/dist/store/endpoints/submit-review.d.ts +0 -25
- package/dist/store/endpoints/submit-review.d.ts.map +0 -1
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
import { createMockDataService } from "@86d-app/core/test-utils";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import type { Review, ReviewReport } from "../service";
|
|
4
|
+
import { createReviewController } from "../service-impl";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Store endpoint integration tests for the reviews module.
|
|
8
|
+
*
|
|
9
|
+
* These tests verify the business logic in store-facing endpoints:
|
|
10
|
+
*
|
|
11
|
+
* 1. submit-review: duplicate prevention for authenticated users,
|
|
12
|
+
* session email enforcement, guest submissions
|
|
13
|
+
* 2. list-product-reviews: approved-only filtering, rating summary,
|
|
14
|
+
* sorting (recent/oldest/highest/lowest/helpful)
|
|
15
|
+
* 3. list-my-reviews: auth guard, pagination calculation
|
|
16
|
+
* 4. mark-helpful: auth-conditional vote dedup vs anonymous increment
|
|
17
|
+
* 5. report-review: existence check, anonymous vs authenticated reports
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
type DataService = ReturnType<typeof createMockDataService>;
|
|
21
|
+
|
|
22
|
+
// ── Simulate store endpoint logic ─────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Simulates submit-review endpoint: duplicate prevention for auth users,
|
|
26
|
+
* forces session email for authenticated, allows body email for guests.
|
|
27
|
+
*/
|
|
28
|
+
async function simulateSubmitReview(
|
|
29
|
+
data: DataService,
|
|
30
|
+
body: {
|
|
31
|
+
productId: string;
|
|
32
|
+
authorName: string;
|
|
33
|
+
authorEmail: string;
|
|
34
|
+
rating: number;
|
|
35
|
+
title?: string;
|
|
36
|
+
body: string;
|
|
37
|
+
images?: Array<{ url: string; caption?: string }>;
|
|
38
|
+
},
|
|
39
|
+
session?: { user: { id: string; email: string } },
|
|
40
|
+
) {
|
|
41
|
+
const controller = createReviewController(data);
|
|
42
|
+
const customerId = session?.user.id;
|
|
43
|
+
|
|
44
|
+
if (customerId) {
|
|
45
|
+
const alreadyReviewed = await controller.hasReviewedProduct(
|
|
46
|
+
customerId,
|
|
47
|
+
body.productId,
|
|
48
|
+
);
|
|
49
|
+
if (alreadyReviewed) {
|
|
50
|
+
return { error: "You have already reviewed this product", status: 409 };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const authorEmail = session ? session.user.email : body.authorEmail;
|
|
55
|
+
|
|
56
|
+
const review = await controller.createReview({
|
|
57
|
+
productId: body.productId,
|
|
58
|
+
authorName: body.authorName,
|
|
59
|
+
authorEmail,
|
|
60
|
+
rating: body.rating,
|
|
61
|
+
title: body.title,
|
|
62
|
+
body: body.body,
|
|
63
|
+
customerId,
|
|
64
|
+
isVerifiedPurchase: false,
|
|
65
|
+
images: body.images,
|
|
66
|
+
});
|
|
67
|
+
return { review };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Simulates list-product-reviews endpoint: parallel fetch of
|
|
72
|
+
* approved-only reviews + rating summary.
|
|
73
|
+
*/
|
|
74
|
+
async function simulateListProductReviews(
|
|
75
|
+
data: DataService,
|
|
76
|
+
productId: string,
|
|
77
|
+
query: {
|
|
78
|
+
take?: number;
|
|
79
|
+
skip?: number;
|
|
80
|
+
sortBy?: "recent" | "oldest" | "highest" | "lowest" | "helpful";
|
|
81
|
+
} = {},
|
|
82
|
+
) {
|
|
83
|
+
const controller = createReviewController(data);
|
|
84
|
+
const [reviews, summary] = await Promise.all([
|
|
85
|
+
controller.listReviewsByProduct(productId, {
|
|
86
|
+
approvedOnly: true,
|
|
87
|
+
take: query.take ?? 20,
|
|
88
|
+
skip: query.skip ?? 0,
|
|
89
|
+
sortBy: query.sortBy ?? "recent",
|
|
90
|
+
}),
|
|
91
|
+
controller.getProductRatingSummary(productId),
|
|
92
|
+
]);
|
|
93
|
+
return { reviews, summary, total: reviews.length };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Simulates list-my-reviews endpoint: auth guard + pagination.
|
|
98
|
+
*/
|
|
99
|
+
async function simulateListMyReviews(
|
|
100
|
+
data: DataService,
|
|
101
|
+
query: {
|
|
102
|
+
page?: number;
|
|
103
|
+
limit?: number;
|
|
104
|
+
status?: "pending" | "approved" | "rejected";
|
|
105
|
+
} = {},
|
|
106
|
+
userId?: string,
|
|
107
|
+
) {
|
|
108
|
+
if (!userId) {
|
|
109
|
+
return { error: "Unauthorized", status: 401 };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const page = query.page ?? 1;
|
|
113
|
+
const limit = query.limit ?? 10;
|
|
114
|
+
const skip = (page - 1) * limit;
|
|
115
|
+
|
|
116
|
+
const controller = createReviewController(data);
|
|
117
|
+
const { reviews, total } = await controller.listReviewsByCustomer(userId, {
|
|
118
|
+
status: query.status,
|
|
119
|
+
take: limit,
|
|
120
|
+
skip,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return { reviews, total, page, limit, pages: Math.ceil(total / limit) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Simulates mark-helpful endpoint: auth users get vote dedup,
|
|
128
|
+
* anonymous users get simple increment.
|
|
129
|
+
*/
|
|
130
|
+
async function simulateMarkHelpful(
|
|
131
|
+
data: DataService,
|
|
132
|
+
reviewId: string,
|
|
133
|
+
voterId?: string,
|
|
134
|
+
) {
|
|
135
|
+
const controller = createReviewController(data);
|
|
136
|
+
|
|
137
|
+
if (voterId) {
|
|
138
|
+
const result = await controller.voteHelpful(reviewId, voterId);
|
|
139
|
+
if (!result) return { error: "Review not found", status: 404 };
|
|
140
|
+
if (result.alreadyVoted) {
|
|
141
|
+
return {
|
|
142
|
+
helpfulCount: result.review.helpfulCount,
|
|
143
|
+
alreadyVoted: true,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
helpfulCount: result.review.helpfulCount,
|
|
148
|
+
alreadyVoted: false,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const review = await controller.markHelpful(reviewId);
|
|
153
|
+
if (!review) return { error: "Review not found", status: 404 };
|
|
154
|
+
return { helpfulCount: review.helpfulCount, alreadyVoted: false };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Simulates report-review endpoint: existence check, allows
|
|
159
|
+
* anonymous reports.
|
|
160
|
+
*/
|
|
161
|
+
async function simulateReportReview(
|
|
162
|
+
data: DataService,
|
|
163
|
+
reviewId: string,
|
|
164
|
+
body: {
|
|
165
|
+
reason: string;
|
|
166
|
+
details?: string;
|
|
167
|
+
},
|
|
168
|
+
reporterId?: string,
|
|
169
|
+
) {
|
|
170
|
+
const controller = createReviewController(data);
|
|
171
|
+
|
|
172
|
+
const review = await controller.getReview(reviewId);
|
|
173
|
+
if (!review) return { error: "Review not found", status: 404 };
|
|
174
|
+
|
|
175
|
+
const report = await controller.reportReview({
|
|
176
|
+
reviewId,
|
|
177
|
+
reporterId,
|
|
178
|
+
reason: body.reason,
|
|
179
|
+
details: body.details,
|
|
180
|
+
});
|
|
181
|
+
return { report };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
async function seedReview(
|
|
187
|
+
data: DataService,
|
|
188
|
+
overrides: Partial<{
|
|
189
|
+
productId: string;
|
|
190
|
+
rating: number;
|
|
191
|
+
status: "pending" | "approved" | "rejected";
|
|
192
|
+
customerId: string;
|
|
193
|
+
authorName: string;
|
|
194
|
+
authorEmail: string;
|
|
195
|
+
helpfulCount: number;
|
|
196
|
+
}> = {},
|
|
197
|
+
): Promise<Review> {
|
|
198
|
+
const controller = createReviewController(
|
|
199
|
+
data,
|
|
200
|
+
overrides.status === "approved" ? { autoApprove: true } : undefined,
|
|
201
|
+
);
|
|
202
|
+
const review = await controller.createReview({
|
|
203
|
+
productId: overrides.productId ?? "prod-1",
|
|
204
|
+
authorName: overrides.authorName ?? "Tester",
|
|
205
|
+
authorEmail: overrides.authorEmail ?? "tester@example.com",
|
|
206
|
+
rating: overrides.rating ?? 4,
|
|
207
|
+
body: "Great product",
|
|
208
|
+
customerId: overrides.customerId,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Override status if not handled by autoApprove
|
|
212
|
+
if (overrides.status && overrides.status !== "approved") {
|
|
213
|
+
await controller.updateReviewStatus(review.id, overrides.status);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Manually set helpfulCount if provided
|
|
217
|
+
if (overrides.helpfulCount) {
|
|
218
|
+
const raw = (await data.get("review", review.id)) as Review;
|
|
219
|
+
const updated = {
|
|
220
|
+
...raw,
|
|
221
|
+
helpfulCount: overrides.helpfulCount,
|
|
222
|
+
};
|
|
223
|
+
await data.upsert("review", review.id, updated as Record<string, unknown>);
|
|
224
|
+
return updated;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Re-read to get final state
|
|
228
|
+
return (await data.get("review", review.id)) as unknown as Review;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Tests ─────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
describe("reviews store endpoints", () => {
|
|
234
|
+
let data: DataService;
|
|
235
|
+
|
|
236
|
+
beforeEach(() => {
|
|
237
|
+
data = createMockDataService();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ── submit-review ────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
describe("submit-review", () => {
|
|
243
|
+
it("allows guest to submit review with body email", async () => {
|
|
244
|
+
const result = await simulateSubmitReview(data, {
|
|
245
|
+
productId: "prod-1",
|
|
246
|
+
authorName: "Guest User",
|
|
247
|
+
authorEmail: "guest@example.com",
|
|
248
|
+
rating: 5,
|
|
249
|
+
body: "Love it!",
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const res = result as { review: Review };
|
|
253
|
+
expect(res.review.authorEmail).toBe("guest@example.com");
|
|
254
|
+
expect(res.review.customerId).toBeUndefined();
|
|
255
|
+
expect(res.review.rating).toBe(5);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("forces session email for authenticated users", async () => {
|
|
259
|
+
const result = await simulateSubmitReview(
|
|
260
|
+
data,
|
|
261
|
+
{
|
|
262
|
+
productId: "prod-1",
|
|
263
|
+
authorName: "Auth User",
|
|
264
|
+
authorEmail: "fake@hacker.com", // Should be ignored
|
|
265
|
+
rating: 4,
|
|
266
|
+
body: "Nice product",
|
|
267
|
+
},
|
|
268
|
+
{ user: { id: "cust-1", email: "real@example.com" } },
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const res = result as { review: Review };
|
|
272
|
+
expect(res.review.authorEmail).toBe("real@example.com");
|
|
273
|
+
expect(res.review.customerId).toBe("cust-1");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("prevents duplicate review from same authenticated customer", async () => {
|
|
277
|
+
const session = { user: { id: "cust-1", email: "user@example.com" } };
|
|
278
|
+
|
|
279
|
+
await simulateSubmitReview(
|
|
280
|
+
data,
|
|
281
|
+
{
|
|
282
|
+
productId: "prod-1",
|
|
283
|
+
authorName: "User",
|
|
284
|
+
authorEmail: "user@example.com",
|
|
285
|
+
rating: 5,
|
|
286
|
+
body: "First review",
|
|
287
|
+
},
|
|
288
|
+
session,
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const second = await simulateSubmitReview(
|
|
292
|
+
data,
|
|
293
|
+
{
|
|
294
|
+
productId: "prod-1",
|
|
295
|
+
authorName: "User",
|
|
296
|
+
authorEmail: "user@example.com",
|
|
297
|
+
rating: 3,
|
|
298
|
+
body: "Trying to review again",
|
|
299
|
+
},
|
|
300
|
+
session,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
expect(second).toEqual({
|
|
304
|
+
error: "You have already reviewed this product",
|
|
305
|
+
status: 409,
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("allows same customer to review different products", async () => {
|
|
310
|
+
const session = { user: { id: "cust-1", email: "user@example.com" } };
|
|
311
|
+
|
|
312
|
+
const first = await simulateSubmitReview(
|
|
313
|
+
data,
|
|
314
|
+
{
|
|
315
|
+
productId: "prod-1",
|
|
316
|
+
authorName: "User",
|
|
317
|
+
authorEmail: "user@example.com",
|
|
318
|
+
rating: 5,
|
|
319
|
+
body: "Product 1 review",
|
|
320
|
+
},
|
|
321
|
+
session,
|
|
322
|
+
);
|
|
323
|
+
expect("review" in first).toBe(true);
|
|
324
|
+
|
|
325
|
+
const second = await simulateSubmitReview(
|
|
326
|
+
data,
|
|
327
|
+
{
|
|
328
|
+
productId: "prod-2",
|
|
329
|
+
authorName: "User",
|
|
330
|
+
authorEmail: "user@example.com",
|
|
331
|
+
rating: 4,
|
|
332
|
+
body: "Product 2 review",
|
|
333
|
+
},
|
|
334
|
+
session,
|
|
335
|
+
);
|
|
336
|
+
expect("review" in second).toBe(true);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("allows guests to submit multiple reviews for same product", async () => {
|
|
340
|
+
// Guests have no customerId — no dedup check
|
|
341
|
+
const first = await simulateSubmitReview(data, {
|
|
342
|
+
productId: "prod-1",
|
|
343
|
+
authorName: "Guest A",
|
|
344
|
+
authorEmail: "a@example.com",
|
|
345
|
+
rating: 5,
|
|
346
|
+
body: "Review 1",
|
|
347
|
+
});
|
|
348
|
+
const second = await simulateSubmitReview(data, {
|
|
349
|
+
productId: "prod-1",
|
|
350
|
+
authorName: "Guest B",
|
|
351
|
+
authorEmail: "b@example.com",
|
|
352
|
+
rating: 3,
|
|
353
|
+
body: "Review 2",
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
expect("review" in first).toBe(true);
|
|
357
|
+
expect("review" in second).toBe(true);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("includes images in review when provided", async () => {
|
|
361
|
+
const result = await simulateSubmitReview(data, {
|
|
362
|
+
productId: "prod-1",
|
|
363
|
+
authorName: "Photo User",
|
|
364
|
+
authorEmail: "photo@example.com",
|
|
365
|
+
rating: 5,
|
|
366
|
+
body: "Check out these pics!",
|
|
367
|
+
images: [
|
|
368
|
+
{ url: "https://img.example.com/1.jpg", caption: "Front view" },
|
|
369
|
+
{ url: "https://img.example.com/2.jpg" },
|
|
370
|
+
],
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const res = result as { review: Review };
|
|
374
|
+
expect(res.review.images).toHaveLength(2);
|
|
375
|
+
expect(res.review.images?.[0].caption).toBe("Front view");
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// ── list-product-reviews ─────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
describe("list-product-reviews", () => {
|
|
382
|
+
it("only returns approved reviews", async () => {
|
|
383
|
+
await seedReview(data, {
|
|
384
|
+
productId: "prod-1",
|
|
385
|
+
status: "approved",
|
|
386
|
+
rating: 5,
|
|
387
|
+
});
|
|
388
|
+
await seedReview(data, {
|
|
389
|
+
productId: "prod-1",
|
|
390
|
+
status: "pending",
|
|
391
|
+
rating: 3,
|
|
392
|
+
});
|
|
393
|
+
await seedReview(data, {
|
|
394
|
+
productId: "prod-1",
|
|
395
|
+
status: "rejected",
|
|
396
|
+
rating: 1,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const result = await simulateListProductReviews(data, "prod-1");
|
|
400
|
+
expect(result.reviews).toHaveLength(1);
|
|
401
|
+
expect(result.reviews[0].rating).toBe(5);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("returns rating summary alongside reviews", async () => {
|
|
405
|
+
await seedReview(data, {
|
|
406
|
+
productId: "prod-1",
|
|
407
|
+
status: "approved",
|
|
408
|
+
rating: 5,
|
|
409
|
+
});
|
|
410
|
+
await seedReview(data, {
|
|
411
|
+
productId: "prod-1",
|
|
412
|
+
status: "approved",
|
|
413
|
+
rating: 3,
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const result = await simulateListProductReviews(data, "prod-1");
|
|
417
|
+
expect(result.summary.count).toBe(2);
|
|
418
|
+
expect(result.summary.average).toBe(4);
|
|
419
|
+
expect(result.summary.distribution["5"]).toBe(1);
|
|
420
|
+
expect(result.summary.distribution["3"]).toBe(1);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("returns empty results for product with no reviews", async () => {
|
|
424
|
+
const result = await simulateListProductReviews(data, "no-reviews");
|
|
425
|
+
expect(result.reviews).toHaveLength(0);
|
|
426
|
+
expect(result.summary.count).toBe(0);
|
|
427
|
+
expect(result.summary.average).toBe(0);
|
|
428
|
+
expect(result.total).toBe(0);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("sorts by highest rating", async () => {
|
|
432
|
+
await seedReview(data, {
|
|
433
|
+
productId: "prod-1",
|
|
434
|
+
status: "approved",
|
|
435
|
+
rating: 2,
|
|
436
|
+
});
|
|
437
|
+
await seedReview(data, {
|
|
438
|
+
productId: "prod-1",
|
|
439
|
+
status: "approved",
|
|
440
|
+
rating: 5,
|
|
441
|
+
});
|
|
442
|
+
await seedReview(data, {
|
|
443
|
+
productId: "prod-1",
|
|
444
|
+
status: "approved",
|
|
445
|
+
rating: 3,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
const result = await simulateListProductReviews(data, "prod-1", {
|
|
449
|
+
sortBy: "highest",
|
|
450
|
+
});
|
|
451
|
+
expect(result.reviews[0].rating).toBe(5);
|
|
452
|
+
expect(result.reviews[2].rating).toBe(2);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("sorts by most helpful", async () => {
|
|
456
|
+
await seedReview(data, {
|
|
457
|
+
productId: "prod-1",
|
|
458
|
+
status: "approved",
|
|
459
|
+
helpfulCount: 1,
|
|
460
|
+
});
|
|
461
|
+
await seedReview(data, {
|
|
462
|
+
productId: "prod-1",
|
|
463
|
+
status: "approved",
|
|
464
|
+
helpfulCount: 10,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const result = await simulateListProductReviews(data, "prod-1", {
|
|
468
|
+
sortBy: "helpful",
|
|
469
|
+
});
|
|
470
|
+
expect(result.reviews[0].helpfulCount).toBe(10);
|
|
471
|
+
expect(result.reviews[1].helpfulCount).toBe(1);
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// ── list-my-reviews ──────────────────────────────────────────────
|
|
476
|
+
|
|
477
|
+
describe("list-my-reviews", () => {
|
|
478
|
+
it("returns 401 for unauthenticated user", async () => {
|
|
479
|
+
const result = await simulateListMyReviews(data);
|
|
480
|
+
expect(result).toEqual({ error: "Unauthorized", status: 401 });
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("returns paginated reviews for authenticated user", async () => {
|
|
484
|
+
await seedReview(data, {
|
|
485
|
+
customerId: "cust-1",
|
|
486
|
+
status: "approved",
|
|
487
|
+
});
|
|
488
|
+
await seedReview(data, {
|
|
489
|
+
customerId: "cust-1",
|
|
490
|
+
status: "pending",
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const result = await simulateListMyReviews(data, {}, "cust-1");
|
|
494
|
+
const res = result as {
|
|
495
|
+
reviews: Review[];
|
|
496
|
+
total: number;
|
|
497
|
+
page: number;
|
|
498
|
+
limit: number;
|
|
499
|
+
pages: number;
|
|
500
|
+
};
|
|
501
|
+
expect(res.total).toBe(2);
|
|
502
|
+
expect(res.page).toBe(1);
|
|
503
|
+
expect(res.pages).toBe(1);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("calculates pages correctly", async () => {
|
|
507
|
+
// Seed 3 reviews for cust-1
|
|
508
|
+
for (let i = 0; i < 3; i++) {
|
|
509
|
+
await seedReview(data, { customerId: "cust-1", status: "approved" });
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const result = await simulateListMyReviews(data, { limit: 2 }, "cust-1");
|
|
513
|
+
const res = result as {
|
|
514
|
+
reviews: Review[];
|
|
515
|
+
total: number;
|
|
516
|
+
pages: number;
|
|
517
|
+
};
|
|
518
|
+
expect(res.total).toBe(3);
|
|
519
|
+
expect(res.pages).toBe(2); // ceil(3/2) = 2
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("does not return another customer's reviews", async () => {
|
|
523
|
+
await seedReview(data, { customerId: "cust-other" });
|
|
524
|
+
|
|
525
|
+
const result = await simulateListMyReviews(data, {}, "cust-1");
|
|
526
|
+
const res = result as { reviews: Review[]; total: number };
|
|
527
|
+
expect(res.total).toBe(0);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// ── mark-helpful ─────────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
describe("mark-helpful", () => {
|
|
534
|
+
it("increments helpful count for anonymous user", async () => {
|
|
535
|
+
const review = await seedReview(data, { status: "approved" });
|
|
536
|
+
|
|
537
|
+
const result = await simulateMarkHelpful(data, review.id);
|
|
538
|
+
expect(result).toMatchObject({
|
|
539
|
+
helpfulCount: 1,
|
|
540
|
+
alreadyVoted: false,
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("anonymous votes are not deduplicated", async () => {
|
|
545
|
+
const review = await seedReview(data, { status: "approved" });
|
|
546
|
+
|
|
547
|
+
await simulateMarkHelpful(data, review.id);
|
|
548
|
+
const result = await simulateMarkHelpful(data, review.id);
|
|
549
|
+
expect(result).toMatchObject({ helpfulCount: 2 });
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("increments helpful count for first authenticated vote", async () => {
|
|
553
|
+
const review = await seedReview(data, { status: "approved" });
|
|
554
|
+
|
|
555
|
+
const result = await simulateMarkHelpful(data, review.id, "voter-1");
|
|
556
|
+
expect(result).toMatchObject({
|
|
557
|
+
helpfulCount: 1,
|
|
558
|
+
alreadyVoted: false,
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("deduplicates authenticated votes", async () => {
|
|
563
|
+
const review = await seedReview(data, { status: "approved" });
|
|
564
|
+
|
|
565
|
+
await simulateMarkHelpful(data, review.id, "voter-1");
|
|
566
|
+
const result = await simulateMarkHelpful(data, review.id, "voter-1");
|
|
567
|
+
expect(result).toMatchObject({
|
|
568
|
+
helpfulCount: 1,
|
|
569
|
+
alreadyVoted: true,
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it("different authenticated voters can each vote", async () => {
|
|
574
|
+
const review = await seedReview(data, { status: "approved" });
|
|
575
|
+
|
|
576
|
+
await simulateMarkHelpful(data, review.id, "voter-1");
|
|
577
|
+
const result = await simulateMarkHelpful(data, review.id, "voter-2");
|
|
578
|
+
expect(result).toMatchObject({
|
|
579
|
+
helpfulCount: 2,
|
|
580
|
+
alreadyVoted: false,
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("returns 404 for nonexistent review", async () => {
|
|
585
|
+
const result = await simulateMarkHelpful(data, "no-such-review");
|
|
586
|
+
expect(result).toEqual({ error: "Review not found", status: 404 });
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it("returns 404 for nonexistent review with auth voter", async () => {
|
|
590
|
+
const result = await simulateMarkHelpful(
|
|
591
|
+
data,
|
|
592
|
+
"no-such-review",
|
|
593
|
+
"voter-1",
|
|
594
|
+
);
|
|
595
|
+
expect(result).toEqual({ error: "Review not found", status: 404 });
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
// ── report-review ────────────────────────────────────────────────
|
|
600
|
+
|
|
601
|
+
describe("report-review", () => {
|
|
602
|
+
it("creates report for existing review", async () => {
|
|
603
|
+
const review = await seedReview(data, { status: "approved" });
|
|
604
|
+
|
|
605
|
+
const result = await simulateReportReview(data, review.id, {
|
|
606
|
+
reason: "spam",
|
|
607
|
+
details: "This looks like spam",
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
const res = result as { report: ReviewReport };
|
|
611
|
+
expect(res.report.reviewId).toBe(review.id);
|
|
612
|
+
expect(res.report.reason).toBe("spam");
|
|
613
|
+
expect(res.report.status).toBe("pending");
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it("returns 404 for nonexistent review", async () => {
|
|
617
|
+
const result = await simulateReportReview(data, "no-such-review", {
|
|
618
|
+
reason: "fake",
|
|
619
|
+
});
|
|
620
|
+
expect(result).toEqual({ error: "Review not found", status: 404 });
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it("allows anonymous reports (no reporterId)", async () => {
|
|
624
|
+
const review = await seedReview(data, { status: "approved" });
|
|
625
|
+
|
|
626
|
+
const result = await simulateReportReview(data, review.id, {
|
|
627
|
+
reason: "offensive",
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
const res = result as { report: ReviewReport };
|
|
631
|
+
expect(res.report.reporterId).toBeUndefined();
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it("includes reporterId for authenticated users", async () => {
|
|
635
|
+
const review = await seedReview(data, { status: "approved" });
|
|
636
|
+
|
|
637
|
+
const result = await simulateReportReview(
|
|
638
|
+
data,
|
|
639
|
+
review.id,
|
|
640
|
+
{ reason: "harassment" },
|
|
641
|
+
"reporter-1",
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
const res = result as { report: ReviewReport };
|
|
645
|
+
expect(res.report.reporterId).toBe("reporter-1");
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
});
|
|
@@ -41,8 +41,9 @@ function formatDate(iso: string): string {
|
|
|
41
41
|
|
|
42
42
|
function extractError(error: Error | null, fallback: string): string {
|
|
43
43
|
if (!error) return fallback;
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
const body = (
|
|
45
|
+
error as Error & { body?: { error?: string | { message?: string } } }
|
|
46
|
+
).body;
|
|
46
47
|
if (typeof body?.error === "string") return body.error;
|
|
47
48
|
if (typeof body?.error?.message === "string") return body.error.message;
|
|
48
49
|
return fallback;
|
|
@@ -33,8 +33,9 @@ function formatDate(iso: string): string {
|
|
|
33
33
|
|
|
34
34
|
function extractError(error: Error | null, fallback: string): string {
|
|
35
35
|
if (!error) return fallback;
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const body = (
|
|
37
|
+
error as Error & { body?: { error?: string | { message?: string } } }
|
|
38
|
+
).body;
|
|
38
39
|
if (typeof body?.error === "string") return body.error;
|
|
39
40
|
if (typeof body?.error?.message === "string") return body.error.message;
|
|
40
41
|
return fallback;
|