@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.
- package/.turbo/turbo-build.log +1 -0
- package/AGENTS.md +54 -18
- package/README.md +150 -41
- package/dist/__tests__/controllers.test.d.ts +2 -0
- package/dist/__tests__/controllers.test.d.ts.map +1 -0
- package/dist/__tests__/endpoint-security.test.d.ts +2 -0
- package/dist/__tests__/endpoint-security.test.d.ts.map +1 -0
- package/dist/__tests__/new-features.test.d.ts +2 -0
- package/dist/__tests__/new-features.test.d.ts.map +1 -0
- package/dist/__tests__/service-impl.test.d.ts +2 -0
- package/dist/__tests__/service-impl.test.d.ts.map +1 -0
- package/dist/admin/components/index.d.ts +4 -0
- package/dist/admin/components/index.d.ts.map +1 -0
- package/dist/admin/components/review-analytics.d.ts +2 -0
- package/dist/admin/components/review-analytics.d.ts.map +1 -0
- package/dist/admin/components/review-list.d.ts +2 -0
- package/dist/admin/components/review-list.d.ts.map +1 -0
- package/dist/admin/components/review-moderation.d.ts +6 -0
- package/dist/admin/components/review-moderation.d.ts.map +1 -0
- package/dist/admin/endpoints/approve-review.d.ts +16 -0
- package/dist/admin/endpoints/approve-review.d.ts.map +1 -0
- package/dist/admin/endpoints/delete-review.d.ts +16 -0
- package/dist/admin/endpoints/delete-review.d.ts.map +1 -0
- package/dist/admin/endpoints/get-review.d.ts +16 -0
- package/dist/admin/endpoints/get-review.d.ts.map +1 -0
- package/dist/admin/endpoints/index.d.ts +167 -0
- package/dist/admin/endpoints/index.d.ts.map +1 -0
- package/dist/admin/endpoints/list-reports.d.ts +17 -0
- package/dist/admin/endpoints/list-reports.d.ts.map +1 -0
- package/dist/admin/endpoints/list-review-requests.d.ts +11 -0
- package/dist/admin/endpoints/list-review-requests.d.ts.map +1 -0
- package/dist/admin/endpoints/list-reviews.d.ts +18 -0
- package/dist/admin/endpoints/list-reviews.d.ts.map +1 -0
- package/dist/admin/endpoints/reject-review.d.ts +16 -0
- package/dist/admin/endpoints/reject-review.d.ts.map +1 -0
- package/dist/admin/endpoints/respond-review.d.ts +19 -0
- package/dist/admin/endpoints/respond-review.d.ts.map +1 -0
- package/dist/admin/endpoints/review-analytics.d.ts +6 -0
- package/dist/admin/endpoints/review-analytics.d.ts.map +1 -0
- package/dist/admin/endpoints/review-request-stats.d.ts +6 -0
- package/dist/admin/endpoints/review-request-stats.d.ts.map +1 -0
- package/dist/admin/endpoints/send-review-request.d.ts +23 -0
- package/dist/admin/endpoints/send-review-request.d.ts.map +1 -0
- package/dist/admin/endpoints/update-report.d.ts +22 -0
- package/dist/admin/endpoints/update-report.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/schema.d.ts +136 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/service-impl.d.ts +6 -0
- package/dist/service-impl.d.ts.map +1 -0
- package/dist/service.d.ts +149 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/store/components/_hooks.d.ts +6 -0
- package/dist/store/components/_hooks.d.ts.map +1 -0
- package/dist/store/components/_utils.d.ts +3 -0
- package/dist/store/components/_utils.d.ts.map +1 -0
- package/dist/store/components/distribution-bars.d.ts +5 -0
- package/dist/store/components/distribution-bars.d.ts.map +1 -0
- package/dist/store/components/index.d.ts +18 -0
- package/dist/store/components/index.d.ts.map +1 -0
- package/dist/store/components/product-reviews.d.ts +5 -0
- package/dist/store/components/product-reviews.d.ts.map +1 -0
- package/dist/store/components/review-card.d.ts +18 -0
- package/dist/store/components/review-card.d.ts.map +1 -0
- package/dist/store/components/review-form.d.ts +5 -0
- package/dist/store/components/review-form.d.ts.map +1 -0
- package/dist/store/components/reviews-summary.d.ts +4 -0
- package/dist/store/components/reviews-summary.d.ts.map +1 -0
- package/dist/store/components/star-display.d.ts +5 -0
- package/dist/store/components/star-display.d.ts.map +1 -0
- package/dist/store/components/star-picker.d.ts +5 -0
- package/dist/store/components/star-picker.d.ts.map +1 -0
- package/dist/store/endpoints/index.d.ts +116 -0
- package/dist/store/endpoints/index.d.ts.map +1 -0
- package/dist/store/endpoints/list-my-reviews.d.ts +30 -0
- package/dist/store/endpoints/list-my-reviews.d.ts.map +1 -0
- package/dist/store/endpoints/list-product-reviews.d.ts +23 -0
- package/dist/store/endpoints/list-product-reviews.d.ts.map +1 -0
- package/dist/store/endpoints/mark-helpful.d.ts +18 -0
- package/dist/store/endpoints/mark-helpful.d.ts.map +1 -0
- package/dist/store/endpoints/report-review.d.ts +27 -0
- package/dist/store/endpoints/report-review.d.ts.map +1 -0
- package/dist/store/endpoints/submit-review.d.ts +25 -0
- package/dist/store/endpoints/submit-review.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/controllers.test.ts +1074 -0
- package/src/__tests__/endpoint-security.test.ts +559 -0
- package/src/__tests__/new-features.test.ts +618 -0
- package/src/__tests__/service-impl.test.ts +1 -1
- package/src/admin/endpoints/index.ts +4 -0
- package/src/admin/endpoints/list-reports.ts +25 -0
- package/src/admin/endpoints/update-report.ts +22 -0
- package/src/index.ts +7 -1
- package/src/schema.ts +28 -0
- package/src/service-impl.ts +151 -3
- package/src/service.ts +61 -0
- package/src/store/endpoints/index.ts +2 -0
- package/src/store/endpoints/list-my-reviews.ts +1 -1
- package/src/store/endpoints/list-product-reviews.ts +6 -2
- package/src/store/endpoints/mark-helpful.ts +21 -2
- package/src/store/endpoints/report-review.ts +38 -0
- package/src/store/endpoints/submit-review.ts +33 -7
- package/COMPONENTS.md +0 -34
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import { createMockDataService } from "@86d-app/core/test-utils";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import type { ReviewController } from "../service";
|
|
4
|
+
import { createReviewController } from "../service-impl";
|
|
5
|
+
|
|
6
|
+
// ── Helper ──────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
function makeReview(overrides: Record<string, unknown> = {}) {
|
|
9
|
+
return {
|
|
10
|
+
productId: "prod_1",
|
|
11
|
+
authorName: "Alice",
|
|
12
|
+
authorEmail: "alice@test.com",
|
|
13
|
+
rating: 5,
|
|
14
|
+
body: "Great product!",
|
|
15
|
+
...overrides,
|
|
16
|
+
} as Parameters<ReviewController["createReview"]>[0];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
describe("reviews new features", () => {
|
|
22
|
+
let mockData: ReturnType<typeof createMockDataService>;
|
|
23
|
+
let controller: ReturnType<typeof createReviewController>;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
mockData = createMockDataService();
|
|
27
|
+
controller = createReviewController(mockData);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ── Photo/Image Reviews ─────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
describe("photo reviews", () => {
|
|
33
|
+
it("creates a review with images", async () => {
|
|
34
|
+
const review = await controller.createReview(
|
|
35
|
+
makeReview({
|
|
36
|
+
images: [
|
|
37
|
+
{ url: "https://example.com/photo1.jpg", caption: "Front view" },
|
|
38
|
+
{ url: "https://example.com/photo2.jpg" },
|
|
39
|
+
],
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
expect(review.images).toHaveLength(2);
|
|
43
|
+
expect(review.images?.[0]?.url).toBe("https://example.com/photo1.jpg");
|
|
44
|
+
expect(review.images?.[0]?.caption).toBe("Front view");
|
|
45
|
+
expect(review.images?.[1]?.caption).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("creates a review without images (backward compatible)", async () => {
|
|
49
|
+
const review = await controller.createReview(makeReview());
|
|
50
|
+
expect(review.images).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("creates a review with empty images array (no images stored)", async () => {
|
|
54
|
+
const review = await controller.createReview(makeReview({ images: [] }));
|
|
55
|
+
expect(review.images).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("persists images and retrieves them", async () => {
|
|
59
|
+
const review = await controller.createReview(
|
|
60
|
+
makeReview({
|
|
61
|
+
images: [{ url: "https://example.com/pic.jpg", caption: "My photo" }],
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
const fetched = await controller.getReview(review.id);
|
|
65
|
+
expect(fetched?.images).toHaveLength(1);
|
|
66
|
+
expect(fetched?.images?.[0]?.url).toBe("https://example.com/pic.jpg");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("images appear in product review listing", async () => {
|
|
70
|
+
const autoCtrl = createReviewController(mockData, {
|
|
71
|
+
autoApprove: true,
|
|
72
|
+
});
|
|
73
|
+
await autoCtrl.createReview(
|
|
74
|
+
makeReview({
|
|
75
|
+
images: [{ url: "https://example.com/review-pic.jpg" }],
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
const reviews = await autoCtrl.listReviewsByProduct("prod_1", {
|
|
79
|
+
approvedOnly: true,
|
|
80
|
+
});
|
|
81
|
+
expect(reviews[0]?.images).toHaveLength(1);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── Duplicate Review Prevention ─────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
describe("duplicate review prevention", () => {
|
|
88
|
+
it("hasReviewedProduct returns false when customer has not reviewed product", async () => {
|
|
89
|
+
const result = await controller.hasReviewedProduct("cust_1", "prod_1");
|
|
90
|
+
expect(result).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("hasReviewedProduct returns true when customer has reviewed product", async () => {
|
|
94
|
+
await controller.createReview(
|
|
95
|
+
makeReview({ customerId: "cust_1", productId: "prod_1" }),
|
|
96
|
+
);
|
|
97
|
+
const result = await controller.hasReviewedProduct("cust_1", "prod_1");
|
|
98
|
+
expect(result).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("hasReviewedProduct is product-specific", async () => {
|
|
102
|
+
await controller.createReview(
|
|
103
|
+
makeReview({ customerId: "cust_1", productId: "prod_1" }),
|
|
104
|
+
);
|
|
105
|
+
const result = await controller.hasReviewedProduct("cust_1", "prod_2");
|
|
106
|
+
expect(result).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("hasReviewedProduct is customer-specific", async () => {
|
|
110
|
+
await controller.createReview(
|
|
111
|
+
makeReview({ customerId: "cust_1", productId: "prod_1" }),
|
|
112
|
+
);
|
|
113
|
+
const result = await controller.hasReviewedProduct("cust_2", "prod_1");
|
|
114
|
+
expect(result).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("hasReviewedProduct returns true regardless of review status", async () => {
|
|
118
|
+
const review = await controller.createReview(
|
|
119
|
+
makeReview({ customerId: "cust_1", productId: "prod_1" }),
|
|
120
|
+
);
|
|
121
|
+
await controller.updateReviewStatus(review.id, "rejected");
|
|
122
|
+
const result = await controller.hasReviewedProduct("cust_1", "prod_1");
|
|
123
|
+
expect(result).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── Vote-Based Helpfulness ──────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
describe("vote-based helpfulness (voteHelpful)", () => {
|
|
130
|
+
it("records a vote and increments helpfulCount", async () => {
|
|
131
|
+
const review = await controller.createReview(makeReview());
|
|
132
|
+
const result = await controller.voteHelpful(review.id, "voter_1");
|
|
133
|
+
expect(result).not.toBeNull();
|
|
134
|
+
expect(result?.review.helpfulCount).toBe(1);
|
|
135
|
+
expect(result?.alreadyVoted).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("prevents duplicate votes from same voter", async () => {
|
|
139
|
+
const review = await controller.createReview(makeReview());
|
|
140
|
+
await controller.voteHelpful(review.id, "voter_1");
|
|
141
|
+
const second = await controller.voteHelpful(review.id, "voter_1");
|
|
142
|
+
expect(second?.alreadyVoted).toBe(true);
|
|
143
|
+
expect(second?.review.helpfulCount).toBe(1); // not incremented
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("allows different voters on same review", async () => {
|
|
147
|
+
const review = await controller.createReview(makeReview());
|
|
148
|
+
await controller.voteHelpful(review.id, "voter_1");
|
|
149
|
+
const second = await controller.voteHelpful(review.id, "voter_2");
|
|
150
|
+
expect(second?.alreadyVoted).toBe(false);
|
|
151
|
+
expect(second?.review.helpfulCount).toBe(2);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("returns null for non-existent review", async () => {
|
|
155
|
+
const result = await controller.voteHelpful("nonexistent_id", "voter_1");
|
|
156
|
+
expect(result).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("allows same voter on different reviews", async () => {
|
|
160
|
+
const r1 = await controller.createReview(
|
|
161
|
+
makeReview({ productId: "prod_1" }),
|
|
162
|
+
);
|
|
163
|
+
const r2 = await controller.createReview(
|
|
164
|
+
makeReview({
|
|
165
|
+
productId: "prod_2",
|
|
166
|
+
authorEmail: "bob@test.com",
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
const v1 = await controller.voteHelpful(r1.id, "voter_1");
|
|
170
|
+
const v2 = await controller.voteHelpful(r2.id, "voter_1");
|
|
171
|
+
expect(v1?.alreadyVoted).toBe(false);
|
|
172
|
+
expect(v2?.alreadyVoted).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("vote dedup does not affect markHelpful (anonymous)", async () => {
|
|
176
|
+
const review = await controller.createReview(makeReview());
|
|
177
|
+
await controller.voteHelpful(review.id, "voter_1");
|
|
178
|
+
// markHelpful still works (anonymous increment)
|
|
179
|
+
const result = await controller.markHelpful(review.id);
|
|
180
|
+
expect(result?.helpfulCount).toBe(2);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ── Review Sorting ──────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
describe("review sorting", () => {
|
|
187
|
+
let autoCtrl: ReturnType<typeof createReviewController>;
|
|
188
|
+
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
autoCtrl = createReviewController(mockData, { autoApprove: true });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("sorts by recent (newest first)", async () => {
|
|
194
|
+
// Manually set different createdAt timestamps to guarantee ordering
|
|
195
|
+
const r1 = await autoCtrl.createReview(
|
|
196
|
+
makeReview({ authorEmail: "a@test.com" }),
|
|
197
|
+
);
|
|
198
|
+
const r2 = await autoCtrl.createReview(
|
|
199
|
+
makeReview({ authorEmail: "b@test.com" }),
|
|
200
|
+
);
|
|
201
|
+
const r3 = await autoCtrl.createReview(
|
|
202
|
+
makeReview({ authorEmail: "c@test.com" }),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// Patch timestamps to ensure distinct ordering
|
|
206
|
+
const stored1 = await autoCtrl.getReview(r1.id);
|
|
207
|
+
const stored2 = await autoCtrl.getReview(r2.id);
|
|
208
|
+
const stored3 = await autoCtrl.getReview(r3.id);
|
|
209
|
+
if (stored1 && stored2 && stored3) {
|
|
210
|
+
await mockData.upsert("review", r1.id, {
|
|
211
|
+
...(stored1 as unknown as Record<string, unknown>),
|
|
212
|
+
createdAt: new Date("2024-01-01"),
|
|
213
|
+
});
|
|
214
|
+
await mockData.upsert("review", r2.id, {
|
|
215
|
+
...(stored2 as unknown as Record<string, unknown>),
|
|
216
|
+
createdAt: new Date("2024-06-01"),
|
|
217
|
+
});
|
|
218
|
+
await mockData.upsert("review", r3.id, {
|
|
219
|
+
...(stored3 as unknown as Record<string, unknown>),
|
|
220
|
+
createdAt: new Date("2024-12-01"),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const reviews = await autoCtrl.listReviewsByProduct("prod_1", {
|
|
225
|
+
approvedOnly: true,
|
|
226
|
+
sortBy: "recent",
|
|
227
|
+
});
|
|
228
|
+
expect(reviews[0]?.id).toBe(r3.id);
|
|
229
|
+
expect(reviews[2]?.id).toBe(r1.id);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("sorts by oldest first", async () => {
|
|
233
|
+
const r1 = await autoCtrl.createReview(
|
|
234
|
+
makeReview({ authorEmail: "a@test.com" }),
|
|
235
|
+
);
|
|
236
|
+
const r2 = await autoCtrl.createReview(
|
|
237
|
+
makeReview({ authorEmail: "b@test.com" }),
|
|
238
|
+
);
|
|
239
|
+
const r3 = await autoCtrl.createReview(
|
|
240
|
+
makeReview({ authorEmail: "c@test.com" }),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const stored1 = await autoCtrl.getReview(r1.id);
|
|
244
|
+
const stored2 = await autoCtrl.getReview(r2.id);
|
|
245
|
+
const stored3 = await autoCtrl.getReview(r3.id);
|
|
246
|
+
if (stored1 && stored2 && stored3) {
|
|
247
|
+
await mockData.upsert("review", r1.id, {
|
|
248
|
+
...(stored1 as unknown as Record<string, unknown>),
|
|
249
|
+
createdAt: new Date("2024-01-01"),
|
|
250
|
+
});
|
|
251
|
+
await mockData.upsert("review", r2.id, {
|
|
252
|
+
...(stored2 as unknown as Record<string, unknown>),
|
|
253
|
+
createdAt: new Date("2024-06-01"),
|
|
254
|
+
});
|
|
255
|
+
await mockData.upsert("review", r3.id, {
|
|
256
|
+
...(stored3 as unknown as Record<string, unknown>),
|
|
257
|
+
createdAt: new Date("2024-12-01"),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const reviews = await autoCtrl.listReviewsByProduct("prod_1", {
|
|
262
|
+
approvedOnly: true,
|
|
263
|
+
sortBy: "oldest",
|
|
264
|
+
});
|
|
265
|
+
expect(reviews[0]?.id).toBe(r1.id);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("sorts by highest rating", async () => {
|
|
269
|
+
await autoCtrl.createReview(
|
|
270
|
+
makeReview({ rating: 3, authorEmail: "a@test.com" }),
|
|
271
|
+
);
|
|
272
|
+
await autoCtrl.createReview(
|
|
273
|
+
makeReview({ rating: 5, authorEmail: "b@test.com" }),
|
|
274
|
+
);
|
|
275
|
+
await autoCtrl.createReview(
|
|
276
|
+
makeReview({ rating: 1, authorEmail: "c@test.com" }),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const reviews = await autoCtrl.listReviewsByProduct("prod_1", {
|
|
280
|
+
approvedOnly: true,
|
|
281
|
+
sortBy: "highest",
|
|
282
|
+
});
|
|
283
|
+
expect(reviews[0]?.rating).toBe(5);
|
|
284
|
+
expect(reviews[1]?.rating).toBe(3);
|
|
285
|
+
expect(reviews[2]?.rating).toBe(1);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("sorts by lowest rating", async () => {
|
|
289
|
+
await autoCtrl.createReview(
|
|
290
|
+
makeReview({ rating: 3, authorEmail: "a@test.com" }),
|
|
291
|
+
);
|
|
292
|
+
await autoCtrl.createReview(
|
|
293
|
+
makeReview({ rating: 5, authorEmail: "b@test.com" }),
|
|
294
|
+
);
|
|
295
|
+
await autoCtrl.createReview(
|
|
296
|
+
makeReview({ rating: 1, authorEmail: "c@test.com" }),
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const reviews = await autoCtrl.listReviewsByProduct("prod_1", {
|
|
300
|
+
approvedOnly: true,
|
|
301
|
+
sortBy: "lowest",
|
|
302
|
+
});
|
|
303
|
+
expect(reviews[0]?.rating).toBe(1);
|
|
304
|
+
expect(reviews[2]?.rating).toBe(5);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("sorts by most helpful", async () => {
|
|
308
|
+
const r1 = await autoCtrl.createReview(
|
|
309
|
+
makeReview({ authorEmail: "a@test.com" }),
|
|
310
|
+
);
|
|
311
|
+
const r2 = await autoCtrl.createReview(
|
|
312
|
+
makeReview({ authorEmail: "b@test.com" }),
|
|
313
|
+
);
|
|
314
|
+
await autoCtrl.createReview(makeReview({ authorEmail: "c@test.com" }));
|
|
315
|
+
|
|
316
|
+
await autoCtrl.markHelpful(r2.id);
|
|
317
|
+
await autoCtrl.markHelpful(r2.id);
|
|
318
|
+
await autoCtrl.markHelpful(r1.id);
|
|
319
|
+
|
|
320
|
+
const reviews = await autoCtrl.listReviewsByProduct("prod_1", {
|
|
321
|
+
approvedOnly: true,
|
|
322
|
+
sortBy: "helpful",
|
|
323
|
+
});
|
|
324
|
+
expect(reviews[0]?.id).toBe(r2.id);
|
|
325
|
+
expect(reviews[0]?.helpfulCount).toBe(2);
|
|
326
|
+
expect(reviews[1]?.id).toBe(r1.id);
|
|
327
|
+
expect(reviews[1]?.helpfulCount).toBe(1);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("sorting works with pagination", async () => {
|
|
331
|
+
await autoCtrl.createReview(
|
|
332
|
+
makeReview({ rating: 1, authorEmail: "a@test.com" }),
|
|
333
|
+
);
|
|
334
|
+
await autoCtrl.createReview(
|
|
335
|
+
makeReview({ rating: 5, authorEmail: "b@test.com" }),
|
|
336
|
+
);
|
|
337
|
+
await autoCtrl.createReview(
|
|
338
|
+
makeReview({ rating: 3, authorEmail: "c@test.com" }),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const page1 = await autoCtrl.listReviewsByProduct("prod_1", {
|
|
342
|
+
approvedOnly: true,
|
|
343
|
+
sortBy: "highest",
|
|
344
|
+
take: 2,
|
|
345
|
+
skip: 0,
|
|
346
|
+
});
|
|
347
|
+
expect(page1).toHaveLength(2);
|
|
348
|
+
expect(page1[0]?.rating).toBe(5);
|
|
349
|
+
expect(page1[1]?.rating).toBe(3);
|
|
350
|
+
|
|
351
|
+
const page2 = await autoCtrl.listReviewsByProduct("prod_1", {
|
|
352
|
+
approvedOnly: true,
|
|
353
|
+
sortBy: "highest",
|
|
354
|
+
take: 2,
|
|
355
|
+
skip: 2,
|
|
356
|
+
});
|
|
357
|
+
expect(page2).toHaveLength(1);
|
|
358
|
+
expect(page2[0]?.rating).toBe(1);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("without sortBy returns unsorted (default order)", async () => {
|
|
362
|
+
await autoCtrl.createReview(
|
|
363
|
+
makeReview({ rating: 3, authorEmail: "a@test.com" }),
|
|
364
|
+
);
|
|
365
|
+
await autoCtrl.createReview(
|
|
366
|
+
makeReview({ rating: 5, authorEmail: "b@test.com" }),
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
const reviews = await autoCtrl.listReviewsByProduct("prod_1", {
|
|
370
|
+
approvedOnly: true,
|
|
371
|
+
});
|
|
372
|
+
expect(reviews).toHaveLength(2);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// ── Review Reporting ────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
describe("review reporting", () => {
|
|
379
|
+
it("creates a report for a review", async () => {
|
|
380
|
+
const review = await controller.createReview(makeReview());
|
|
381
|
+
const report = await controller.reportReview({
|
|
382
|
+
reviewId: review.id,
|
|
383
|
+
reporterId: "reporter_1",
|
|
384
|
+
reason: "spam",
|
|
385
|
+
});
|
|
386
|
+
expect(report.id).toBeDefined();
|
|
387
|
+
expect(report.reviewId).toBe(review.id);
|
|
388
|
+
expect(report.reporterId).toBe("reporter_1");
|
|
389
|
+
expect(report.reason).toBe("spam");
|
|
390
|
+
expect(report.status).toBe("pending");
|
|
391
|
+
expect(report.createdAt).toBeInstanceOf(Date);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("creates a report with optional details", async () => {
|
|
395
|
+
const review = await controller.createReview(makeReview());
|
|
396
|
+
const report = await controller.reportReview({
|
|
397
|
+
reviewId: review.id,
|
|
398
|
+
reason: "offensive",
|
|
399
|
+
details: "Contains inappropriate language",
|
|
400
|
+
});
|
|
401
|
+
expect(report.details).toBe("Contains inappropriate language");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("creates a report without reporterId (anonymous)", async () => {
|
|
405
|
+
const review = await controller.createReview(makeReview());
|
|
406
|
+
const report = await controller.reportReview({
|
|
407
|
+
reviewId: review.id,
|
|
408
|
+
reason: "fake",
|
|
409
|
+
});
|
|
410
|
+
expect(report.reporterId).toBeUndefined();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("allows multiple reports on the same review", async () => {
|
|
414
|
+
const review = await controller.createReview(makeReview());
|
|
415
|
+
await controller.reportReview({
|
|
416
|
+
reviewId: review.id,
|
|
417
|
+
reporterId: "reporter_1",
|
|
418
|
+
reason: "spam",
|
|
419
|
+
});
|
|
420
|
+
await controller.reportReview({
|
|
421
|
+
reviewId: review.id,
|
|
422
|
+
reporterId: "reporter_2",
|
|
423
|
+
reason: "offensive",
|
|
424
|
+
});
|
|
425
|
+
const reports = await controller.listReports({
|
|
426
|
+
reviewId: review.id,
|
|
427
|
+
});
|
|
428
|
+
expect(reports).toHaveLength(2);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("lists reports filtered by status", async () => {
|
|
432
|
+
const review = await controller.createReview(makeReview());
|
|
433
|
+
const r1 = await controller.reportReview({
|
|
434
|
+
reviewId: review.id,
|
|
435
|
+
reason: "spam",
|
|
436
|
+
});
|
|
437
|
+
await controller.reportReview({
|
|
438
|
+
reviewId: review.id,
|
|
439
|
+
reason: "fake",
|
|
440
|
+
});
|
|
441
|
+
await controller.updateReportStatus(r1.id, "resolved");
|
|
442
|
+
|
|
443
|
+
const pending = await controller.listReports({ status: "pending" });
|
|
444
|
+
expect(pending).toHaveLength(1);
|
|
445
|
+
expect(pending[0]?.reason).toBe("fake");
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("updates report status to resolved", async () => {
|
|
449
|
+
const review = await controller.createReview(makeReview());
|
|
450
|
+
const report = await controller.reportReview({
|
|
451
|
+
reviewId: review.id,
|
|
452
|
+
reason: "spam",
|
|
453
|
+
});
|
|
454
|
+
const updated = await controller.updateReportStatus(
|
|
455
|
+
report.id,
|
|
456
|
+
"resolved",
|
|
457
|
+
);
|
|
458
|
+
expect(updated?.status).toBe("resolved");
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("updates report status to dismissed", async () => {
|
|
462
|
+
const review = await controller.createReview(makeReview());
|
|
463
|
+
const report = await controller.reportReview({
|
|
464
|
+
reviewId: review.id,
|
|
465
|
+
reason: "spam",
|
|
466
|
+
});
|
|
467
|
+
const updated = await controller.updateReportStatus(
|
|
468
|
+
report.id,
|
|
469
|
+
"dismissed",
|
|
470
|
+
);
|
|
471
|
+
expect(updated?.status).toBe("dismissed");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("returns null when updating non-existent report", async () => {
|
|
475
|
+
const result = await controller.updateReportStatus(
|
|
476
|
+
"nonexistent",
|
|
477
|
+
"resolved",
|
|
478
|
+
);
|
|
479
|
+
expect(result).toBeNull();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("getReportCount returns pending report count", async () => {
|
|
483
|
+
const review = await controller.createReview(makeReview());
|
|
484
|
+
expect(await controller.getReportCount(review.id)).toBe(0);
|
|
485
|
+
|
|
486
|
+
const r1 = await controller.reportReview({
|
|
487
|
+
reviewId: review.id,
|
|
488
|
+
reason: "spam",
|
|
489
|
+
});
|
|
490
|
+
await controller.reportReview({
|
|
491
|
+
reviewId: review.id,
|
|
492
|
+
reason: "fake",
|
|
493
|
+
});
|
|
494
|
+
expect(await controller.getReportCount(review.id)).toBe(2);
|
|
495
|
+
|
|
496
|
+
await controller.updateReportStatus(r1.id, "resolved");
|
|
497
|
+
expect(await controller.getReportCount(review.id)).toBe(1);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("reports are review-specific", async () => {
|
|
501
|
+
const r1 = await controller.createReview(
|
|
502
|
+
makeReview({ productId: "prod_1" }),
|
|
503
|
+
);
|
|
504
|
+
const r2 = await controller.createReview(
|
|
505
|
+
makeReview({
|
|
506
|
+
productId: "prod_2",
|
|
507
|
+
authorEmail: "bob@test.com",
|
|
508
|
+
}),
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
await controller.reportReview({
|
|
512
|
+
reviewId: r1.id,
|
|
513
|
+
reason: "spam",
|
|
514
|
+
});
|
|
515
|
+
await controller.reportReview({
|
|
516
|
+
reviewId: r2.id,
|
|
517
|
+
reason: "fake",
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
const r1Reports = await controller.listReports({
|
|
521
|
+
reviewId: r1.id,
|
|
522
|
+
});
|
|
523
|
+
expect(r1Reports).toHaveLength(1);
|
|
524
|
+
expect(r1Reports[0]?.reason).toBe("spam");
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("listReports supports pagination", async () => {
|
|
528
|
+
const review = await controller.createReview(makeReview());
|
|
529
|
+
for (let i = 0; i < 5; i++) {
|
|
530
|
+
await controller.reportReview({
|
|
531
|
+
reviewId: review.id,
|
|
532
|
+
reporterId: `reporter_${i}`,
|
|
533
|
+
reason: "spam",
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
const page = await controller.listReports({ take: 2 });
|
|
537
|
+
expect(page).toHaveLength(2);
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// ── Analytics with Reports ──────────────────────────────────────────
|
|
542
|
+
|
|
543
|
+
describe("analytics with reports", () => {
|
|
544
|
+
it("analytics includes reportedCount", async () => {
|
|
545
|
+
const r1 = await controller.createReview(
|
|
546
|
+
makeReview({ authorEmail: "a@test.com" }),
|
|
547
|
+
);
|
|
548
|
+
await controller.createReview(makeReview({ authorEmail: "b@test.com" }));
|
|
549
|
+
|
|
550
|
+
await controller.reportReview({
|
|
551
|
+
reviewId: r1.id,
|
|
552
|
+
reason: "spam",
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
const analytics = await controller.getReviewAnalytics();
|
|
556
|
+
expect(analytics.reportedCount).toBe(1);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("reportedCount only counts unique reviews with pending reports", async () => {
|
|
560
|
+
const r1 = await controller.createReview(
|
|
561
|
+
makeReview({ authorEmail: "a@test.com" }),
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
await controller.reportReview({
|
|
565
|
+
reviewId: r1.id,
|
|
566
|
+
reporterId: "rep_1",
|
|
567
|
+
reason: "spam",
|
|
568
|
+
});
|
|
569
|
+
await controller.reportReview({
|
|
570
|
+
reviewId: r1.id,
|
|
571
|
+
reporterId: "rep_2",
|
|
572
|
+
reason: "fake",
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const analytics = await controller.getReviewAnalytics();
|
|
576
|
+
expect(analytics.reportedCount).toBe(1); // same review, counted once
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it("resolved reports not counted in reportedCount", async () => {
|
|
580
|
+
const r1 = await controller.createReview(
|
|
581
|
+
makeReview({ authorEmail: "a@test.com" }),
|
|
582
|
+
);
|
|
583
|
+
const report = await controller.reportReview({
|
|
584
|
+
reviewId: r1.id,
|
|
585
|
+
reason: "spam",
|
|
586
|
+
});
|
|
587
|
+
await controller.updateReportStatus(report.id, "resolved");
|
|
588
|
+
|
|
589
|
+
const analytics = await controller.getReviewAnalytics();
|
|
590
|
+
expect(analytics.reportedCount).toBe(0);
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// ── Module Factory ──────────────────────────────────────────────────
|
|
595
|
+
|
|
596
|
+
describe("module factory", () => {
|
|
597
|
+
it("exports new types in module type exports", async () => {
|
|
598
|
+
// Import the module to verify it compiles and exports
|
|
599
|
+
const mod = await import("../index");
|
|
600
|
+
const module = mod.default();
|
|
601
|
+
expect(module.id).toBe("reviews");
|
|
602
|
+
expect(module.version).toBe("0.0.2");
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("declares review.reported event", async () => {
|
|
606
|
+
const mod = await import("../index");
|
|
607
|
+
const module = mod.default();
|
|
608
|
+
expect(module.events?.emits).toContain("review.reported");
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("schema includes reviewVote and reviewReport tables", async () => {
|
|
612
|
+
const mod = await import("../index");
|
|
613
|
+
const module = mod.default();
|
|
614
|
+
expect(module.schema).toHaveProperty("reviewVote");
|
|
615
|
+
expect(module.schema).toHaveProperty("reviewReport");
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
});
|
|
@@ -1333,7 +1333,7 @@ describe("reviews module factory", () => {
|
|
|
1333
1333
|
it("returns a module with correct id and version", () => {
|
|
1334
1334
|
const mod = reviewsModule();
|
|
1335
1335
|
expect(mod.id).toBe("reviews");
|
|
1336
|
-
expect(mod.version).toBe("0.0.
|
|
1336
|
+
expect(mod.version).toBe("0.0.2");
|
|
1337
1337
|
});
|
|
1338
1338
|
|
|
1339
1339
|
it("exports the reviews schema", () => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { approveReview } from "./approve-review";
|
|
2
2
|
import { deleteReview } from "./delete-review";
|
|
3
3
|
import { getReview } from "./get-review";
|
|
4
|
+
import { listReports } from "./list-reports";
|
|
4
5
|
import { listReviewRequests } from "./list-review-requests";
|
|
5
6
|
import { listReviews } from "./list-reviews";
|
|
6
7
|
import { rejectReview } from "./reject-review";
|
|
@@ -8,10 +9,13 @@ import { respondReview } from "./respond-review";
|
|
|
8
9
|
import { reviewAnalytics } from "./review-analytics";
|
|
9
10
|
import { reviewRequestStats } from "./review-request-stats";
|
|
10
11
|
import { sendReviewRequest } from "./send-review-request";
|
|
12
|
+
import { updateReport } from "./update-report";
|
|
11
13
|
|
|
12
14
|
export const adminEndpoints = {
|
|
13
15
|
"/admin/reviews": listReviews,
|
|
14
16
|
"/admin/reviews/analytics": reviewAnalytics,
|
|
17
|
+
"/admin/reviews/reports": listReports,
|
|
18
|
+
"/admin/reviews/reports/:id/update": updateReport,
|
|
15
19
|
"/admin/reviews/requests": listReviewRequests,
|
|
16
20
|
"/admin/reviews/request-stats": reviewRequestStats,
|
|
17
21
|
"/admin/reviews/send-request": sendReviewRequest,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createAdminEndpoint, z } from "@86d-app/core";
|
|
2
|
+
import type { ReportStatus, ReviewController } from "../../service";
|
|
3
|
+
|
|
4
|
+
export const listReports = createAdminEndpoint(
|
|
5
|
+
"/admin/reviews/reports",
|
|
6
|
+
{
|
|
7
|
+
method: "GET",
|
|
8
|
+
query: z.object({
|
|
9
|
+
status: z.enum(["pending", "resolved", "dismissed"]).optional(),
|
|
10
|
+
reviewId: z.string().max(200).optional(),
|
|
11
|
+
take: z.coerce.number().int().min(1).max(100).optional(),
|
|
12
|
+
skip: z.coerce.number().int().min(0).optional(),
|
|
13
|
+
}),
|
|
14
|
+
},
|
|
15
|
+
async (ctx) => {
|
|
16
|
+
const controller = ctx.context.controllers.reviews as ReviewController;
|
|
17
|
+
const reports = await controller.listReports({
|
|
18
|
+
status: ctx.query.status as ReportStatus | undefined,
|
|
19
|
+
reviewId: ctx.query.reviewId,
|
|
20
|
+
take: ctx.query.take ?? 50,
|
|
21
|
+
skip: ctx.query.skip ?? 0,
|
|
22
|
+
});
|
|
23
|
+
return { reports };
|
|
24
|
+
},
|
|
25
|
+
);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createAdminEndpoint, z } from "@86d-app/core";
|
|
2
|
+
import type { ReportStatus, ReviewController } from "../../service";
|
|
3
|
+
|
|
4
|
+
export const updateReport = createAdminEndpoint(
|
|
5
|
+
"/admin/reviews/reports/:id/update",
|
|
6
|
+
{
|
|
7
|
+
method: "PUT",
|
|
8
|
+
params: z.object({ id: z.string() }),
|
|
9
|
+
body: z.object({
|
|
10
|
+
status: z.enum(["resolved", "dismissed"]),
|
|
11
|
+
}),
|
|
12
|
+
},
|
|
13
|
+
async (ctx) => {
|
|
14
|
+
const controller = ctx.context.controllers.reviews as ReviewController;
|
|
15
|
+
const report = await controller.updateReportStatus(
|
|
16
|
+
ctx.params.id,
|
|
17
|
+
ctx.body.status as ReportStatus,
|
|
18
|
+
);
|
|
19
|
+
if (!report) return { error: "Report not found", status: 404 };
|
|
20
|
+
return { report };
|
|
21
|
+
},
|
|
22
|
+
);
|