@labdigital/commercetools-mock 2.30.0 → 2.31.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@labdigital/commercetools-mock",
3
- "version": "2.30.0",
3
+ "version": "2.31.0",
4
4
  "license": "MIT",
5
5
  "author": "Michael van Tellingen",
6
6
  "type": "module",
@@ -0,0 +1,328 @@
1
+ import { ProductProjection, _SearchQuery } from "@commercetools/platform-sdk";
2
+ import { describe, expect, test } from "vitest";
3
+ import { cloneObject } from "~src/helpers";
4
+ import { parseSearchQuery } from "./productSearchFilter";
5
+
6
+ describe("Product search filter", () => {
7
+ const exampleProduct: ProductProjection = {
8
+ id: "7401d82f-1378-47ba-996a-85beeb87ac87",
9
+ version: 2,
10
+ createdAt: "2022-07-22T10:02:40.851Z",
11
+ lastModifiedAt: "2022-07-22T10:02:44.427Z",
12
+ key: "test-product",
13
+ productType: {
14
+ typeId: "product-type",
15
+ id: "b9b4b426-938b-4ccb-9f36-c6f933e8446e",
16
+ },
17
+ name: {
18
+ "nl-NL": "test",
19
+ "en-US": "my english test",
20
+ },
21
+ slug: {
22
+ "nl-NL": "test",
23
+ "en-US": "test",
24
+ },
25
+ variants: [],
26
+ searchKeywords: {},
27
+ categories: [],
28
+ masterVariant: {
29
+ id: 1,
30
+ sku: "MYSKU",
31
+ attributes: [
32
+ {
33
+ name: "Country",
34
+ value: {
35
+ key: "NL",
36
+ label: {
37
+ de: "niederlande",
38
+ en: "netherlands",
39
+ nl: "nederland",
40
+ },
41
+ },
42
+ },
43
+ {
44
+ name: "number",
45
+ value: 4,
46
+ },
47
+ ],
48
+ prices: [
49
+ {
50
+ id: "dummy-uuid",
51
+ value: {
52
+ type: "centPrecision",
53
+ currencyCode: "EUR",
54
+ centAmount: 1789,
55
+ fractionDigits: 2,
56
+ },
57
+ },
58
+ ],
59
+ },
60
+ };
61
+
62
+ const match = (filterObject: _SearchQuery, product?: ProductProjection) => {
63
+ const matchFunc = parseSearchQuery(filterObject);
64
+ const clone = cloneObject(product ?? exampleProduct);
65
+ return {
66
+ isMatch: matchFunc(clone, false),
67
+ product: clone,
68
+ };
69
+ };
70
+
71
+ test("by product key", async () => {
72
+ expect(
73
+ match({
74
+ exists: {
75
+ field: "key",
76
+ },
77
+ }).isMatch,
78
+ ).toBeTruthy();
79
+
80
+ expect(
81
+ match({
82
+ not: {
83
+ exists: {
84
+ field: "key",
85
+ },
86
+ },
87
+ }).isMatch,
88
+ ).toBeFalsy();
89
+
90
+ expect(
91
+ match({
92
+ exact: {
93
+ field: "key",
94
+ value: "test-product",
95
+ },
96
+ }).isMatch,
97
+ ).toBeTruthy();
98
+ });
99
+
100
+ test("by product type id", async () => {
101
+ expect(
102
+ match({
103
+ exact: {
104
+ field: "productType.id",
105
+ value: "b9b4b426-938b-4ccb-9f36-c6f933e8446e",
106
+ },
107
+ }).isMatch,
108
+ ).toBeTruthy();
109
+ });
110
+
111
+ test("by variant SKU", async () => {
112
+ expect(
113
+ match({
114
+ exists: {
115
+ field: "variants.sku",
116
+ },
117
+ }).isMatch,
118
+ ).toBeTruthy();
119
+
120
+ expect(
121
+ match({
122
+ not: {
123
+ exists: {
124
+ field: "variants.sku",
125
+ },
126
+ },
127
+ }).isMatch,
128
+ ).toBeFalsy();
129
+
130
+ expect(
131
+ match({
132
+ exact: {
133
+ field: "variants.sku",
134
+ value: "MYSKU",
135
+ },
136
+ }).isMatch,
137
+ ).toBeTruthy();
138
+ });
139
+
140
+ test("by attribute value", async () => {
141
+ expect(
142
+ match({
143
+ exact: {
144
+ field: "variants.attributes.number",
145
+ value: 4,
146
+ },
147
+ }).isMatch,
148
+ ).toBeTruthy();
149
+
150
+ expect(
151
+ match({
152
+ or: [
153
+ {
154
+ exact: {
155
+ field: "variants.attributes.number",
156
+ value: 3,
157
+ },
158
+ },
159
+ {
160
+ exact: {
161
+ field: "variants.attributes.number",
162
+ value: 4,
163
+ },
164
+ },
165
+ ],
166
+ }).isMatch,
167
+ ).toBeTruthy();
168
+
169
+ expect(
170
+ match({
171
+ exact: {
172
+ field: "variants.attributes.number",
173
+ value: 5,
174
+ },
175
+ }).isMatch,
176
+ ).toBeFalsy();
177
+ });
178
+
179
+ test("by attribute range", async () => {
180
+ expect(
181
+ match({
182
+ range: {
183
+ field: "variants.attributes.number",
184
+ gt: 0,
185
+ lt: 5,
186
+ },
187
+ }).isMatch,
188
+ ).toBeTruthy();
189
+
190
+ expect(
191
+ match({
192
+ range: {
193
+ field: "variants.attributes.number",
194
+ gt: 4,
195
+ lt: 10,
196
+ },
197
+ }).isMatch,
198
+ ).toBeFalsy();
199
+
200
+ expect(
201
+ match({
202
+ range: {
203
+ field: "variants.attributes.number",
204
+ gte: 4,
205
+ lte: 10,
206
+ },
207
+ }).isMatch,
208
+ ).toBeTruthy();
209
+ });
210
+
211
+ test("by attribute enum key", async () => {
212
+ expect(
213
+ match({
214
+ exact: {
215
+ field: "variants.attributes.Country.key",
216
+ value: "NL",
217
+ },
218
+ }).isMatch,
219
+ ).toBeTruthy();
220
+
221
+ expect(
222
+ match({
223
+ exact: {
224
+ field: "variants.attributes.Country.key",
225
+ value: "DE",
226
+ },
227
+ }).isMatch,
228
+ ).toBeFalsy();
229
+ });
230
+
231
+ test("by attribute text value", async () => {
232
+ expect(
233
+ match({
234
+ wildcard: {
235
+ field: "name",
236
+ value: "*test*",
237
+ language: "nl-NL",
238
+ caseInsensitive: true,
239
+ },
240
+ }).isMatch,
241
+ ).toBeTruthy();
242
+
243
+ expect(
244
+ match({
245
+ wildcard: {
246
+ field: "name",
247
+ value: "*other*",
248
+ language: "nl-NL",
249
+ caseInsensitive: true,
250
+ },
251
+ }).isMatch,
252
+ ).toBeFalsy();
253
+
254
+ expect(
255
+ match({
256
+ wildcard: {
257
+ field: "name",
258
+ value: "*Test*",
259
+ language: "nl-NL",
260
+ caseInsensitive: false,
261
+ },
262
+ }).isMatch,
263
+ ).toBeFalsy();
264
+
265
+ expect(
266
+ match({
267
+ wildcard: {
268
+ field: "name",
269
+ value: "*english Test*",
270
+ language: "en-US",
271
+ caseInsensitive: true,
272
+ },
273
+ }).isMatch,
274
+ ).toBeTruthy();
275
+ });
276
+
277
+ test("by price range", async () => {
278
+ expect(
279
+ match({
280
+ range: {
281
+ field: "variants.prices.currentCentAmount",
282
+ gte: 1500,
283
+ lte: 2000,
284
+ },
285
+ }).isMatch,
286
+ ).toBeTruthy();
287
+
288
+ expect(
289
+ match({
290
+ range: {
291
+ field: "variants.prices.currentCentAmount",
292
+ gt: 1800,
293
+ lte: 2000,
294
+ },
295
+ }).isMatch,
296
+ ).toBeFalsy();
297
+ });
298
+
299
+ test("by price range - or", async () => {
300
+ expect(
301
+ match({
302
+ or: [
303
+ {
304
+ range: {
305
+ field: "variants.prices.currentCentAmount",
306
+ gte: 2,
307
+ lte: 1500,
308
+ },
309
+ },
310
+ {
311
+ range: {
312
+ field: "variants.prices.currentCentAmount",
313
+ gte: 1500,
314
+ lte: 3000,
315
+ },
316
+ },
317
+ {
318
+ range: {
319
+ field: "variants.prices.currentCentAmount",
320
+ gte: 3000,
321
+ lte: 4000,
322
+ },
323
+ },
324
+ ],
325
+ }).isMatch,
326
+ ).toBeTruthy();
327
+ });
328
+ });
@@ -0,0 +1,329 @@
1
+ import type {
2
+ ProductProjection,
3
+ ProductVariant,
4
+ SearchAndExpression,
5
+ SearchDateRangeExpression,
6
+ SearchDateTimeRangeExpression,
7
+ SearchExactExpression,
8
+ SearchExistsExpression,
9
+ SearchFilterExpression,
10
+ SearchFullTextExpression,
11
+ SearchFullTextPrefixExpression,
12
+ SearchLongRangeExpression,
13
+ SearchNotExpression,
14
+ SearchNumberRangeExpression,
15
+ SearchOrExpression,
16
+ SearchPrefixExpression,
17
+ SearchTimeRangeExpression,
18
+ SearchWildCardExpression,
19
+ _SearchQuery,
20
+ _SearchQueryExpression,
21
+ _SearchQueryExpressionValue,
22
+ } from "@commercetools/platform-sdk";
23
+ import { nestedLookup } from "~src/helpers";
24
+ import type { Writable } from "../types";
25
+ import { getVariants } from "./projectionSearchFilter";
26
+
27
+ type ProductSearchFilterFunc = (
28
+ p: Writable<ProductProjection>,
29
+ markMatchingVariants: boolean,
30
+ ) => boolean;
31
+
32
+ // @TODO: Implement field boosting:
33
+ // https://docs.commercetools.com/merchant-center/advanced-product-search#boost-field
34
+ export const parseSearchQuery = (
35
+ searchQuery: _SearchQuery,
36
+ ): ProductSearchFilterFunc => {
37
+ if (isSearchAndExpression(searchQuery)) {
38
+ return (obj, markMatchingVariant) =>
39
+ searchQuery.and.every((expr) => {
40
+ const filterFunc = parseSearchQuery(expr);
41
+
42
+ return filterFunc(obj, markMatchingVariant);
43
+ });
44
+ }
45
+
46
+ if (isSearchOrExpression(searchQuery)) {
47
+ return (obj, markMatchingVariant) =>
48
+ searchQuery.or.some((expr) => {
49
+ const filterFunc = parseSearchQuery(expr);
50
+
51
+ return filterFunc(obj, markMatchingVariant);
52
+ });
53
+ }
54
+
55
+ if (isSearchNotExpression(searchQuery)) {
56
+ return (obj, markMatchingVariant) =>
57
+ !parseSearchQuery(searchQuery.not)(obj, markMatchingVariant);
58
+ }
59
+
60
+ if (isSearchFilterExpression(searchQuery)) {
61
+ // Matching resources of a query are checked for their relevancy to the search.
62
+ // The relevancy is expressed by an internal score.
63
+ // All expressions except filter expressions contribute to that score.
64
+ // All sub-expressions of a filter are implicitly connected with an and expression.
65
+ // NOTE: for now just implementing it like a AND expression
66
+ return (obj, markMatchingVariant) =>
67
+ searchQuery.filter.every((expr) => {
68
+ const filterFunc = parseSearchQuery(expr);
69
+
70
+ return filterFunc(obj, markMatchingVariant);
71
+ });
72
+ }
73
+
74
+ if (isSearchRangeExpression(searchQuery)) {
75
+ const generateRangeMatchFunc = (value: any) => {
76
+ const rangeFilters = [];
77
+
78
+ if (searchQuery.range.gte) {
79
+ rangeFilters.push(value >= searchQuery.range.gte);
80
+ }
81
+
82
+ if (searchQuery.range.gt) {
83
+ rangeFilters.push(value > searchQuery.range.gt);
84
+ }
85
+
86
+ if (searchQuery.range.lte) {
87
+ rangeFilters.push(value <= searchQuery.range.lte);
88
+ }
89
+
90
+ if (searchQuery.range.lt) {
91
+ rangeFilters.push(value < searchQuery.range.lt);
92
+ }
93
+
94
+ return rangeFilters.every((filter) => filter);
95
+ };
96
+
97
+ return generateFieldMatchFunc(generateRangeMatchFunc, searchQuery.range);
98
+ }
99
+
100
+ if (isSearchExactExpression(searchQuery)) {
101
+ return generateFieldMatchFunc(
102
+ (value: any) => value === searchQuery.exact.value,
103
+ searchQuery.exact,
104
+ );
105
+ }
106
+
107
+ if (isSearchExistsExpression(searchQuery)) {
108
+ return generateFieldMatchFunc((value: any) => !!value, searchQuery.exists);
109
+ }
110
+
111
+ if (isSearchFullTextExpression(searchQuery)) {
112
+ // @TODO: Implement fulltext search, to fully support functionality offered by commercetools:
113
+ // https://docs.commercetools.com/api/search-query-language#fulltext
114
+ return generateFieldMatchFunc(
115
+ (value: any) => value.includes(searchQuery.fullText.value),
116
+ searchQuery.fullText,
117
+ );
118
+ }
119
+
120
+ if (isSearchFullTextPrefixExpression(searchQuery)) {
121
+ // @TODO: Implement fulltext search, to fully support functionality offered by commercetools:
122
+ // https://docs.commercetools.com/api/search-query-language#fulltext
123
+ return generateFieldMatchFunc(
124
+ (value: any) => value.startsWith(searchQuery.fullTextPrefix.value),
125
+ searchQuery.fullTextPrefix,
126
+ );
127
+ }
128
+
129
+ if (isSearchPrefixExpression(searchQuery)) {
130
+ return generateFieldMatchFunc(
131
+ (value: any) => value.startsWith(searchQuery.prefix.value),
132
+ searchQuery.prefix,
133
+ );
134
+ }
135
+
136
+ if (isSearchWildCardExpression(searchQuery)) {
137
+ // @TODO: Fully implement wildcard search, as specified:
138
+ // https://docs.commercetools.com/api/search-query-language#fulltext
139
+ const generateWildcardMatchFunc = (value: any) => {
140
+ const wildCardValues = searchQuery.wildcard.value
141
+ .split("*")
142
+ .filter((v: string) => !!v);
143
+
144
+ if (searchQuery.wildcard.caseInsensitive) {
145
+ return wildCardValues.every((wildCardValue: string) =>
146
+ value.toLowerCase().includes(wildCardValue.toLowerCase()),
147
+ );
148
+ }
149
+
150
+ return wildCardValues.every((wildCardValue: string) =>
151
+ value.includes(wildCardValue),
152
+ );
153
+ };
154
+
155
+ return generateFieldMatchFunc(
156
+ generateWildcardMatchFunc,
157
+ searchQuery.wildcard,
158
+ );
159
+ }
160
+
161
+ throw new Error("Unsupported search query expression");
162
+ };
163
+
164
+ const generateFieldMatchFunc = (
165
+ matchFunc: (value: any) => boolean,
166
+ searchQuery: _SearchQueryExpressionValue,
167
+ ) => {
168
+ const generateMatchFunc = (
169
+ obj: ProductProjection,
170
+ markMatchingVariants: boolean,
171
+ ) => {
172
+ if (searchQuery.field.startsWith("variants.")) {
173
+ const variantField = searchQuery.field.substring(
174
+ searchQuery.field.indexOf(".") + 1,
175
+ );
176
+
177
+ const variants = getVariants(obj) as Writable<ProductVariant>[];
178
+ for (const variant of variants) {
179
+ const value = resolveFieldValue(variant, {
180
+ ...searchQuery,
181
+ field: variantField,
182
+ });
183
+
184
+ if (matchFunc(value)) {
185
+ if (markMatchingVariants) {
186
+ for (const v of variants) {
187
+ v.isMatchingVariant = false;
188
+ }
189
+ variant.isMatchingVariant = true;
190
+ }
191
+
192
+ return true;
193
+ }
194
+ }
195
+
196
+ return false;
197
+ }
198
+
199
+ return matchFunc(resolveFieldValue(obj, searchQuery));
200
+ };
201
+
202
+ return generateMatchFunc;
203
+ };
204
+
205
+ const resolveFieldValue = (
206
+ obj: any,
207
+ searchQuery: _SearchQueryExpressionValue,
208
+ ) => {
209
+ if (searchQuery.field === undefined) {
210
+ throw new Error("Missing field path in query expression");
211
+ }
212
+
213
+ let fieldPath = searchQuery.field;
214
+ const language = "language" in searchQuery ? searchQuery.language : undefined;
215
+
216
+ if (fieldPath.startsWith("variants.")) {
217
+ fieldPath = fieldPath.substring(fieldPath.indexOf(".") + 1);
218
+ }
219
+
220
+ if (fieldPath.startsWith("attributes.")) {
221
+ const [, attrName, ...rest] = fieldPath.split(".");
222
+ if (!obj.attributes) {
223
+ return undefined;
224
+ }
225
+
226
+ for (const attr of obj.attributes) {
227
+ if (attr.name === attrName) {
228
+ return nestedLookupByLanguage(attr.value, rest.join("."), language);
229
+ }
230
+ }
231
+ }
232
+
233
+ if (fieldPath === "prices.currentCentAmount") {
234
+ return obj.prices && obj.prices.length > 0
235
+ ? obj.prices[0].value.centAmount
236
+ : undefined;
237
+ }
238
+
239
+ return nestedLookupByLanguage(obj, fieldPath, language);
240
+ };
241
+
242
+ const nestedLookupByLanguage = (
243
+ obj: any,
244
+ path: string,
245
+ language?: string,
246
+ ): any => {
247
+ const value = nestedLookup(obj, path);
248
+
249
+ if (language && value && typeof value === "object") {
250
+ // Due to Commercetools supporting "en", but also "en-US" as language, we need to find the best match
251
+ const matchingLanguageKey = Object.keys(value).find((key) =>
252
+ key.toLowerCase().startsWith(language.toLowerCase()),
253
+ );
254
+
255
+ return matchingLanguageKey ? value[matchingLanguageKey] : undefined;
256
+ }
257
+
258
+ return value;
259
+ };
260
+
261
+ // type guards
262
+ const isSearchAndExpression = (
263
+ expr: _SearchQuery,
264
+ ): expr is SearchAndExpression =>
265
+ (expr as SearchAndExpression).and !== undefined;
266
+
267
+ const isSearchOrExpression = (expr: _SearchQuery): expr is SearchOrExpression =>
268
+ (expr as SearchOrExpression).or !== undefined;
269
+
270
+ type SearchRangeExpression =
271
+ | SearchDateRangeExpression
272
+ | SearchDateTimeRangeExpression
273
+ | SearchLongRangeExpression
274
+ | SearchNumberRangeExpression
275
+ | SearchTimeRangeExpression;
276
+
277
+ // Type guard for SearchNotExpression
278
+ const isSearchNotExpression = (
279
+ expr: _SearchQueryExpression,
280
+ ): expr is SearchNotExpression =>
281
+ (expr as SearchNotExpression).not !== undefined;
282
+
283
+ // Type guard for SearchFilterExpression
284
+ const isSearchFilterExpression = (
285
+ expr: _SearchQueryExpression,
286
+ ): expr is SearchFilterExpression =>
287
+ (expr as SearchFilterExpression).filter !== undefined;
288
+
289
+ // Type guard for SearchDateRangeExpression
290
+ const isSearchRangeExpression = (
291
+ expr: _SearchQueryExpression,
292
+ ): expr is SearchRangeExpression =>
293
+ (expr as SearchRangeExpression).range !== undefined;
294
+
295
+ // Type guard for SearchExactExpression
296
+ const isSearchExactExpression = (
297
+ expr: _SearchQueryExpression,
298
+ ): expr is SearchExactExpression =>
299
+ (expr as SearchExactExpression).exact !== undefined;
300
+
301
+ // Type guard for SearchExistsExpression
302
+ const isSearchExistsExpression = (
303
+ expr: _SearchQueryExpression,
304
+ ): expr is SearchExistsExpression =>
305
+ (expr as SearchExistsExpression).exists !== undefined;
306
+
307
+ // Type guard for SearchFullTextExpression
308
+ const isSearchFullTextExpression = (
309
+ expr: _SearchQueryExpression,
310
+ ): expr is SearchFullTextExpression =>
311
+ (expr as SearchFullTextExpression).fullText !== undefined;
312
+
313
+ // Type guard for SearchFullTextPrefixExpression
314
+ const isSearchFullTextPrefixExpression = (
315
+ expr: _SearchQueryExpression,
316
+ ): expr is SearchFullTextPrefixExpression =>
317
+ (expr as SearchFullTextPrefixExpression).fullTextPrefix !== undefined;
318
+
319
+ // Type guard for SearchPrefixExpression
320
+ const isSearchPrefixExpression = (
321
+ expr: _SearchQueryExpression,
322
+ ): expr is SearchPrefixExpression =>
323
+ (expr as SearchPrefixExpression).prefix !== undefined;
324
+
325
+ // Type guard for SearchWildCardExpression
326
+ const isSearchWildCardExpression = (
327
+ expr: _SearchQueryExpression,
328
+ ): expr is SearchWildCardExpression =>
329
+ (expr as SearchWildCardExpression).wildcard !== undefined;
@@ -7,6 +7,7 @@ import {
7
7
  ProductSearchResult,
8
8
  } from "@commercetools/platform-sdk";
9
9
  import { CommercetoolsError } from "./exceptions";
10
+ import { parseSearchQuery } from "./lib/productSearchFilter";
10
11
  import { validateSearchQuery } from "./lib/searchQueryTypeChecker";
11
12
  import { applyPriceSelector } from "./priceSelector";
12
13
  import { AbstractStorage } from "./storage";
@@ -22,7 +23,7 @@ export class ProductSearch {
22
23
  projectKey: string,
23
24
  params: ProductSearchRequest,
24
25
  ): ProductPagedSearchResponse {
25
- const resources = this._storage
26
+ let resources = this._storage
26
27
  .all(projectKey, "product")
27
28
  .map((r) =>
28
29
  this.transform(r, params.productProjectionParameters?.staged ?? false),
@@ -34,10 +35,19 @@ export class ProductSearch {
34
35
  return true;
35
36
  });
36
37
 
37
- // Validate query, if given
38
+ const markMatchingVariant = params.markMatchingVariants ?? false;
39
+
40
+ // Apply filters pre facetting
38
41
  if (params.query) {
39
42
  try {
40
43
  validateSearchQuery(params.query);
44
+
45
+ const matchFunc = parseSearchQuery(params.query);
46
+
47
+ // Filters can modify the output. So clone the resources first.
48
+ resources = resources.filter((resource) =>
49
+ matchFunc(resource, markMatchingVariant),
50
+ );
41
51
  } catch (err) {
42
52
  console.error(err);
43
53
  throw new CommercetoolsError<InvalidInputError>(
@@ -6,6 +6,7 @@ import type {
6
6
  OrderAddReturnInfoAction,
7
7
  OrderChangeOrderStateAction,
8
8
  OrderChangePaymentStateAction,
9
+ OrderChangeShipmentStateAction,
9
10
  OrderSetBillingAddressAction,
10
11
  OrderSetCustomFieldAction,
11
12
  OrderSetCustomTypeAction,
@@ -117,6 +118,14 @@ export class OrderUpdateHandler
117
118
  resource.paymentState = paymentState;
118
119
  }
119
120
 
121
+ changeShipmentState(
122
+ context: RepositoryContext,
123
+ resource: Writable<Order>,
124
+ { shipmentState }: OrderChangeShipmentStateAction,
125
+ ) {
126
+ resource.shipmentState = shipmentState;
127
+ }
128
+
120
129
  setBillingAddress(
121
130
  context: RepositoryContext,
122
131
  resource: Writable<Order>,