@labdigital/commercetools-mock 2.29.0 → 2.30.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.29.0",
3
+ "version": "2.30.0",
4
4
  "license": "MIT",
5
5
  "author": "Michael van Tellingen",
6
6
  "type": "module",
@@ -0,0 +1,96 @@
1
+ import type {
2
+ SearchAndExpression,
3
+ SearchOrExpression,
4
+ _SearchQuery,
5
+ _SearchQueryExpression,
6
+ } from "@commercetools/platform-sdk";
7
+ import { describe, expect, it } from "vitest";
8
+ import {
9
+ isSearchAndExpression,
10
+ isSearchAnyValue,
11
+ isSearchExactExpression,
12
+ isSearchExistsExpression,
13
+ isSearchFilterExpression,
14
+ isSearchFullTextExpression,
15
+ isSearchFullTextPrefixExpression,
16
+ isSearchNotExpression,
17
+ isSearchOrExpression,
18
+ isSearchPrefixExpression,
19
+ isSearchRangeExpression,
20
+ isSearchWildCardExpression,
21
+ validateSearchQuery,
22
+ } from "./searchQueryTypeChecker";
23
+
24
+ describe("searchQueryTypeChecker", () => {
25
+ it("should validate SearchAndExpression", () => {
26
+ const query: SearchAndExpression = { and: [] };
27
+ expect(isSearchAndExpression(query)).toBe(true);
28
+ });
29
+
30
+ it("should validate SearchOrExpression", () => {
31
+ const query: SearchOrExpression = { or: [] };
32
+ expect(isSearchOrExpression(query)).toBe(true);
33
+ });
34
+
35
+ it("should validate SearchNotExpression", () => {
36
+ const query: _SearchQueryExpression = { not: {} };
37
+ expect(isSearchNotExpression(query)).toBe(true);
38
+ });
39
+
40
+ it("should validate SearchFilterExpression", () => {
41
+ const query: _SearchQueryExpression = { filter: [] };
42
+ expect(isSearchFilterExpression(query)).toBe(true);
43
+ });
44
+
45
+ it("should validate SearchRangeExpression", () => {
46
+ const query: _SearchQueryExpression = { range: {} };
47
+ expect(isSearchRangeExpression(query)).toBe(true);
48
+ });
49
+
50
+ it("should validate SearchExactExpression", () => {
51
+ const query: _SearchQueryExpression = { exact: "some-exact" };
52
+ expect(isSearchExactExpression(query)).toBe(true);
53
+ });
54
+
55
+ it("should validate SearchExistsExpression", () => {
56
+ const query: _SearchQueryExpression = { exists: true };
57
+ expect(isSearchExistsExpression(query)).toBe(true);
58
+ });
59
+
60
+ it("should validate SearchFullTextExpression", () => {
61
+ const query: _SearchQueryExpression = { fullText: "some-text" };
62
+ expect(isSearchFullTextExpression(query)).toBe(true);
63
+ });
64
+
65
+ it("should validate SearchFullTextPrefixExpression", () => {
66
+ const query: _SearchQueryExpression = { fullTextPrefix: "some-prefix" };
67
+ expect(isSearchFullTextPrefixExpression(query)).toBe(true);
68
+ });
69
+
70
+ it("should validate SearchPrefixExpression", () => {
71
+ const query: _SearchQueryExpression = { prefix: "some-prefix" };
72
+ expect(isSearchPrefixExpression(query)).toBe(true);
73
+ });
74
+
75
+ it("should validate SearchWildCardExpression", () => {
76
+ const query: _SearchQueryExpression = { wildcard: "some-wildcard" };
77
+ expect(isSearchWildCardExpression(query)).toBe(true);
78
+ });
79
+
80
+ it("should validate SearchAnyValue", () => {
81
+ const query: _SearchQueryExpression = { value: "some-value" };
82
+ expect(isSearchAnyValue(query)).toBe(true);
83
+ });
84
+
85
+ it("should throw an error for unsupported query", () => {
86
+ const query = { unsupported: "unsupported" } as _SearchQuery;
87
+ expect(() => validateSearchQuery(query)).toThrow(
88
+ "Unsupported search query expression",
89
+ );
90
+ });
91
+
92
+ it("should not throw an error for supported query", () => {
93
+ const query: SearchAndExpression = { and: [] };
94
+ expect(() => validateSearchQuery(query)).not.toThrow();
95
+ });
96
+ });
@@ -0,0 +1,120 @@
1
+ import type {
2
+ SearchAndExpression,
3
+ SearchAnyValue,
4
+ SearchDateRangeExpression,
5
+ SearchDateTimeRangeExpression,
6
+ SearchExactExpression,
7
+ SearchExistsExpression,
8
+ SearchFilterExpression,
9
+ SearchFullTextExpression,
10
+ SearchFullTextPrefixExpression,
11
+ SearchLongRangeExpression,
12
+ SearchNotExpression,
13
+ SearchNumberRangeExpression,
14
+ SearchOrExpression,
15
+ SearchPrefixExpression,
16
+ SearchTimeRangeExpression,
17
+ SearchWildCardExpression,
18
+ _SearchQuery,
19
+ _SearchQueryExpression,
20
+ } from "@commercetools/platform-sdk";
21
+
22
+ export const validateSearchQuery = (query: _SearchQuery): void => {
23
+ if (isSearchAndExpression(query)) {
24
+ query.and.forEach((expr) => validateSearchQuery(expr));
25
+ } else if (isSearchOrExpression(query)) {
26
+ query.or.forEach((expr) => validateSearchQuery(expr));
27
+ } else if (isSearchNotExpression(query)) {
28
+ validateSearchQuery(query.not);
29
+ } else if (
30
+ isSearchFilterExpression(query) ||
31
+ isSearchRangeExpression(query) ||
32
+ isSearchExactExpression(query) ||
33
+ isSearchExistsExpression(query) ||
34
+ isSearchFullTextExpression(query) ||
35
+ isSearchFullTextPrefixExpression(query) ||
36
+ isSearchPrefixExpression(query) ||
37
+ isSearchWildCardExpression(query) ||
38
+ isSearchAnyValue(query)
39
+ ) {
40
+ return;
41
+ } else {
42
+ throw new Error("Unsupported search query expression");
43
+ }
44
+ };
45
+
46
+ // Type guards
47
+ export const isSearchAndExpression = (
48
+ expr: _SearchQuery,
49
+ ): expr is SearchAndExpression =>
50
+ (expr as SearchAndExpression).and !== undefined;
51
+
52
+ export const isSearchOrExpression = (
53
+ expr: _SearchQuery,
54
+ ): expr is SearchOrExpression => (expr as SearchOrExpression).or !== undefined;
55
+
56
+ export type SearchRangeExpression =
57
+ | SearchDateRangeExpression
58
+ | SearchDateTimeRangeExpression
59
+ | SearchLongRangeExpression
60
+ | SearchNumberRangeExpression
61
+ | SearchTimeRangeExpression;
62
+
63
+ // Type guard for SearchNotExpression
64
+ export const isSearchNotExpression = (
65
+ expr: _SearchQueryExpression,
66
+ ): expr is SearchNotExpression =>
67
+ (expr as SearchNotExpression).not !== undefined;
68
+
69
+ // Type guard for SearchFilterExpression
70
+ export const isSearchFilterExpression = (
71
+ expr: _SearchQueryExpression,
72
+ ): expr is SearchFilterExpression =>
73
+ (expr as SearchFilterExpression).filter !== undefined;
74
+
75
+ // Type guard for SearchDateRangeExpression
76
+ export const isSearchRangeExpression = (
77
+ expr: _SearchQueryExpression,
78
+ ): expr is SearchRangeExpression =>
79
+ (expr as SearchRangeExpression).range !== undefined;
80
+
81
+ // Type guard for SearchExactExpression
82
+ export const isSearchExactExpression = (
83
+ expr: _SearchQueryExpression,
84
+ ): expr is SearchExactExpression =>
85
+ (expr as SearchExactExpression).exact !== undefined;
86
+
87
+ // Type guard for SearchExistsExpression
88
+ export const isSearchExistsExpression = (
89
+ expr: _SearchQueryExpression,
90
+ ): expr is SearchExistsExpression =>
91
+ (expr as SearchExistsExpression).exists !== undefined;
92
+
93
+ // Type guard for SearchFullTextExpression
94
+ export const isSearchFullTextExpression = (
95
+ expr: _SearchQueryExpression,
96
+ ): expr is SearchFullTextExpression =>
97
+ (expr as SearchFullTextExpression).fullText !== undefined;
98
+
99
+ // Type guard for SearchFullTextPrefixExpression
100
+ export const isSearchFullTextPrefixExpression = (
101
+ expr: _SearchQueryExpression,
102
+ ): expr is SearchFullTextPrefixExpression =>
103
+ (expr as SearchFullTextPrefixExpression).fullTextPrefix !== undefined;
104
+
105
+ // Type guard for SearchPrefixExpression
106
+ export const isSearchPrefixExpression = (
107
+ expr: _SearchQueryExpression,
108
+ ): expr is SearchPrefixExpression =>
109
+ (expr as SearchPrefixExpression).prefix !== undefined;
110
+
111
+ // Type guard for SearchWildCardExpression
112
+ export const isSearchWildCardExpression = (
113
+ expr: _SearchQueryExpression,
114
+ ): expr is SearchWildCardExpression =>
115
+ (expr as SearchWildCardExpression).wildcard !== undefined;
116
+
117
+ // Type guard for SearchAnyValue
118
+ export const isSearchAnyValue = (
119
+ expr: _SearchQueryExpression,
120
+ ): expr is SearchAnyValue => (expr as SearchAnyValue).value !== undefined;
@@ -77,7 +77,7 @@ export class ProductProjectionSearch {
77
77
  currency: params.priceCurrency,
78
78
  });
79
79
 
80
- // Apply filters pre facetting
80
+ // Apply filters pre faceting
81
81
  if (params.filter) {
82
82
  try {
83
83
  const filters = params.filter.map(parseFilterExpression);
@@ -0,0 +1,123 @@
1
+ import {
2
+ InvalidInputError,
3
+ Product,
4
+ ProductPagedSearchResponse,
5
+ ProductProjection,
6
+ ProductSearchRequest,
7
+ ProductSearchResult,
8
+ } from "@commercetools/platform-sdk";
9
+ import { CommercetoolsError } from "./exceptions";
10
+ import { validateSearchQuery } from "./lib/searchQueryTypeChecker";
11
+ import { applyPriceSelector } from "./priceSelector";
12
+ import { AbstractStorage } from "./storage";
13
+
14
+ export class ProductSearch {
15
+ protected _storage: AbstractStorage;
16
+
17
+ constructor(storage: AbstractStorage) {
18
+ this._storage = storage;
19
+ }
20
+
21
+ search(
22
+ projectKey: string,
23
+ params: ProductSearchRequest,
24
+ ): ProductPagedSearchResponse {
25
+ const resources = this._storage
26
+ .all(projectKey, "product")
27
+ .map((r) =>
28
+ this.transform(r, params.productProjectionParameters?.staged ?? false),
29
+ )
30
+ .filter((p) => {
31
+ if (!params.productProjectionParameters?.staged ?? false) {
32
+ return p.published;
33
+ }
34
+ return true;
35
+ });
36
+
37
+ // Validate query, if given
38
+ if (params.query) {
39
+ try {
40
+ validateSearchQuery(params.query);
41
+ } catch (err) {
42
+ console.error(err);
43
+ throw new CommercetoolsError<InvalidInputError>(
44
+ {
45
+ code: "InvalidInput",
46
+ message: (err as any).message,
47
+ },
48
+ 400,
49
+ );
50
+ }
51
+ }
52
+
53
+ // Apply the priceSelector
54
+ if (params.productProjectionParameters) {
55
+ applyPriceSelector(resources, {
56
+ country: params.productProjectionParameters.priceCountry,
57
+ channel: params.productProjectionParameters.priceChannel,
58
+ customerGroup: params.productProjectionParameters.priceCustomerGroup,
59
+ currency: params.productProjectionParameters.priceCurrency,
60
+ });
61
+ }
62
+
63
+ // @TODO: Determine whether or not to spoof search, facet filtering, wildcard, boosting and/or sorting.
64
+ // For now this is deliberately not supported.
65
+
66
+ const offset = params.offset || 0;
67
+ const limit = params.limit || 20;
68
+ const productProjectionsResult = resources.slice(offset, offset + limit);
69
+
70
+ /**
71
+ * Do not supply productProjection if productProjectionParameters are not given
72
+ * https://docs.commercetools.com/api/projects/product-search#with-product-projection-parameters
73
+ */
74
+ const productProjectionsParameterGiven =
75
+ !!params?.productProjectionParameters;
76
+
77
+ // Transform to ProductSearchResult
78
+ const results: ProductSearchResult[] = productProjectionsResult.map(
79
+ (product) => ({
80
+ productProjection: productProjectionsParameterGiven
81
+ ? product
82
+ : undefined,
83
+ id: product.id,
84
+ /**
85
+ * @TODO: possibly add support for optional matchingVariants
86
+ * https://docs.commercetools.com/api/projects/product-search#productsearchmatchingvariants
87
+ */
88
+ }),
89
+ );
90
+
91
+ return {
92
+ total: resources.length,
93
+ offset: offset,
94
+ limit: limit,
95
+ results: results,
96
+ facets: [],
97
+ };
98
+ }
99
+
100
+ transform(product: Product, staged: boolean): ProductProjection {
101
+ const obj = !staged
102
+ ? product.masterData.current
103
+ : product.masterData.staged;
104
+
105
+ return {
106
+ id: product.id,
107
+ createdAt: product.createdAt,
108
+ lastModifiedAt: product.lastModifiedAt,
109
+ version: product.version,
110
+ name: obj.name,
111
+ key: product.key,
112
+ description: obj.description,
113
+ metaDescription: obj.metaDescription,
114
+ slug: obj.slug,
115
+ categories: obj.categories,
116
+ masterVariant: obj.masterVariant,
117
+ variants: obj.variants,
118
+ productType: product.productType,
119
+ hasStagedChanges: product.masterData.hasStagedChanges,
120
+ published: product.masterData.published,
121
+ };
122
+ }
123
+ }
@@ -4,12 +4,15 @@ import type {
4
4
  Product,
5
5
  ProductData,
6
6
  ProductDraft,
7
+ ProductPagedSearchResponse,
8
+ ProductSearchRequest,
7
9
  ProductTypeReference,
8
10
  StateReference,
9
11
  TaxCategoryReference,
10
12
  } from "@commercetools/platform-sdk";
11
13
  import { CommercetoolsError } from "~src/exceptions";
12
14
  import { getBaseResourceProperties } from "~src/helpers";
15
+ import { ProductSearch } from "~src/product-search";
13
16
  import { AbstractStorage } from "~src/storage/abstract";
14
17
  import { AbstractResourceRepository, RepositoryContext } from "../abstract";
15
18
  import { getReferenceFromResourceIdentifier } from "../helpers";
@@ -17,9 +20,12 @@ import { ProductUpdateHandler } from "./actions";
17
20
  import { variantFromDraft } from "./helpers";
18
21
 
19
22
  export class ProductRepository extends AbstractResourceRepository<"product"> {
23
+ protected _searchService: ProductSearch;
24
+
20
25
  constructor(storage: AbstractStorage) {
21
26
  super("product", storage);
22
27
  this.actions = new ProductUpdateHandler(storage);
28
+ this._searchService = new ProductSearch(storage);
23
29
  }
24
30
 
25
31
  create(context: RepositoryContext, draft: ProductDraft): Product {
@@ -127,4 +133,11 @@ export class ProductRepository extends AbstractResourceRepository<"product"> {
127
133
 
128
134
  return this.saveNew(context, resource);
129
135
  }
136
+
137
+ search(
138
+ context: RepositoryContext,
139
+ searchRequest: ProductSearchRequest,
140
+ ): ProductPagedSearchResponse {
141
+ return this._searchService.search(context.projectKey, searchRequest);
142
+ }
130
143
  }
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  Project,
3
+ ProjectChangeBusinessUnitStatusOnCreationAction,
3
4
  ProjectChangeCartsConfigurationAction,
4
5
  ProjectChangeCountriesAction,
5
6
  ProjectChangeCountryTaxRateFallbackEnabledAction,
@@ -13,6 +14,7 @@ import type {
13
14
  ProjectSetShippingRateInputTypeAction,
14
15
  ProjectUpdateAction,
15
16
  } from "@commercetools/platform-sdk";
17
+ import { ProjectSetBusinessUnitAssociateRoleOnCreationAction } from "@commercetools/platform-sdk/dist/declarations/src/generated/models/project";
16
18
  import { maskSecretValue } from "../lib/masking";
17
19
  import { AbstractStorage } from "../storage/abstract";
18
20
  import type { Writable } from "../types";
@@ -111,6 +113,20 @@ class ProjectUpdateHandler
111
113
  messagesConfiguration.deleteDaysAfterCreation;
112
114
  }
113
115
 
116
+ changeMyBusinessUnitStatusOnCreation(
117
+ context: RepositoryContext,
118
+ resource: Writable<Project>,
119
+ { status }: ProjectChangeBusinessUnitStatusOnCreationAction,
120
+ ) {
121
+ if (resource.businessUnits === undefined) {
122
+ resource.businessUnits = {
123
+ myBusinessUnitStatusOnCreation: "Inactive",
124
+ };
125
+ }
126
+
127
+ resource.businessUnits.myBusinessUnitStatusOnCreation = status;
128
+ }
129
+
114
130
  changeName(
115
131
  context: RepositoryContext,
116
132
  resource: Writable<Project>,
@@ -153,6 +169,24 @@ class ProjectUpdateHandler
153
169
  resource.externalOAuth = externalOAuth;
154
170
  }
155
171
 
172
+ setMyBusinessUnitAssociateRoleOnCreation(
173
+ context: RepositoryContext,
174
+ resource: Writable<Project>,
175
+ { associateRole }: ProjectSetBusinessUnitAssociateRoleOnCreationAction,
176
+ ) {
177
+ if (resource.businessUnits === undefined) {
178
+ resource.businessUnits = {
179
+ //Default status, so we set it here also
180
+ myBusinessUnitStatusOnCreation: "Inactive",
181
+ };
182
+ }
183
+
184
+ resource.businessUnits.myBusinessUnitAssociateRoleOnCreation = {
185
+ typeId: associateRole.typeId,
186
+ key: associateRole.key ?? "unknown",
187
+ };
188
+ }
189
+
156
190
  setShippingRateInputType(
157
191
  context: RepositoryContext,
158
192
  resource: Writable<Project>,
@@ -1,4 +1,4 @@
1
- import type {
1
+ import {
2
2
  Category,
3
3
  CategoryDraft,
4
4
  Image,
@@ -6,6 +6,9 @@ import type {
6
6
  Product,
7
7
  ProductData,
8
8
  ProductDraft,
9
+ ProductPagedSearchResponse,
10
+ ProductSearchRequest,
11
+ ProductSearchResult,
9
12
  ProductType,
10
13
  ProductTypeDraft,
11
14
  State,
@@ -1485,4 +1488,76 @@ describe("Product update actions", () => {
1485
1488
  ?.myCustomField,
1486
1489
  ).toBe("MyRandomValue");
1487
1490
  });
1491
+
1492
+ // Test the general product search implementation
1493
+ describe("Product Search - Generic", () => {
1494
+ test("Pagination", async () => {
1495
+ {
1496
+ const body: ProductSearchRequest = {
1497
+ productProjectionParameters: {
1498
+ storeProjection: "dummy-store",
1499
+ localeProjection: ["en-US"],
1500
+ priceCurrency: "EUR",
1501
+ priceChannel: "dummy-channel",
1502
+ expand: ["categories[*]", "categories[*].ancestors[*]"],
1503
+ },
1504
+ limit: 24,
1505
+ };
1506
+ const response = await supertest(ctMock.app)
1507
+ .post("/dummy/products/search")
1508
+ .send(body);
1509
+
1510
+ const pagedSearchResponse: ProductPagedSearchResponse = response.body;
1511
+ expect(pagedSearchResponse.limit).toBe(24);
1512
+ expect(pagedSearchResponse.offset).toBe(0);
1513
+ expect(pagedSearchResponse.total).toBeGreaterThan(0);
1514
+
1515
+ // Deliberately not supported fow now
1516
+ expect(pagedSearchResponse.facets).toEqual([]);
1517
+
1518
+ const results: ProductSearchResult[] = pagedSearchResponse.results;
1519
+ expect(results).toBeDefined();
1520
+ expect(results.length).toBeGreaterThan(0);
1521
+
1522
+ // Find product with sku "1337" to be part of the search results
1523
+ const productFound = results.find(
1524
+ (result) => result?.productProjection?.masterVariant?.sku === "1337",
1525
+ );
1526
+ expect(productFound).toBeDefined();
1527
+
1528
+ const priceCurrencyMatch = results.find((result) =>
1529
+ result?.productProjection?.masterVariant?.prices?.find(
1530
+ (price) => price?.value?.currencyCode === "EUR",
1531
+ ),
1532
+ );
1533
+ expect(priceCurrencyMatch).toBeDefined();
1534
+ }
1535
+ {
1536
+ const body: ProductSearchRequest = {
1537
+ limit: 88,
1538
+ offset: 88,
1539
+ };
1540
+
1541
+ const response = await supertest(ctMock.app)
1542
+ .post("/dummy/products/search")
1543
+ .send(body);
1544
+
1545
+ const pagedSearchResponse: ProductPagedSearchResponse = response.body;
1546
+ expect(pagedSearchResponse.limit).toBe(88);
1547
+ expect(pagedSearchResponse.offset).toBe(88);
1548
+ expect(pagedSearchResponse.total).toBeGreaterThan(0);
1549
+
1550
+ // No results, since we start at offset 88
1551
+ const results: ProductSearchResult[] = pagedSearchResponse.results;
1552
+ expect(results).toBeDefined();
1553
+ expect(results.length).toBe(0);
1554
+
1555
+ // Product with sku "1337" should not be part of the results
1556
+ const productFound = results.find(
1557
+ (result) => result?.productProjection?.masterVariant?.sku === "1337",
1558
+ );
1559
+ expect(productFound).toBeUndefined();
1560
+ }
1561
+ });
1562
+ });
1488
1563
  });
@@ -1,4 +1,5 @@
1
- import { Router } from "express";
1
+ import { Request, Response, Router } from "express";
2
+ import { getRepositoryContext } from "~src/repositories/helpers";
2
3
  import { ProductRepository } from "../repositories/product";
3
4
  import AbstractService from "./abstract";
4
5
 
@@ -13,4 +14,17 @@ export class ProductService extends AbstractService {
13
14
  getBasePath() {
14
15
  return "products";
15
16
  }
17
+
18
+ extraRoutes(router: Router) {
19
+ router.post("/search", this.search.bind(this));
20
+ }
21
+
22
+ search(request: Request, response: Response) {
23
+ const searchBody = request.body;
24
+ const resource = this.repository.search(
25
+ getRepositoryContext(request),
26
+ searchBody,
27
+ );
28
+ return response.status(200).send(resource);
29
+ }
16
30
  }
package/src/types.ts CHANGED
@@ -10,11 +10,11 @@ export type ShallowWritable<T> = { -readonly [P in keyof T]: T[P] };
10
10
  export type ServiceTypes =
11
11
  | ctp.ReferenceTypeId
12
12
  | "product-projection"
13
+ | "product-search"
13
14
  | "my-cart"
14
15
  | "my-order"
15
16
  | "my-payment"
16
- | "my-customer"
17
- | "product-projection";
17
+ | "my-customer";
18
18
 
19
19
  export type Services = Partial<{
20
20
  [index in ServiceTypes]: AbstractService;