@labdigital/commercetools-mock 0.7.1 → 0.9.1

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.
@@ -4,9 +4,25 @@ import {
4
4
  Product,
5
5
  ProductProjection,
6
6
  QueryParam,
7
+ FacetResults,
8
+ FacetTerm,
9
+ TermFacetResult,
10
+ RangeFacetResult,
11
+ FilteredFacetResult,
7
12
  } from '@commercetools/platform-sdk'
13
+ import { ByProjectKeyProductProjectionsSearchRequestBuilder } from '@commercetools/platform-sdk/dist/declarations/src/generated/client/search/by-project-key-product-projections-search-request-builder'
14
+ import { nestedLookup } from './helpers'
15
+ import { ProductService } from './services/product'
16
+ import { Writable } from './types'
8
17
  import { CommercetoolsError } from './exceptions'
9
- import { parseFilterExpression } from './lib/projectionSearchFilter'
18
+ import {
19
+ FilterExpression,
20
+ generateFacetFunc,
21
+ getVariants,
22
+ parseFilterExpression,
23
+ RangeExpression,
24
+ resolveVariantValue,
25
+ } from './lib/projectionSearchFilter'
10
26
  import { applyPriceSelector } from './priceSelector'
11
27
  import { AbstractStorage } from './storage'
12
28
 
@@ -68,10 +84,9 @@ export class ProductProjectionSearch {
68
84
  )
69
85
 
70
86
  // Filters can modify the output. So clone the resources first.
71
- resources = resources
72
- .filter(resource =>
73
- filters.every(f => f(resource, markMatchingVariant))
74
- )
87
+ resources = resources.filter(resource =>
88
+ filters.every(f => f(resource, markMatchingVariant))
89
+ )
75
90
  } catch (err) {
76
91
  throw new CommercetoolsError<InvalidInputError>(
77
92
  {
@@ -84,6 +99,7 @@ export class ProductProjectionSearch {
84
99
  }
85
100
 
86
101
  // TODO: Calculate facets
102
+ const facets = this.getFacets(params, resources)
87
103
 
88
104
  // Apply filters post facetting
89
105
  if (params['filter.query']) {
@@ -91,10 +107,10 @@ export class ProductProjectionSearch {
91
107
  const filters = params['filter.query'].map(f =>
92
108
  parseFilterExpression(f, params.staged ?? false)
93
109
  )
94
- resources = resources
95
- .filter(resource =>
96
- filters.every(f => f(resource, markMatchingVariant))
97
- )
110
+
111
+ resources = resources.filter(resource =>
112
+ filters.every(f => f(resource, markMatchingVariant))
113
+ )
98
114
  } catch (err) {
99
115
  throw new CommercetoolsError<InvalidInputError>(
100
116
  {
@@ -127,7 +143,7 @@ export class ProductProjectionSearch {
127
143
  offset: offset,
128
144
  limit: limit,
129
145
  results: resources.map(this.transform),
130
- facets: {},
146
+ facets: facets,
131
147
  }
132
148
  }
133
149
 
@@ -139,6 +155,9 @@ export class ProductProjectionSearch {
139
155
  lastModifiedAt: product.lastModifiedAt,
140
156
  version: product.version,
141
157
  name: obj.name,
158
+ key: product.key,
159
+ description: obj.description,
160
+ metaDescription: obj.metaDescription,
142
161
  slug: obj.slug,
143
162
  categories: obj.categories,
144
163
  masterVariant: obj.masterVariant,
@@ -146,4 +165,185 @@ export class ProductProjectionSearch {
146
165
  productType: product.productType,
147
166
  }
148
167
  }
168
+
169
+ getFacets(
170
+ params: ProductProjectionSearchParams,
171
+ products: Product[]
172
+ ): FacetResults {
173
+ if (!params.facet) return {}
174
+ const staged = false
175
+ const result: FacetResults = {}
176
+
177
+ for (const facet of params.facet) {
178
+ const expression = generateFacetFunc(facet)
179
+
180
+ // Term Facet
181
+ if (expression.type === 'TermExpression') {
182
+ result[facet] = this.termFacet(expression.source, products, staged)
183
+ }
184
+
185
+ // Range Facet
186
+ if (expression.type === 'RangeExpression') {
187
+ result[expression.source] = this.rangeFacet(
188
+ expression.source,
189
+ expression.children,
190
+ products,
191
+ staged
192
+ )
193
+ }
194
+
195
+ // FilteredFacet
196
+ if (expression.type === 'FilterExpression') {
197
+ result[expression.source] = this.filterFacet(
198
+ expression.source,
199
+ expression.children,
200
+ products,
201
+ staged
202
+ )
203
+ }
204
+ }
205
+
206
+ return result
207
+ }
208
+
209
+ /**
210
+ * TODO: This implemention needs the following additional features:
211
+ * - counting products
212
+ * - correct dataType
213
+ */
214
+ termFacet(
215
+ facet: string,
216
+ products: Product[],
217
+ staged: boolean
218
+ ): TermFacetResult {
219
+ const result: Writable<TermFacetResult> = {
220
+ type: 'terms',
221
+ dataType: 'text',
222
+ missing: 0,
223
+ total: 0,
224
+ other: 0,
225
+ terms: [],
226
+ }
227
+ const terms: Record<any, number> = {}
228
+
229
+ if (facet.startsWith('variants.')) {
230
+ products.forEach(p => {
231
+ const variants = getVariants(p, staged)
232
+ variants.forEach(v => {
233
+ result.total++
234
+
235
+ let value = resolveVariantValue(v, facet)
236
+ if (value === undefined) {
237
+ result.missing++
238
+ } else {
239
+ if (typeof value === 'number') {
240
+ value = Number(value).toFixed(1)
241
+ }
242
+ terms[value] = value in terms ? terms[value] + 1 : 1
243
+ }
244
+ })
245
+ })
246
+ } else {
247
+ products.forEach(p => {
248
+ const value = nestedLookup(p, facet)
249
+ result.total++
250
+ if (value === undefined) {
251
+ result.missing++
252
+ } else {
253
+ terms[value] = value in terms ? terms[value] + 1 : 1
254
+ }
255
+ })
256
+ }
257
+ for (const term in terms) {
258
+ result.terms.push({
259
+ term: term as any,
260
+ count: terms[term],
261
+ })
262
+ }
263
+ return result
264
+ }
265
+
266
+ filterFacet(
267
+ source: string,
268
+ filters: FilterExpression[] | undefined,
269
+ products: Product[],
270
+ staged: boolean
271
+ ): FilteredFacetResult {
272
+ let count = 0
273
+ if (source.startsWith('variants.')) {
274
+ for (const p of products) {
275
+ for (const v of getVariants(p, staged)) {
276
+ const val = resolveVariantValue(v, source)
277
+ if (filters?.some(f => f.match(val))) {
278
+ count++
279
+ }
280
+ }
281
+ }
282
+ } else {
283
+ throw new Error('not supported')
284
+ }
285
+
286
+ return {
287
+ type: 'filter',
288
+ count: count,
289
+ }
290
+ }
291
+
292
+ rangeFacet(
293
+ source: string,
294
+ ranges: RangeExpression[] | undefined,
295
+ products: Product[],
296
+ staged: boolean
297
+ ): RangeFacetResult {
298
+ const counts =
299
+ ranges?.map(range => {
300
+ if (source.startsWith('variants.')) {
301
+ const values = []
302
+ for (const p of products) {
303
+ for (const v of getVariants(p, staged)) {
304
+ const val = resolveVariantValue(v, source)
305
+ if (val === undefined) {
306
+ continue
307
+ }
308
+
309
+ if (range.match(val)) {
310
+ values.push(val)
311
+ }
312
+ }
313
+ }
314
+
315
+ const numValues = values.length
316
+ return {
317
+ type: 'double',
318
+ from: range.start || 0,
319
+ fromStr: range.start !== null ? Number(range.start).toFixed(1) : '',
320
+ to: range.stop || 0,
321
+ toStr: range.stop !== null ? Number(range.stop).toFixed(1) : '',
322
+ count: numValues,
323
+ // totalCount: 0,
324
+ total: values.reduce((a, b) => a + b, 0),
325
+ min: numValues > 0 ? Math.min(...values) : 0,
326
+ max: numValues > 0 ? Math.max(...values) : 0,
327
+ mean: numValues > 0 ? mean(values) : 0,
328
+ }
329
+ } else {
330
+ throw new Error('not supported')
331
+ }
332
+ }) || []
333
+ const data: RangeFacetResult = {
334
+ type: 'range',
335
+ // @ts-ignore
336
+ dataType: 'number',
337
+ ranges: counts,
338
+ }
339
+ return data
340
+ }
341
+ }
342
+
343
+ const mean = (arr: number[]) => {
344
+ let total = 0
345
+ for (let i = 0; i < arr.length; i++) {
346
+ total += arr[i]
347
+ }
348
+ return total / arr.length
149
349
  }
@@ -65,8 +65,18 @@ export abstract class AbstractRepository {
65
65
  if (!deepEqual(modifiedResource, resource)) {
66
66
  this.save(context, modifiedResource)
67
67
  }
68
- return modifiedResource
68
+
69
+ const result = this.postProcessResource(modifiedResource)
70
+ if (!result) {
71
+ throw new Error("invalid post process action")
72
+ }
73
+ return result
74
+ }
75
+
76
+ postProcessResource(resource: BaseResource | null): BaseResource | null {
77
+ return resource
69
78
  }
79
+
70
80
  }
71
81
 
72
82
  export abstract class AbstractResourceRepository extends AbstractRepository {
@@ -79,12 +89,17 @@ export abstract class AbstractResourceRepository extends AbstractRepository {
79
89
  }
80
90
 
81
91
  query(context: RepositoryContext, params: QueryParams = {}) {
82
- return this._storage.query(context.projectKey, this.getTypeId(), {
92
+ const result = this._storage.query(context.projectKey, this.getTypeId(), {
83
93
  expand: params.expand,
84
94
  where: params.where,
85
95
  offset: params.offset,
86
96
  limit: params.limit,
87
97
  })
98
+
99
+ // @ts-ignore
100
+ result.results = result.results.map(this.postProcessResource)
101
+
102
+ return result
88
103
  }
89
104
 
90
105
  get(
@@ -92,7 +107,8 @@ export abstract class AbstractResourceRepository extends AbstractRepository {
92
107
  id: string,
93
108
  params: GetParams = {}
94
109
  ): BaseResource | null {
95
- return this._storage.get(context.projectKey, this.getTypeId(), id, params)
110
+ const resource = this._storage.get(context.projectKey, this.getTypeId(), id, params)
111
+ return this.postProcessResource(resource)
96
112
  }
97
113
 
98
114
  getByKey(
@@ -100,12 +116,13 @@ export abstract class AbstractResourceRepository extends AbstractRepository {
100
116
  key: string,
101
117
  params: GetParams = {}
102
118
  ): BaseResource | null {
103
- return this._storage.getByKey(
119
+ const resource = this._storage.getByKey(
104
120
  context.projectKey,
105
121
  this.getTypeId(),
106
122
  key,
107
123
  params
108
124
  )
125
+ return this.postProcessResource(resource)
109
126
  }
110
127
 
111
128
  delete(
@@ -113,12 +130,13 @@ export abstract class AbstractResourceRepository extends AbstractRepository {
113
130
  id: string,
114
131
  params: GetParams = {}
115
132
  ): BaseResource | null {
116
- return this._storage.delete(
133
+ const resource = this._storage.delete(
117
134
  context.projectKey,
118
135
  this.getTypeId(),
119
136
  id,
120
137
  params
121
138
  )
139
+ return this.postProcessResource(resource)
122
140
  }
123
141
 
124
142
  save(context: RepositoryContext, resource: BaseResource) {
@@ -10,13 +10,30 @@ import {
10
10
  } from '@commercetools/platform-sdk'
11
11
  import { Writable } from '../types'
12
12
  import { getBaseResourceProperties } from '../helpers'
13
- import { AbstractResourceRepository, RepositoryContext } from './abstract'
13
+ import { AbstractResourceRepository, GetParams, RepositoryContext } from './abstract'
14
+ import { maskSecretValue } from '../lib/masking'
14
15
 
15
16
  export class ExtensionRepository extends AbstractResourceRepository {
16
17
  getTypeId(): ReferenceTypeId {
17
18
  return 'extension'
18
19
  }
19
20
 
21
+ postProcessResource(resource: Extension) {
22
+ if (resource) {
23
+ if (resource.destination.type === "HTTP" &&
24
+ resource.destination.authentication?.type === "AuthorizationHeader"
25
+ ) {
26
+ return maskSecretValue(
27
+ resource, 'destination.authentication.headerValue')
28
+ }
29
+ else if (resource.destination.type == "AWSLambda") {
30
+ return maskSecretValue(
31
+ resource, 'destination.accessSecret')
32
+ }
33
+ }
34
+ return resource
35
+ }
36
+
20
37
  create(context: RepositoryContext, draft: ExtensionDraft): Extension {
21
38
  const resource: Extension = {
22
39
  ...getBaseResourceProperties(),
@@ -75,10 +75,44 @@ export const createPrice = (draft: PriceDraft): Price => {
75
75
  }
76
76
 
77
77
  export const createTypedMoney = (value: Money): TypedMoney => {
78
+
79
+ // Taken from https://docs.adyen.com/development-resources/currency-codes
80
+ let fractionDigits = 2
81
+ switch (value.currencyCode.toUpperCase()) {
82
+ case 'BHD':
83
+ case 'IQD':
84
+ case 'JOD':
85
+ case 'KWD':
86
+ case 'LYD':
87
+ case 'OMR':
88
+ case 'TND':
89
+ fractionDigits = 3
90
+ break
91
+ case 'CVE':
92
+ case 'DJF':
93
+ case 'GNF':
94
+ case 'IDR':
95
+ case 'JPY':
96
+ case 'KMF':
97
+ case 'KRW':
98
+ case 'PYG':
99
+ case 'RWF':
100
+ case 'UGX':
101
+ case 'VND':
102
+ case 'VUV':
103
+ case 'XAF':
104
+ case 'XOF':
105
+ case 'XPF':
106
+ fractionDigits = 0
107
+ break
108
+ default:
109
+ fractionDigits = 2
110
+ }
111
+
78
112
  return {
79
113
  type: 'centPrecision',
80
- fractionDigits: 2,
81
114
  ...value,
115
+ fractionDigits: fractionDigits,
82
116
  }
83
117
  }
84
118
 
@@ -135,10 +169,10 @@ export const getReferenceFromResourceIdentifier = <T extends Reference>(
135
169
  )
136
170
  }
137
171
 
138
- return ({
172
+ return {
139
173
  typeId: resourceIdentifier.typeId,
140
174
  id: resource?.id,
141
- } as unknown) as T
175
+ } as unknown as T
142
176
  }
143
177
 
144
178
  export const getRepositoryContext = (request: Request): RepositoryContext => {
@@ -39,6 +39,7 @@ export class ProductProjectionRepository extends AbstractResourceRepository {
39
39
  const results = this._searchService.search(context.projectKey, {
40
40
  filter: QueryParamsAsArray(query.filter),
41
41
  'filter.query': QueryParamsAsArray(query['filter.query']),
42
+ facet: QueryParamsAsArray(query.facet),
42
43
  offset: query.offset ? Number(query.offset) : undefined,
43
44
  limit: query.limit ? Number(query.limit) : undefined,
44
45
  expand: QueryParamsAsArray(query.expand),
@@ -23,11 +23,16 @@ import { maskSecretValue } from '../lib/masking'
23
23
  export class ProjectRepository extends AbstractRepository {
24
24
  get(context: RepositoryContext): Project | null {
25
25
  const resource = this._storage.getProject(context.projectKey)
26
- const masked = maskSecretValue<Project>(
27
- resource,
28
- 'externalOAuth.authorizationHeader'
29
- )
30
- return masked
26
+ return this.postProcessResource(resource)
27
+ }
28
+
29
+ postProcessResource(resource: any): any {
30
+ if (resource) {
31
+ return maskSecretValue(
32
+ resource, 'externalOAuth.authorizationHeader')
33
+ }
34
+ return resource
35
+
31
36
  }
32
37
 
33
38
  save(context: RepositoryContext, resource: Project) {
@@ -2,12 +2,14 @@ import { getBaseResourceProperties } from '../helpers'
2
2
  import { getReferenceFromResourceIdentifier } from './helpers'
3
3
  import {
4
4
  ReferenceTypeId,
5
+ StateReference,
5
6
  State,
6
7
  StateChangeKeyAction,
7
8
  StateDraft,
8
9
  StateSetDescriptionAction,
9
10
  StateSetNameAction,
10
11
  StateSetRolesAction,
12
+ StateSetTransitionsAction,
11
13
  StateUpdateAction,
12
14
  } from '@commercetools/platform-sdk'
13
15
  import { AbstractResourceRepository, RepositoryContext } from './abstract'
@@ -71,5 +73,17 @@ export class StateRepository extends AbstractResourceRepository {
71
73
  ) => {
72
74
  resource.roles = roles
73
75
  },
76
+ setTransitions: (
77
+ context: RepositoryContext,
78
+ resource: Writable<State>,
79
+ { transitions }: StateSetTransitionsAction
80
+ ) => {
81
+ resource.transitions = transitions?.map((resourceId): StateReference => {
82
+ return {
83
+ id: resourceId.id || "",
84
+ typeId: "state",
85
+ }
86
+ })
87
+ }
74
88
  }
75
89
  }
@@ -50,10 +50,29 @@ beforeEach(async () => {
50
50
  attributes: [
51
51
  {
52
52
  name: 'number',
53
- value: '1' as any,
53
+ value: 4 as any,
54
54
  },
55
55
  ],
56
56
  },
57
+ variants: [
58
+ {
59
+ sku: 'my-other-sku',
60
+ prices: [
61
+ {
62
+ value: {
63
+ currencyCode: 'EUR',
64
+ centAmount: 91789,
65
+ },
66
+ },
67
+ ],
68
+ attributes: [
69
+ {
70
+ name: 'number',
71
+ value: 50 as any,
72
+ },
73
+ ],
74
+ },
75
+ ],
57
76
  name: {
58
77
  'nl-NL': 'test product',
59
78
  },
@@ -96,7 +115,26 @@ beforeEach(async () => {
96
115
  images: [],
97
116
  attributes: productDraft.masterVariant?.attributes,
98
117
  },
99
- variants: [],
118
+ variants: [
119
+ {
120
+ id: 2,
121
+ sku: 'my-other-sku',
122
+ prices: [
123
+ {
124
+ id: product.masterData.current.variants[0].prices[0].id,
125
+ value: {
126
+ type: 'centPrecision',
127
+ currencyCode: 'EUR',
128
+ centAmount: 91789,
129
+ fractionDigits: 2,
130
+ },
131
+ },
132
+ ],
133
+ assets: [],
134
+ images: [],
135
+ attributes: productDraft.variants![0].attributes,
136
+ },
137
+ ],
100
138
  name: productDraft.name,
101
139
  slug: productDraft.slug,
102
140
  categories: [],
@@ -210,7 +248,7 @@ describe('Product Projection Search - Filters', () => {
210
248
  const response = await supertest(ctMock.app)
211
249
  .get('/dummy/product-projections/search')
212
250
  .query({
213
- filter: ['variants.attributes.number:range(2 TO 10)'],
251
+ filter: ['variants.attributes.number:range(5 TO 10)'],
214
252
  })
215
253
 
216
254
  const result: ProductProjectionPagedSearchResponse = response.body
@@ -220,3 +258,132 @@ describe('Product Projection Search - Filters', () => {
220
258
  })
221
259
  })
222
260
  })
261
+
262
+ describe('Product Projection Search - Facets', () => {
263
+ test('termExpr - variants.attributes.number', async () => {
264
+ const response = await supertest(ctMock.app)
265
+ .get('/dummy/product-projections/search')
266
+ .query({
267
+ facet: ['variants.attributes.number'],
268
+ })
269
+
270
+ const result: ProductProjectionPagedSearchResponse = response.body
271
+ expect(result).toMatchObject({
272
+ count: 1,
273
+ facets: {
274
+ 'variants.attributes.number': {
275
+ type: 'terms',
276
+ dataType: 'text',
277
+ missing: 0,
278
+ total: 2,
279
+ terms: [
280
+ {
281
+ term: '4.0',
282
+ count: 1,
283
+ },
284
+ {
285
+ term: '50.0',
286
+ count: 1,
287
+ },
288
+ ],
289
+ },
290
+ },
291
+ results: [
292
+ {
293
+ masterVariant: { sku: 'my-sku' },
294
+ },
295
+ ],
296
+ })
297
+ })
298
+
299
+ test('filterExpr - variants.attributes.number', async () => {
300
+ const response = await supertest(ctMock.app)
301
+ .get('/dummy/product-projections/search')
302
+ .query({
303
+ facet: ['variants.attributes.number:3,4'],
304
+ })
305
+
306
+ const result: ProductProjectionPagedSearchResponse = response.body
307
+ expect(result).toMatchObject({
308
+ count: 1,
309
+ facets: {
310
+ 'variants.attributes.number': {
311
+ type: 'filter',
312
+ count: 1,
313
+ },
314
+ },
315
+ results: [
316
+ {
317
+ masterVariant: { sku: 'my-sku' },
318
+ },
319
+ ],
320
+ })
321
+ })
322
+
323
+ test('rangeExpr - variants.attributes.number', async () => {
324
+ const response = await supertest(ctMock.app)
325
+ .get('/dummy/product-projections/search')
326
+ .query({
327
+ facet: [
328
+ 'variants.attributes.number:range(* TO 5), (5 TO 25), (25 TO 100)',
329
+ ],
330
+ })
331
+
332
+ const result: ProductProjectionPagedSearchResponse = response.body
333
+ expect(result).toMatchObject({
334
+ count: 1,
335
+ facets: {
336
+ 'variants.attributes.number': {
337
+ type: 'range',
338
+ dataType: 'number',
339
+ ranges: [
340
+ {
341
+ type: 'double',
342
+ from: 0.0,
343
+ fromStr: '',
344
+ to: 5.0,
345
+ toStr: '5.0',
346
+ count: 1,
347
+ // totalCount: 1,
348
+ total: 4.0,
349
+ min: 4.0,
350
+ max: 4.0,
351
+ mean: 4.0,
352
+ },
353
+ {
354
+ type: 'double',
355
+ from: 5.0,
356
+ fromStr: '5.0',
357
+ to: 25.0,
358
+ toStr: '25.0',
359
+ count: 0,
360
+ // totalCount: 0,
361
+ total: 0.0,
362
+ min: 0.0,
363
+ max: 0.0,
364
+ mean: 0.0,
365
+ },
366
+ {
367
+ type: 'double',
368
+ from: 25.0,
369
+ fromStr: '25.0',
370
+ to: 100.0,
371
+ toStr: '100.0',
372
+ count: 1,
373
+ // totalCount: 1,
374
+ total: 50,
375
+ min: 50.0,
376
+ max: 50.0,
377
+ mean: 50.0,
378
+ },
379
+ ],
380
+ },
381
+ },
382
+ results: [
383
+ {
384
+ masterVariant: { sku: 'my-sku' },
385
+ },
386
+ ],
387
+ })
388
+ })
389
+ })