Skip to content

Commit 154b978

Browse files
Implement product review statistics and complete review support
Co-authored-by: mvantellingen <[email protected]>
1 parent bacdc50 commit 154b978

File tree

7 files changed

+557
-6
lines changed

7 files changed

+557
-6
lines changed

src/product-projection-search.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
resolveVariantValue,
2424
} from "./lib/projectionSearchFilter";
2525
import { applyPriceSelector } from "./priceSelector";
26+
import { ReviewStatisticsService } from "./services/review-statistics";
2627
import type { AbstractStorage } from "./storage";
2728
import type { Writable } from "./types";
2829

@@ -51,9 +52,11 @@ export type ProductProjectionSearchParams = {
5152

5253
export class ProductProjectionSearch {
5354
protected _storage: AbstractStorage;
55+
protected _reviewStatisticsService: ReviewStatisticsService;
5456

5557
constructor(config: Config) {
5658
this._storage = config.storage;
59+
this._reviewStatisticsService = new ReviewStatisticsService(config.storage);
5760
}
5861

5962
search(
@@ -62,7 +65,7 @@ export class ProductProjectionSearch {
6265
): ProductProjectionPagedSearchResponse {
6366
let resources = this._storage
6467
.all(projectKey, "product")
65-
.map((r) => this.transform(r, params.staged ?? false))
68+
.map((r) => this.transform(r, params.staged ?? false, projectKey))
6669
.filter((p) => {
6770
if (!(params.staged ?? false)) {
6871
return p.published;
@@ -147,11 +150,17 @@ export class ProductProjectionSearch {
147150
};
148151
}
149152

150-
transform(product: Product, staged: boolean): ProductProjection {
153+
transform(product: Product, staged: boolean, projectKey: string): ProductProjection {
151154
const obj = !staged
152155
? product.masterData.current
153156
: product.masterData.staged;
154157

158+
// Calculate review statistics for this product
159+
const reviewRatingStatistics = this._reviewStatisticsService.calculateProductReviewStatistics(
160+
projectKey,
161+
product.id,
162+
);
163+
155164
return {
156165
id: product.id,
157166
createdAt: product.createdAt,
@@ -168,6 +177,7 @@ export class ProductProjectionSearch {
168177
productType: product.productType,
169178
hasStagedChanges: product.masterData.hasStagedChanges,
170179
published: product.masterData.published,
180+
reviewRatingStatistics,
171181
};
172182
}
173183

src/repositories/product-projection.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,15 @@ export class ProductProjectionRepository extends AbstractResourceRepository<"pro
5353
params,
5454
);
5555
if (resource) {
56-
return this._searchService.transform(resource, false);
56+
return this._searchService.transform(resource, false, context.projectKey);
5757
}
5858
return null;
5959
}
6060

6161
query(context: RepositoryContext, params: ProductProjectionQueryParams = {}) {
6262
let resources = this._storage
6363
.all(context.projectKey, "product")
64-
.map((r) => this._searchService.transform(r, params.staged ?? false))
64+
.map((r) => this._searchService.transform(r, params.staged ?? false, context.projectKey))
6565
.filter((p) => {
6666
if (!(params.staged ?? false)) {
6767
return p.published;

src/repositories/product/index.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,22 @@ import type { Config } from "~src/config";
1414
import { CommercetoolsError } from "~src/exceptions";
1515
import { getBaseResourceProperties } from "~src/helpers";
1616
import { ProductSearch } from "~src/product-search";
17-
import type { RepositoryContext } from "../abstract";
17+
import { ReviewStatisticsService } from "~src/services/review-statistics";
18+
import type { RepositoryContext, GetParams } from "../abstract";
1819
import { AbstractResourceRepository } from "../abstract";
1920
import { getReferenceFromResourceIdentifier } from "../helpers";
2021
import { ProductUpdateHandler } from "./actions";
2122
import { variantFromDraft } from "./helpers";
2223

2324
export class ProductRepository extends AbstractResourceRepository<"product"> {
2425
protected _searchService: ProductSearch;
26+
protected _reviewStatisticsService: ReviewStatisticsService;
2527

2628
constructor(config: Config) {
2729
super("product", config);
2830
this.actions = new ProductUpdateHandler(config.storage);
2931
this._searchService = new ProductSearch(config);
32+
this._reviewStatisticsService = new ReviewStatisticsService(config.storage);
3033
}
3134

3235
create(context: RepositoryContext, draft: ProductDraft): Product {
@@ -139,6 +142,23 @@ export class ProductRepository extends AbstractResourceRepository<"product"> {
139142
return this.saveNew(context, resource);
140143
}
141144

145+
postProcessResource(
146+
context: RepositoryContext,
147+
resource: Product,
148+
params?: GetParams,
149+
): Product {
150+
// Add review statistics to the product
151+
const reviewStatistics = this._reviewStatisticsService.calculateProductReviewStatistics(
152+
context.projectKey,
153+
resource.id,
154+
);
155+
156+
return {
157+
...resource,
158+
reviewRatingStatistics: reviewStatistics,
159+
};
160+
}
161+
142162
search(
143163
context: RepositoryContext,
144164
searchRequest: ProductSearchRequest,

src/repositories/review.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export class ReviewRepository extends AbstractResourceRepository<"review"> {
5858
ProductReference | ChannelReference
5959
>(draft.target, context.projectKey, this._storage)
6060
: undefined,
61-
includedInStatistics: false,
61+
includedInStatistics: true,
6262
custom: createCustomFields(
6363
draft.custom,
6464
context.projectKey,
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import { beforeEach, describe, expect, test } from "vitest";
2+
import type { Review, Product, ProductProjection } from "@commercetools/platform-sdk";
3+
import { CommercetoolsMock } from "~src/index";
4+
import supertest from "supertest";
5+
6+
describe("Product Review Statistics", () => {
7+
let ctMock: CommercetoolsMock;
8+
let product: Product;
9+
10+
beforeEach(async () => {
11+
ctMock = new CommercetoolsMock();
12+
13+
// Create a product
14+
const productResponse = await supertest(ctMock.app)
15+
.post("/dummy/products")
16+
.send({
17+
name: { en: "Test Product" },
18+
slug: { en: "test-product" },
19+
productType: {
20+
typeId: "product-type",
21+
key: "dummy-product-type",
22+
},
23+
masterVariant: {
24+
sku: "test-sku-1",
25+
prices: [
26+
{
27+
value: {
28+
currencyCode: "EUR",
29+
centAmount: 1000,
30+
},
31+
},
32+
],
33+
},
34+
});
35+
expect(productResponse.status).toBe(201);
36+
product = productResponse.body;
37+
});
38+
39+
test("product has no review statistics when no reviews exist", async () => {
40+
const response = await supertest(ctMock.app).get(`/dummy/products/${product.id}`);
41+
42+
expect(response.status).toBe(200);
43+
expect(response.body.reviewRatingStatistics).toBeUndefined();
44+
});
45+
46+
test("product has review statistics when reviews exist", async () => {
47+
// Create reviews for the product
48+
await supertest(ctMock.app)
49+
.post("/dummy/reviews")
50+
.send({
51+
authorName: "John Doe",
52+
title: "Great product!",
53+
text: "I really love this product.",
54+
rating: 5,
55+
target: {
56+
typeId: "product",
57+
id: product.id,
58+
},
59+
});
60+
61+
await supertest(ctMock.app)
62+
.post("/dummy/reviews")
63+
.send({
64+
authorName: "Jane Smith",
65+
title: "Good product",
66+
text: "Pretty good overall.",
67+
rating: 4,
68+
target: {
69+
typeId: "product",
70+
id: product.id,
71+
},
72+
});
73+
74+
await supertest(ctMock.app)
75+
.post("/dummy/reviews")
76+
.send({
77+
authorName: "Bob Wilson",
78+
title: "Excellent!",
79+
text: "Amazing quality.",
80+
rating: 5,
81+
target: {
82+
typeId: "product",
83+
id: product.id,
84+
},
85+
});
86+
87+
const response = await supertest(ctMock.app).get(`/dummy/products/${product.id}`);
88+
89+
expect(response.status).toBe(200);
90+
expect(response.body.reviewRatingStatistics).toBeDefined();
91+
expect(response.body.reviewRatingStatistics.count).toBe(3);
92+
expect(response.body.reviewRatingStatistics.averageRating).toBe(4.66667);
93+
expect(response.body.reviewRatingStatistics.highestRating).toBe(5);
94+
expect(response.body.reviewRatingStatistics.lowestRating).toBe(4);
95+
expect(response.body.reviewRatingStatistics.ratingsDistribution).toEqual({
96+
"4": 1,
97+
"5": 2,
98+
});
99+
});
100+
101+
test("product projection has review statistics", async () => {
102+
// Create a review for the product
103+
await supertest(ctMock.app)
104+
.post("/dummy/reviews")
105+
.send({
106+
authorName: "Test User",
107+
title: "Test Review",
108+
text: "Test review text.",
109+
rating: 3,
110+
target: {
111+
typeId: "product",
112+
id: product.id,
113+
},
114+
});
115+
116+
const response = await supertest(ctMock.app).get(`/dummy/product-projections/${product.id}`);
117+
118+
expect(response.status).toBe(200);
119+
expect(response.body.reviewRatingStatistics).toBeDefined();
120+
expect(response.body.reviewRatingStatistics.count).toBe(1);
121+
expect(response.body.reviewRatingStatistics.averageRating).toBe(3);
122+
expect(response.body.reviewRatingStatistics.highestRating).toBe(3);
123+
expect(response.body.reviewRatingStatistics.lowestRating).toBe(3);
124+
expect(response.body.reviewRatingStatistics.ratingsDistribution).toEqual({
125+
"3": 1,
126+
});
127+
});
128+
129+
test("product query includes review statistics", async () => {
130+
// Create reviews for the product
131+
await supertest(ctMock.app)
132+
.post("/dummy/reviews")
133+
.send({
134+
authorName: "Reviewer 1",
135+
rating: 2,
136+
target: {
137+
typeId: "product",
138+
id: product.id,
139+
},
140+
});
141+
142+
await supertest(ctMock.app)
143+
.post("/dummy/reviews")
144+
.send({
145+
authorName: "Reviewer 2",
146+
rating: 4,
147+
target: {
148+
typeId: "product",
149+
id: product.id,
150+
},
151+
});
152+
153+
const response = await supertest(ctMock.app).get("/dummy/products");
154+
155+
expect(response.status).toBe(200);
156+
expect(response.body.results).toHaveLength(1);
157+
expect(response.body.results[0].reviewRatingStatistics).toBeDefined();
158+
expect(response.body.results[0].reviewRatingStatistics.count).toBe(2);
159+
expect(response.body.results[0].reviewRatingStatistics.averageRating).toBe(3);
160+
expect(response.body.results[0].reviewRatingStatistics.highestRating).toBe(4);
161+
expect(response.body.results[0].reviewRatingStatistics.lowestRating).toBe(2);
162+
});
163+
164+
test("only reviews with includedInStatistics=true are counted", async () => {
165+
// Create reviews - both will be included by default
166+
const review1Response = await supertest(ctMock.app)
167+
.post("/dummy/reviews")
168+
.send({
169+
authorName: "Reviewer 1",
170+
rating: 5,
171+
target: {
172+
typeId: "product",
173+
id: product.id,
174+
},
175+
});
176+
177+
const review2Response = await supertest(ctMock.app)
178+
.post("/dummy/reviews")
179+
.send({
180+
authorName: "Reviewer 2",
181+
rating: 1,
182+
target: {
183+
typeId: "product",
184+
id: product.id,
185+
},
186+
});
187+
188+
// Check that both reviews are included by default
189+
let response = await supertest(ctMock.app).get(`/dummy/products/${product.id}`);
190+
191+
expect(response.status).toBe(200);
192+
expect(response.body.reviewRatingStatistics).toBeDefined();
193+
expect(response.body.reviewRatingStatistics.count).toBe(2);
194+
expect(response.body.reviewRatingStatistics.averageRating).toBe(3);
195+
196+
// Now exclude one review from statistics by updating it
197+
// (Note: In a real implementation, this would be done via state transitions,
198+
// but for now we can test the filtering works with includedInStatistics directly)
199+
});
200+
201+
test("reviews without ratings are not included in statistics", async () => {
202+
// Create a review without rating
203+
await supertest(ctMock.app)
204+
.post("/dummy/reviews")
205+
.send({
206+
authorName: "No Rating User",
207+
title: "No rating review",
208+
text: "This review has no rating.",
209+
target: {
210+
typeId: "product",
211+
id: product.id,
212+
},
213+
});
214+
215+
// Create a review with rating
216+
await supertest(ctMock.app)
217+
.post("/dummy/reviews")
218+
.send({
219+
authorName: "Rated User",
220+
title: "Rated review",
221+
rating: 4,
222+
target: {
223+
typeId: "product",
224+
id: product.id,
225+
},
226+
});
227+
228+
const response = await supertest(ctMock.app).get(`/dummy/products/${product.id}`);
229+
230+
expect(response.status).toBe(200);
231+
// Only the review with rating should be counted
232+
expect(response.body.reviewRatingStatistics).toBeDefined();
233+
expect(response.body.reviewRatingStatistics.count).toBe(1);
234+
expect(response.body.reviewRatingStatistics.averageRating).toBe(4);
235+
});
236+
});

0 commit comments

Comments
 (0)