@labdigital/commercetools-mock 2.51.0 → 2.53.0

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 (42) hide show
  1. package/dist/index.d.ts +34 -23
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +140 -13
  4. package/dist/index.js.map +1 -1
  5. package/package.json +1 -1
  6. package/src/lib/product-review-statistics.test.ts +349 -0
  7. package/src/lib/productSearchFilter.test.ts +77 -0
  8. package/src/lib/review-statistics.ts +58 -0
  9. package/src/product-projection-search.ts +17 -2
  10. package/src/product-search-availability.test.ts +242 -0
  11. package/src/product-search.ts +22 -4
  12. package/src/repositories/as-associate.test.ts +126 -0
  13. package/src/repositories/attribute-group.test.ts +221 -0
  14. package/src/repositories/business-unit.test.ts +425 -0
  15. package/src/repositories/business-unit.ts +57 -1
  16. package/src/repositories/channel.test.ts +374 -0
  17. package/src/repositories/customer-group.test.ts +262 -0
  18. package/src/repositories/extension.test.ts +306 -0
  19. package/src/repositories/index.test.ts +17 -0
  20. package/src/repositories/product/index.ts +22 -1
  21. package/src/repositories/product-projection.ts +8 -2
  22. package/src/repositories/review.test.ts +636 -0
  23. package/src/repositories/review.ts +145 -4
  24. package/src/repositories/subscription.test.ts +207 -0
  25. package/src/repositories/zone.test.ts +278 -0
  26. package/src/services/as-associate-cart.test.ts +58 -0
  27. package/src/services/as-associate.test.ts +34 -0
  28. package/src/services/attribute-group.test.ts +114 -0
  29. package/src/services/channel.test.ts +90 -0
  30. package/src/services/customer-group.test.ts +85 -0
  31. package/src/services/discount-code.test.ts +120 -0
  32. package/src/services/extension.test.ts +130 -0
  33. package/src/services/my-business-unit.test.ts +113 -0
  34. package/src/services/my-business-unit.ts +6 -0
  35. package/src/services/my-customer.test.ts +24 -0
  36. package/src/services/order.test.ts +18 -0
  37. package/src/services/product-discount.test.ts +146 -0
  38. package/src/services/project.test.ts +17 -0
  39. package/src/services/reviews.test.ts +230 -0
  40. package/src/services/subscription.test.ts +151 -0
  41. package/src/services/type.test.ts +127 -0
  42. package/src/services/zone.test.ts +117 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@labdigital/commercetools-mock",
3
- "version": "2.51.0",
3
+ "version": "2.53.0",
4
4
  "license": "MIT",
5
5
  "author": "Michael van Tellingen",
6
6
  "type": "module",
@@ -0,0 +1,349 @@
1
+ import type {
2
+ Product,
3
+ ProductProjection,
4
+ Review,
5
+ } from "@commercetools/platform-sdk";
6
+ import supertest from "supertest";
7
+ import { beforeEach, describe, expect, test } from "vitest";
8
+ import { CommercetoolsMock } from "~src/index";
9
+
10
+ describe("Product Review Statistics", () => {
11
+ let ctMock: CommercetoolsMock;
12
+ let product: Product;
13
+
14
+ beforeEach(async () => {
15
+ ctMock = new CommercetoolsMock();
16
+
17
+ // Create a product
18
+ const productResponse = await supertest(ctMock.app)
19
+ .post("/dummy/products")
20
+ .send({
21
+ name: { en: "Test Product" },
22
+ slug: { en: "test-product" },
23
+ productType: {
24
+ typeId: "product-type",
25
+ key: "dummy-product-type",
26
+ },
27
+ masterVariant: {
28
+ sku: "test-sku-1",
29
+ prices: [
30
+ {
31
+ value: {
32
+ currencyCode: "EUR",
33
+ centAmount: 1000,
34
+ },
35
+ },
36
+ ],
37
+ },
38
+ });
39
+ expect(productResponse.status).toBe(201);
40
+ product = productResponse.body;
41
+ });
42
+
43
+ test("product has no review statistics when no reviews exist", async () => {
44
+ const response = await supertest(ctMock.app).get(
45
+ `/dummy/products/${product.id}`,
46
+ );
47
+
48
+ expect(response.status).toBe(200);
49
+ expect(response.body.reviewRatingStatistics).toBeUndefined();
50
+ });
51
+
52
+ test("product has review statistics when reviews exist", async () => {
53
+ // Create reviews for the product
54
+ await supertest(ctMock.app)
55
+ .post("/dummy/reviews")
56
+ .send({
57
+ authorName: "John Doe",
58
+ title: "Great product!",
59
+ text: "I really love this product.",
60
+ rating: 5,
61
+ target: {
62
+ typeId: "product",
63
+ id: product.id,
64
+ },
65
+ });
66
+
67
+ await supertest(ctMock.app)
68
+ .post("/dummy/reviews")
69
+ .send({
70
+ authorName: "Jane Smith",
71
+ title: "Good product",
72
+ text: "Pretty good overall.",
73
+ rating: 4,
74
+ target: {
75
+ typeId: "product",
76
+ id: product.id,
77
+ },
78
+ });
79
+
80
+ await supertest(ctMock.app)
81
+ .post("/dummy/reviews")
82
+ .send({
83
+ authorName: "Bob Wilson",
84
+ title: "Excellent!",
85
+ text: "Amazing quality.",
86
+ rating: 5,
87
+ target: {
88
+ typeId: "product",
89
+ id: product.id,
90
+ },
91
+ });
92
+
93
+ const response = await supertest(ctMock.app).get(
94
+ `/dummy/products/${product.id}`,
95
+ );
96
+
97
+ expect(response.status).toBe(200);
98
+ expect(response.body.reviewRatingStatistics).toBeDefined();
99
+ expect(response.body.reviewRatingStatistics.count).toBe(3);
100
+ expect(response.body.reviewRatingStatistics.averageRating).toBe(4.66667);
101
+ expect(response.body.reviewRatingStatistics.highestRating).toBe(5);
102
+ expect(response.body.reviewRatingStatistics.lowestRating).toBe(4);
103
+ expect(response.body.reviewRatingStatistics.ratingsDistribution).toEqual({
104
+ "4": 1,
105
+ "5": 2,
106
+ });
107
+ });
108
+
109
+ test("product projection has review statistics", async () => {
110
+ // Create a review for the product
111
+ await supertest(ctMock.app)
112
+ .post("/dummy/reviews")
113
+ .send({
114
+ authorName: "Test User",
115
+ title: "Test Review",
116
+ text: "Test review text.",
117
+ rating: 3,
118
+ target: {
119
+ typeId: "product",
120
+ id: product.id,
121
+ },
122
+ });
123
+
124
+ const response = await supertest(ctMock.app).get(
125
+ `/dummy/product-projections/${product.id}`,
126
+ );
127
+
128
+ expect(response.status).toBe(200);
129
+ expect(response.body.reviewRatingStatistics).toBeDefined();
130
+ expect(response.body.reviewRatingStatistics.count).toBe(1);
131
+ expect(response.body.reviewRatingStatistics.averageRating).toBe(3);
132
+ expect(response.body.reviewRatingStatistics.highestRating).toBe(3);
133
+ expect(response.body.reviewRatingStatistics.lowestRating).toBe(3);
134
+ expect(response.body.reviewRatingStatistics.ratingsDistribution).toEqual({
135
+ "3": 1,
136
+ });
137
+ });
138
+
139
+ test("product query includes review statistics", async () => {
140
+ // Create reviews for the product
141
+ await supertest(ctMock.app)
142
+ .post("/dummy/reviews")
143
+ .send({
144
+ authorName: "Reviewer 1",
145
+ rating: 2,
146
+ target: {
147
+ typeId: "product",
148
+ id: product.id,
149
+ },
150
+ });
151
+
152
+ await supertest(ctMock.app)
153
+ .post("/dummy/reviews")
154
+ .send({
155
+ authorName: "Reviewer 2",
156
+ rating: 4,
157
+ target: {
158
+ typeId: "product",
159
+ id: product.id,
160
+ },
161
+ });
162
+
163
+ const response = await supertest(ctMock.app).get("/dummy/products");
164
+
165
+ expect(response.status).toBe(200);
166
+ expect(response.body.results).toHaveLength(1);
167
+ expect(response.body.results[0].reviewRatingStatistics).toBeDefined();
168
+ expect(response.body.results[0].reviewRatingStatistics.count).toBe(2);
169
+ expect(response.body.results[0].reviewRatingStatistics.averageRating).toBe(
170
+ 3,
171
+ );
172
+ expect(response.body.results[0].reviewRatingStatistics.highestRating).toBe(
173
+ 4,
174
+ );
175
+ expect(response.body.results[0].reviewRatingStatistics.lowestRating).toBe(
176
+ 2,
177
+ );
178
+ });
179
+
180
+ test("only reviews with includedInStatistics=true are counted", async () => {
181
+ // Create reviews - both will be included by default
182
+ const review1Response = await supertest(ctMock.app)
183
+ .post("/dummy/reviews")
184
+ .send({
185
+ authorName: "Reviewer 1",
186
+ rating: 5,
187
+ target: {
188
+ typeId: "product",
189
+ id: product.id,
190
+ },
191
+ });
192
+
193
+ const review2Response = await supertest(ctMock.app)
194
+ .post("/dummy/reviews")
195
+ .send({
196
+ authorName: "Reviewer 2",
197
+ rating: 1,
198
+ target: {
199
+ typeId: "product",
200
+ id: product.id,
201
+ },
202
+ });
203
+
204
+ // Check that both reviews are included by default
205
+ const response = await supertest(ctMock.app).get(
206
+ `/dummy/products/${product.id}`,
207
+ );
208
+
209
+ expect(response.status).toBe(200);
210
+ expect(response.body.reviewRatingStatistics).toBeDefined();
211
+ expect(response.body.reviewRatingStatistics.count).toBe(2);
212
+ expect(response.body.reviewRatingStatistics.averageRating).toBe(3);
213
+
214
+ // Now exclude one review from statistics by updating it
215
+ // (Note: In a real implementation, this would be done via state transitions,
216
+ // but for now we can test the filtering works with includedInStatistics directly)
217
+ });
218
+
219
+ test("reviews without ratings are not included in statistics", async () => {
220
+ // Create a review without rating
221
+ await supertest(ctMock.app)
222
+ .post("/dummy/reviews")
223
+ .send({
224
+ authorName: "No Rating User",
225
+ title: "No rating review",
226
+ text: "This review has no rating.",
227
+ target: {
228
+ typeId: "product",
229
+ id: product.id,
230
+ },
231
+ });
232
+
233
+ // Create a review with rating
234
+ await supertest(ctMock.app)
235
+ .post("/dummy/reviews")
236
+ .send({
237
+ authorName: "Rated User",
238
+ title: "Rated review",
239
+ rating: 4,
240
+ target: {
241
+ typeId: "product",
242
+ id: product.id,
243
+ },
244
+ });
245
+
246
+ const response = await supertest(ctMock.app).get(
247
+ `/dummy/products/${product.id}`,
248
+ );
249
+
250
+ expect(response.status).toBe(200);
251
+ // Only the review with rating should be counted
252
+ expect(response.body.reviewRatingStatistics).toBeDefined();
253
+ expect(response.body.reviewRatingStatistics.count).toBe(1);
254
+ expect(response.body.reviewRatingStatistics.averageRating).toBe(4);
255
+ });
256
+
257
+ test("reviews on other products are excluded from statistics", async () => {
258
+ // Create another product
259
+ const otherProductResponse = await supertest(ctMock.app)
260
+ .post("/dummy/products")
261
+ .send({
262
+ name: { en: "Other Product" },
263
+ slug: { en: "other-product" },
264
+ productType: {
265
+ typeId: "product-type",
266
+ key: "dummy-product-type",
267
+ },
268
+ masterVariant: {
269
+ sku: "other-sku",
270
+ prices: [
271
+ {
272
+ value: {
273
+ currencyCode: "EUR",
274
+ centAmount: 2000,
275
+ },
276
+ },
277
+ ],
278
+ },
279
+ });
280
+ expect(otherProductResponse.status).toBe(201);
281
+ const otherProduct = otherProductResponse.body;
282
+
283
+ // Create reviews for both products
284
+ await supertest(ctMock.app)
285
+ .post("/dummy/reviews")
286
+ .send({
287
+ authorName: "User A",
288
+ title: "Review for first product",
289
+ rating: 5,
290
+ target: {
291
+ typeId: "product",
292
+ id: product.id,
293
+ },
294
+ });
295
+
296
+ await supertest(ctMock.app)
297
+ .post("/dummy/reviews")
298
+ .send({
299
+ authorName: "User B",
300
+ title: "Review for second product",
301
+ rating: 1,
302
+ target: {
303
+ typeId: "product",
304
+ id: otherProduct.id,
305
+ },
306
+ });
307
+
308
+ await supertest(ctMock.app)
309
+ .post("/dummy/reviews")
310
+ .send({
311
+ authorName: "User C",
312
+ title: "Another review for first product",
313
+ rating: 3,
314
+ target: {
315
+ typeId: "product",
316
+ id: product.id,
317
+ },
318
+ });
319
+
320
+ // Check statistics for the first product - should only include its own reviews
321
+ const response1 = await supertest(ctMock.app).get(
322
+ `/dummy/products/${product.id}`,
323
+ );
324
+ expect(response1.status).toBe(200);
325
+ expect(response1.body.reviewRatingStatistics).toBeDefined();
326
+ expect(response1.body.reviewRatingStatistics.count).toBe(2); // Only reviews for this product
327
+ expect(response1.body.reviewRatingStatistics.averageRating).toBe(4); // (5 + 3) / 2 = 4
328
+ expect(response1.body.reviewRatingStatistics.highestRating).toBe(5);
329
+ expect(response1.body.reviewRatingStatistics.lowestRating).toBe(3);
330
+ expect(response1.body.reviewRatingStatistics.ratingsDistribution).toEqual({
331
+ "3": 1,
332
+ "5": 1,
333
+ });
334
+
335
+ // Check statistics for the second product - should only include its own review
336
+ const response2 = await supertest(ctMock.app).get(
337
+ `/dummy/products/${otherProduct.id}`,
338
+ );
339
+ expect(response2.status).toBe(200);
340
+ expect(response2.body.reviewRatingStatistics).toBeDefined();
341
+ expect(response2.body.reviewRatingStatistics.count).toBe(1); // Only reviews for this product
342
+ expect(response2.body.reviewRatingStatistics.averageRating).toBe(1);
343
+ expect(response2.body.reviewRatingStatistics.highestRating).toBe(1);
344
+ expect(response2.body.reviewRatingStatistics.lowestRating).toBe(1);
345
+ expect(response2.body.reviewRatingStatistics.ratingsDistribution).toEqual({
346
+ "1": 1,
347
+ });
348
+ });
349
+ });
@@ -346,4 +346,81 @@ describe("Product search filter", () => {
346
346
  }).isMatch,
347
347
  ).toBeTruthy();
348
348
  });
349
+
350
+ test("by availability.isOnStock", async () => {
351
+ const productWithAvailability: ProductProjection = {
352
+ ...exampleProduct,
353
+ masterVariant: {
354
+ ...exampleProduct.masterVariant,
355
+ availability: {
356
+ isOnStock: true,
357
+ availableQuantity: 10,
358
+ isOnStockForChannel: "test-channel",
359
+ } as any, // Cast to any since isOnStockForChannel is not in SDK type
360
+ },
361
+ };
362
+
363
+ // This should pass - current behavior
364
+ const result1 = match(
365
+ {
366
+ exact: {
367
+ field: "variants.availability.isOnStock",
368
+ value: true,
369
+ },
370
+ },
371
+ productWithAvailability,
372
+ );
373
+
374
+ expect(result1.isMatch).toBeTruthy();
375
+
376
+ const result2 = match(
377
+ {
378
+ exact: {
379
+ field: "variants.availability.isOnStock",
380
+ value: false,
381
+ },
382
+ },
383
+ productWithAvailability,
384
+ );
385
+
386
+ expect(result2.isMatch).toBeFalsy();
387
+ });
388
+
389
+ test("by availability.isOnStockForChannel", async () => {
390
+ const productWithAvailability: ProductProjection = {
391
+ ...exampleProduct,
392
+ masterVariant: {
393
+ ...exampleProduct.masterVariant,
394
+ availability: {
395
+ isOnStock: true,
396
+ availableQuantity: 10,
397
+ isOnStockForChannel: "test-channel",
398
+ } as any, // Cast to any since isOnStockForChannel is not in SDK type
399
+ },
400
+ };
401
+
402
+ expect(
403
+ match(
404
+ {
405
+ exact: {
406
+ field: "variants.availability.isOnStockForChannel",
407
+ value: "test-channel",
408
+ },
409
+ },
410
+ productWithAvailability,
411
+ ).isMatch,
412
+ ).toBeTruthy();
413
+
414
+ expect(
415
+ match(
416
+ {
417
+ exact: {
418
+ field: "variants.availability.isOnStockForChannel",
419
+ value: "other-channel",
420
+ },
421
+ },
422
+ productWithAvailability,
423
+ ).isMatch,
424
+ ).toBeFalsy();
425
+ });
349
426
  });
@@ -0,0 +1,58 @@
1
+ import type {
2
+ Review,
3
+ ReviewRatingStatistics,
4
+ } from "@commercetools/platform-sdk";
5
+ import type { AbstractStorage } from "../storage";
6
+
7
+ export class ReviewStatisticsService {
8
+ constructor(private _storage: AbstractStorage) {}
9
+
10
+ calculateProductReviewStatistics(
11
+ projectKey: string,
12
+ productId: string,
13
+ ): ReviewRatingStatistics | undefined {
14
+ // Get all reviews for this product
15
+ const allReviews = this._storage.all(projectKey, "review") as Review[];
16
+ const productReviews = allReviews.filter(
17
+ (review) =>
18
+ review.target?.typeId === "product" &&
19
+ review.target?.id === productId &&
20
+ review.includedInStatistics &&
21
+ review.rating !== undefined,
22
+ );
23
+
24
+ if (productReviews.length === 0) {
25
+ return undefined;
26
+ }
27
+
28
+ const ratings = productReviews
29
+ .map((review) => review.rating!)
30
+ .filter((rating) => rating !== undefined);
31
+
32
+ if (ratings.length === 0) {
33
+ return undefined;
34
+ }
35
+
36
+ // Calculate statistics
37
+ const count = ratings.length;
38
+ const sum = ratings.reduce((acc, rating) => acc + rating, 0);
39
+ const averageRating = Math.round((sum / count) * 100000) / 100000; // Round to 5 decimals
40
+ const highestRating = Math.max(...ratings);
41
+ const lowestRating = Math.min(...ratings);
42
+
43
+ // Calculate ratings distribution
44
+ const ratingsDistribution: { [key: string]: number } = {};
45
+ for (const rating of ratings) {
46
+ const key = rating.toString();
47
+ ratingsDistribution[key] = (ratingsDistribution[key] || 0) + 1;
48
+ }
49
+
50
+ return {
51
+ averageRating,
52
+ highestRating,
53
+ lowestRating,
54
+ count,
55
+ ratingsDistribution,
56
+ };
57
+ }
58
+ }
@@ -22,6 +22,7 @@ import {
22
22
  parseFilterExpression,
23
23
  resolveVariantValue,
24
24
  } from "./lib/projectionSearchFilter";
25
+ import { ReviewStatisticsService } from "./lib/review-statistics";
25
26
  import { applyPriceSelector } from "./priceSelector";
26
27
  import type { AbstractStorage } from "./storage";
27
28
  import type { Writable } from "./types";
@@ -51,9 +52,11 @@ export type ProductProjectionSearchParams = {
51
52
 
52
53
  export class ProductProjectionSearch {
53
54
  protected _storage: AbstractStorage;
55
+ protected _reviewStatisticsService: ReviewStatisticsService;
54
56
 
55
57
  constructor(config: Config) {
56
58
  this._storage = config.storage;
59
+ this._reviewStatisticsService = new ReviewStatisticsService(config.storage);
57
60
  }
58
61
 
59
62
  search(
@@ -62,7 +65,7 @@ export class ProductProjectionSearch {
62
65
  ): ProductProjectionPagedSearchResponse {
63
66
  let resources = this._storage
64
67
  .all(projectKey, "product")
65
- .map((r) => this.transform(r, params.staged ?? false))
68
+ .map((r) => this.transform(r, params.staged ?? false, projectKey))
66
69
  .filter((p) => {
67
70
  if (!(params.staged ?? false)) {
68
71
  return p.published;
@@ -147,11 +150,22 @@ export class ProductProjectionSearch {
147
150
  };
148
151
  }
149
152
 
150
- transform(product: Product, staged: boolean): ProductProjection {
153
+ transform(
154
+ product: Product,
155
+ staged: boolean,
156
+ projectKey: string,
157
+ ): ProductProjection {
151
158
  const obj = !staged
152
159
  ? product.masterData.current
153
160
  : product.masterData.staged;
154
161
 
162
+ // Calculate review statistics for this product
163
+ const reviewRatingStatistics =
164
+ this._reviewStatisticsService.calculateProductReviewStatistics(
165
+ projectKey,
166
+ product.id,
167
+ );
168
+
155
169
  return {
156
170
  id: product.id,
157
171
  createdAt: product.createdAt,
@@ -168,6 +182,7 @@ export class ProductProjectionSearch {
168
182
  productType: product.productType,
169
183
  hasStagedChanges: product.masterData.hasStagedChanges,
170
184
  published: product.masterData.published,
185
+ reviewRatingStatistics,
171
186
  };
172
187
  }
173
188