@labdigital/commercetools-mock 1.10.0 → 2.0.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,7 +1,7 @@
1
1
  {
2
2
  "name": "@labdigital/commercetools-mock",
3
3
  "author": "Michael van Tellingen",
4
- "version": "1.10.0",
4
+ "version": "2.0.0",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.cjs",
7
7
  "module": "dist/index.js",
@@ -19,7 +19,7 @@
19
19
  }
20
20
  },
21
21
  "engines": {
22
- "node": ">=16",
22
+ "node": ">=18",
23
23
  "pnpm": ">=8.6.5"
24
24
  },
25
25
  "packageManager": "pnpm@8.6.5",
@@ -39,7 +39,7 @@
39
39
  "express": "^4.18.2",
40
40
  "lodash.isequal": "^4.5.0",
41
41
  "morgan": "^1.10.0",
42
- "nock": "^13.3.2",
42
+ "msw": "^2.0.0",
43
43
  "supertest": "^6.3.3",
44
44
  "uuid": "^9.0.0"
45
45
  },
package/src/constants.ts CHANGED
@@ -1,4 +1,2 @@
1
- export const DEFAULT_API_HOSTNAME =
2
- /^https:\/\/api\..*?\.commercetools.com:443$/
3
- export const DEFAULT_AUTH_HOSTNAME =
4
- /^https:\/\/auth\..*?\.commercetools.com:443$/
1
+ export const DEFAULT_API_HOSTNAME = 'https://api.*.commercetools.com'
2
+ export const DEFAULT_AUTH_HOSTNAME = 'https://auth.*.commercetools.com'
package/src/ctMock.ts CHANGED
@@ -1,7 +1,8 @@
1
- import nock from 'nock'
2
1
  import express, { NextFunction, Request, Response } from 'express'
3
2
  import supertest from 'supertest'
4
3
  import morgan from 'morgan'
4
+ import { setupServer, SetupServer } from 'msw/node'
5
+ import { http, HttpResponse } from 'msw'
5
6
  import { AbstractStorage, InMemoryStorage } from './storage/index.js'
6
7
  import { Services } from './types.js'
7
8
  import { CommercetoolsError } from './exceptions.js'
@@ -36,16 +37,15 @@ const DEFAULT_OPTIONS: CommercetoolsMockOptions = {
36
37
  silent: false,
37
38
  }
38
39
 
40
+ const _globalListeners: SetupServer[] = []
41
+
39
42
  export class CommercetoolsMock {
40
43
  public app: express.Express
41
44
  public options: CommercetoolsMockOptions
42
45
 
43
46
  private _storage: AbstractStorage
44
47
  private _oauth2: OAuth2Server
45
- private _nockScopes: {
46
- auth: nock.Scope | undefined
47
- api: nock.Scope | undefined
48
- } = { auth: undefined, api: undefined }
48
+ private _mswServer: SetupServer | undefined = undefined
49
49
  private _services: Services | null
50
50
  private _repositories: RepositoryMap | null
51
51
  private _projectService?: ProjectService
@@ -67,19 +67,17 @@ export class CommercetoolsMock {
67
67
 
68
68
  start() {
69
69
  // Order is important here when the hostnames match
70
- this.mockAuthHost()
71
- this.mockApiHost()
70
+ this.clear()
71
+ this.startServer()
72
72
  }
73
73
 
74
74
  stop() {
75
- this._nockScopes.auth?.persist(false)
76
- this._nockScopes.auth = undefined
77
-
78
- this._nockScopes.api?.persist(false)
79
- this._nockScopes.api = undefined
75
+ this._mswServer?.close()
76
+ this._mswServer = undefined
80
77
  }
81
78
 
82
79
  clear() {
80
+ this._mswServer?.resetHandlers()
83
81
  this._storage.clear()
84
82
  }
85
83
 
@@ -157,48 +155,81 @@ export class CommercetoolsMock {
157
155
  return app
158
156
  }
159
157
 
160
- private mockApiHost() {
161
- const app = this.app
158
+ private startServer() {
159
+ // Check if there are any other servers running
160
+ if (_globalListeners.length > 0) {
161
+ if (this._mswServer !== undefined) {
162
+ throw new Error('Server already started')
163
+ } else {
164
+ console.warn("Server wasn't stopped properly, clearing")
165
+ _globalListeners.forEach((listener) => listener.close())
166
+ }
167
+ }
162
168
 
163
- this._nockScopes.api = nock(this.options.apiHost)
164
- .persist()
165
- .get(/.*/)
166
- .reply(async function (uri) {
167
- const response = await supertest(app)
168
- .get(uri)
169
- .set(copyHeaders(this.req.headers))
170
- return [response.status, response.body]
171
- })
172
- .post(/.*/)
173
- .reply(async function (uri, body) {
174
- const response = await supertest(app)
175
- .post(uri)
176
- .set(copyHeaders(this.req.headers))
169
+ const app = this.app
170
+ this._mswServer = setupServer(
171
+ http.post(`${this.options.authHost}/oauth/*`, async ({ request }) => {
172
+ const text = await request.text()
173
+ const url = new URL(request.url)
174
+ const res = await supertest(app)
175
+ .post(url.pathname + '?' + url.searchParams.toString())
176
+ .set(copyHeaders(request.headers))
177
+ .send(text)
178
+
179
+ return new HttpResponse(res.text, {
180
+ status: res.status,
181
+ headers: res.headers,
182
+ })
183
+ }),
184
+ http.get(`${this.options.apiHost}/*`, async ({ request }) => {
185
+ const body = await request.text()
186
+ const url = new URL(request.url)
187
+ const res = await supertest(app)
188
+ .get(url.pathname + '?' + url.searchParams.toString())
189
+ .set(copyHeaders(request.headers))
177
190
  .send(body)
178
- return [response.status, response.body]
179
- })
180
- .delete(/.*/)
181
- .reply(async function (uri, body) {
182
- const response = await supertest(app)
183
- .delete(uri)
184
- .set(copyHeaders(this.req.headers))
191
+ return new HttpResponse(res.text, {
192
+ status: res.status,
193
+ headers: res.headers,
194
+ })
195
+ }),
196
+ http.post(`${this.options.apiHost}/*`, async ({ request }) => {
197
+ const body = await request.text()
198
+ const url = new URL(request.url)
199
+ const res = await supertest(app)
200
+ .post(url.pathname + '?' + url.searchParams.toString())
201
+ .set(copyHeaders(request.headers))
202
+ .send(body)
203
+ return new HttpResponse(res.text, {
204
+ status: res.status,
205
+ headers: res.headers,
206
+ })
207
+ }),
208
+ http.delete(`${this.options.apiHost}/*`, async ({ request }) => {
209
+ const body = await request.text()
210
+ const url = new URL(request.url)
211
+ const res = await supertest(app)
212
+ .delete(url.pathname + '?' + url.searchParams.toString())
213
+ .set(copyHeaders(request.headers))
185
214
  .send(body)
186
- return [response.status, response.body]
187
- })
188
- }
189
-
190
- private mockAuthHost() {
191
- const app = this.app
192
215
 
193
- this._nockScopes.auth = nock(this.options.authHost)
194
- .persist()
195
- .post(/^\/oauth\/.*/)
196
- .reply(async function (uri, body) {
197
- const response = await supertest(app)
198
- .post(uri + '?' + body)
199
- .set(copyHeaders(this.req.headers))
200
- .send()
201
- return [response.status, response.body]
216
+ return new HttpResponse(res.text, {
217
+ status: res.status,
218
+ headers: res.headers,
219
+ })
202
220
  })
221
+ )
222
+ this._mswServer.listen({
223
+ // We need to allow requests done by supertest
224
+ onUnhandledRequest: (request, print) => {
225
+ const url = new URL(request.url)
226
+ if (url.hostname === '127.0.0.1') {
227
+ return
228
+ }
229
+ print.error()
230
+ },
231
+ })
232
+
233
+ _globalListeners.push(this._mswServer)
203
234
  }
204
235
  }
package/src/index.test.ts CHANGED
@@ -1,27 +1,63 @@
1
1
  import { type InvalidTokenError } from '@commercetools/platform-sdk'
2
2
  import { CommercetoolsMock } from './index.js'
3
- import { afterEach, beforeEach, expect, test } from 'vitest'
4
- import nock from 'nock'
3
+ import { expect, test } from 'vitest'
5
4
  import got from 'got'
6
5
 
7
- beforeEach(() => {
8
- nock.disableNetConnect()
9
- nock.enableNetConnect('127.0.0.1') // supertest
10
- })
11
- afterEach(() => {
12
- nock.enableNetConnect()
13
- nock.cleanAll()
6
+ test('node:fetch client', async () => {
7
+ const ctMock = new CommercetoolsMock({
8
+ enableAuthentication: true,
9
+ validateCredentials: true,
10
+ apiHost: 'https://localhost',
11
+ authHost: 'https://localhost:8080',
12
+ })
13
+ ctMock.start()
14
+
15
+ const authHeader = 'Basic ' + Buffer.from('foo:bar').toString('base64')
16
+ let response = await fetch('https://localhost:8080/oauth/token', {
17
+ method: 'POST',
18
+ headers: {
19
+ 'Content-Type': 'application/x-www-form-urlencoded',
20
+ Authorization: authHeader,
21
+ },
22
+ body: new URLSearchParams({
23
+ grant_type: 'client_credentials',
24
+ scope: 'manage_project:commercetools-node-mock',
25
+ }),
26
+ })
27
+
28
+ const authBody = await response.json()
29
+ expect(response.status).toBe(200)
30
+
31
+ const token = authBody.access_token
32
+ response = await fetch('https://localhost/my-project/orders', {
33
+ headers: {
34
+ Authorization: `Bearer ${token}`,
35
+ },
36
+ })
37
+
38
+ const body = await response.json()
39
+ expect(response.status).toBe(200)
40
+ expect(body).toStrictEqual({
41
+ count: 0,
42
+ total: 0,
43
+ offset: 0,
44
+ limit: 20,
45
+ results: [],
46
+ })
47
+ ctMock.stop()
14
48
  })
15
49
 
16
- test('Default mock endpoints', async () => {
50
+ test('got client', async () => {
17
51
  const ctMock = new CommercetoolsMock({
18
52
  enableAuthentication: true,
19
53
  validateCredentials: true,
54
+ apiHost: 'https://localhost',
55
+ authHost: 'https://localhost:8080',
20
56
  })
21
57
  ctMock.start()
22
58
 
23
59
  let response = await got.post<{ access_token: string }>(
24
- 'https://auth.europe-west1.gcp.commercetools.com/oauth/token',
60
+ 'https://localhost:8080/oauth/token',
25
61
  {
26
62
  searchParams: {
27
63
  grant_type: 'client_credentials',
@@ -36,15 +72,12 @@ test('Default mock endpoints', async () => {
36
72
 
37
73
  const token = response.body.access_token
38
74
  expect(response.body.access_token).toBeDefined()
39
- response = await got.get(
40
- 'https://api.europe-west1.gcp.commercetools.com/my-project/orders',
41
- {
42
- headers: {
43
- Authorization: `Bearer ${token}`,
44
- },
45
- responseType: 'json',
46
- }
47
- )
75
+ response = await got.get('https://localhost/my-project/orders', {
76
+ headers: {
77
+ Authorization: `Bearer ${token}`,
78
+ },
79
+ responseType: 'json',
80
+ })
48
81
  expect(response.statusCode).toBe(200)
49
82
  expect(response.body).toStrictEqual({
50
83
  count: 0,
@@ -192,6 +225,7 @@ test('apiHost mock proxy: querystring', async () => {
192
225
  expand: 'custom.type',
193
226
  },
194
227
  })
228
+
195
229
  expect(response.statusCode).toBe(200)
196
230
  expect(response.body).toStrictEqual({
197
231
  count: 0,
package/src/lib/proxy.ts CHANGED
@@ -1,12 +1,12 @@
1
- export const copyHeaders = (headers: Record<string, string>) => {
2
- const validHeaders = ['accept', 'host', 'authorization']
1
+ export const copyHeaders = (headers: Headers) => {
2
+ const validHeaders = ['accept', 'host', 'authorization', 'content-type']
3
3
  const result: Record<string, string> = {}
4
4
 
5
- Object.entries(headers).forEach(([key, value]) => {
5
+ for (const [key, value] of headers.entries()) {
6
6
  if (validHeaders.includes(key.toLowerCase())) {
7
7
  result[key] = value
8
8
  }
9
- })
9
+ }
10
10
 
11
11
  return result
12
12
  }
@@ -4,6 +4,8 @@ import type {
4
4
  Cart,
5
5
  CartAddLineItemAction,
6
6
  CartChangeLineItemQuantityAction,
7
+ CartAddItemShippingAddressAction,
8
+ CartSetLineItemShippingDetailsAction,
7
9
  CartDraft,
8
10
  CartRemoveLineItemAction,
9
11
  CartSetBillingAddressAction,
@@ -18,6 +20,7 @@ import type {
18
20
  GeneralError,
19
21
  LineItem,
20
22
  LineItemDraft,
23
+ ItemShippingDetails,
21
24
  Price,
22
25
  Product,
23
26
  ProductPagedQueryResponse,
@@ -216,6 +219,20 @@ export class CartRepository extends AbstractResourceRepository<'cart'> {
216
219
  // Update cart total price
217
220
  resource.totalPrice.centAmount = calculateCartTotalPrice(resource)
218
221
  },
222
+ addItemShippingAddress: (
223
+ context: RepositoryContext,
224
+ resource: Writable<Cart>,
225
+ { action, address }: CartAddItemShippingAddressAction
226
+ ) => {
227
+ const newAddress = createAddress(
228
+ address,
229
+ context.projectKey,
230
+ this._storage
231
+ )
232
+ if (newAddress) {
233
+ resource.itemShippingAddresses.push(newAddress)
234
+ }
235
+ },
219
236
  changeLineItemQuantity: (
220
237
  context: RepositoryContext,
221
238
  resource: Writable<Cart>,
@@ -392,6 +409,37 @@ export class CartRepository extends AbstractResourceRepository<'cart'> {
392
409
  ) => {
393
410
  resource.locale = locale
394
411
  },
412
+ setLineItemShippingDetails: (
413
+ context: RepositoryContext,
414
+ resource: Writable<Cart>,
415
+ {
416
+ action,
417
+ shippingDetails,
418
+ lineItemId,
419
+ lineItemKey,
420
+ }: CartSetLineItemShippingDetailsAction
421
+ ) => {
422
+ const lineItem = resource.lineItems.find(
423
+ (x) =>
424
+ (lineItemId && x.id === lineItemId) ||
425
+ (lineItemKey && x.key === lineItemKey)
426
+ )
427
+
428
+ if (!lineItem) {
429
+ // Check if line item is found
430
+ throw new CommercetoolsError<GeneralError>({
431
+ code: 'General',
432
+ message: lineItemKey
433
+ ? `A line item with key '${lineItemKey}' not found.`
434
+ : `A line item with ID '${lineItemId}' not found.`,
435
+ })
436
+ }
437
+
438
+ lineItem.shippingDetails = {
439
+ ...shippingDetails,
440
+ valid: true,
441
+ } as ItemShippingDetails
442
+ },
395
443
  setShippingAddress: (
396
444
  context: RepositoryContext,
397
445
  resource: Writable<Cart>,
@@ -1,8 +1,8 @@
1
1
  import type {
2
2
  ProductSelection,
3
+ ProductSelectionChangeNameAction,
3
4
  ProductSelectionDraft,
4
- Review,
5
- ReviewUpdateAction,
5
+ ProductSelectionUpdateAction,
6
6
  } from '@commercetools/platform-sdk'
7
7
  import { getBaseResourceProperties } from '../helpers.js'
8
8
  import type { Writable } from '../types.js'
@@ -20,6 +20,7 @@ export class ProductSelectionRepository extends AbstractResourceRepository<'prod
20
20
  const resource: ProductSelection = {
21
21
  ...getBaseResourceProperties(),
22
22
  productCount: 0,
23
+ key: draft.key,
23
24
  name: draft.name,
24
25
  type: 'individual',
25
26
  mode: 'Individual',
@@ -30,12 +31,20 @@ export class ProductSelectionRepository extends AbstractResourceRepository<'prod
30
31
 
31
32
  actions: Partial<
32
33
  Record<
33
- ReviewUpdateAction['action'],
34
+ ProductSelectionUpdateAction['action'],
34
35
  (
35
36
  context: RepositoryContext,
36
- resource: Writable<Review>,
37
+ resource: Writable<ProductSelection>,
37
38
  action: any
38
39
  ) => void
39
40
  >
40
- > = {}
41
+ > = {
42
+ changeName: (
43
+ context: RepositoryContext,
44
+ resource: Writable<ProductSelection>,
45
+ { name }: ProductSelectionChangeNameAction
46
+ ) => {
47
+ resource.name = name
48
+ },
49
+ }
41
50
  }
@@ -272,6 +272,37 @@ describe('Cart Update Actions', () => {
272
272
  expect(response.body.message).toBe("A product with ID '123' not found.")
273
273
  })
274
274
 
275
+ test('addItemShippingAddress', async () => {
276
+ const product = await supertest(ctMock.app)
277
+ .post(`/dummy/products`)
278
+ .send(productDraft)
279
+ .then((x) => x.body)
280
+
281
+ assert(cart, 'cart not created')
282
+
283
+ const response = await supertest(ctMock.app)
284
+ .post(`/dummy/carts/${cart.id}`)
285
+ .send({
286
+ version: 1,
287
+ actions: [
288
+ {
289
+ action: 'addItemShippingAddress',
290
+ address: {
291
+ firstName: 'John',
292
+ lastName: 'Doe',
293
+ company: 'My Company',
294
+ country: 'NL',
295
+ },
296
+ },
297
+ ],
298
+ })
299
+
300
+ expect(response.body.itemShippingAddresses).toHaveLength(1)
301
+ expect(response.status).toBe(200)
302
+ expect(response.body.version).toBe(2)
303
+ expect(response.body.lineItems).toHaveLength(0)
304
+ })
305
+
275
306
  test('removeLineItem', async () => {
276
307
  const product = await supertest(ctMock.app)
277
308
  .post(`/dummy/products`)
@@ -418,4 +449,60 @@ describe('Cart Update Actions', () => {
418
449
  expect(response.body.version).toBe(2)
419
450
  expect(response.body.shippingAddress).toEqual(address)
420
451
  })
452
+
453
+ test('setLineItemShippingDetails', async () => {
454
+ const product = await supertest(ctMock.app)
455
+ .post(`/dummy/products`)
456
+ .send(productDraft)
457
+ .then((x) => x.body)
458
+
459
+ assert(cart, 'cart not created')
460
+ assert(product, 'product not created')
461
+
462
+ const updatedCart = await supertest(ctMock.app)
463
+ .post(`/dummy/carts/${cart.id}`)
464
+ .send({
465
+ version: 1,
466
+ actions: [
467
+ {
468
+ action: 'addLineItem',
469
+ productId: product.id,
470
+ variantId: product.masterData.current.variants[0].id,
471
+ },
472
+ ],
473
+ })
474
+ const lineItem = updatedCart.body.lineItems[0]
475
+ assert(lineItem, 'lineItem not created')
476
+
477
+ expect(updatedCart.body.version).toBe(2)
478
+ expect(updatedCart.body.lineItems).toHaveLength(1)
479
+
480
+ const response = await supertest(ctMock.app)
481
+ .post(`/dummy/carts/${cart.id}`)
482
+ .send({
483
+ version: updatedCart.body.version,
484
+ actions: [
485
+ {
486
+ action: 'setLineItemShippingDetails',
487
+ lineItemId: lineItem.id,
488
+ shippingDetails: {
489
+ targets: [
490
+ {
491
+ addressKey: 'address-key',
492
+ quantity: 1,
493
+ },
494
+ ],
495
+ },
496
+ },
497
+ ],
498
+ })
499
+
500
+ expect(response.status).toBe(200)
501
+ expect(response.body.version).toBe(3)
502
+ expect(response.body.lineItems).toHaveLength(1)
503
+
504
+ const updatedLineItem = response.body.lineItems[0]
505
+ expect(updatedLineItem.shippingDetails).toBeDefined()
506
+ expect(updatedLineItem.shippingDetails.targets).toHaveLength(1)
507
+ })
421
508
  })
@@ -1,12 +1,13 @@
1
1
  import { AssociateRoleServices } from './associate-roles.js'
2
+ import { AttributeGroupService } from './attribute-group.js'
2
3
  import { BusinessUnitServices } from './business-units.js'
3
- import { CartService } from './cart.js'
4
4
  import { CartDiscountService } from './cart-discount.js'
5
+ import { CartService } from './cart.js'
5
6
  import { CategoryServices } from './category.js'
6
7
  import { ChannelService } from './channel.js'
7
8
  import { CustomObjectService } from './custom-object.js'
8
- import { CustomerService } from './customer.js'
9
9
  import { CustomerGroupService } from './customer-group.js'
10
+ import { CustomerService } from './customer.js'
10
11
  import { DiscountCodeService } from './discount-code.js'
11
12
  import { ExtensionServices } from './extension.js'
12
13
  import { InventoryEntryService } from './inventory-entry.js'
@@ -16,10 +17,11 @@ import { MyOrderService } from './my-order.js'
16
17
  import { MyPaymentService } from './my-payment.js'
17
18
  import { OrderService } from './order.js'
18
19
  import { PaymentService } from './payment.js'
19
- import { ProductService } from './product.js'
20
20
  import { ProductDiscountService } from './product-discount.js'
21
21
  import { ProductProjectionService } from './product-projection.js'
22
+ import { ProductSelectionService } from './product-selection.js'
22
23
  import { ProductTypeService } from './product-type.js'
24
+ import { ProductService } from './product.js'
23
25
  import { ShippingMethodService } from './shipping-method.js'
24
26
  import { ShoppingListService } from './shopping-list.js'
25
27
  import { StandAlonePriceService } from './standalone-price.js'
@@ -29,7 +31,6 @@ import { SubscriptionService } from './subscription.js'
29
31
  import { TaxCategoryService } from './tax-category.js'
30
32
  import { TypeService } from './type.js'
31
33
  import { ZoneService } from './zone.js'
32
- import { AttributeGroupService } from './attribute-group.js'
33
34
 
34
35
  export const createServices = (router: any, repos: any) => ({
35
36
  'associate-role': new AssociateRoleServices(router, repos['associate-role']),
@@ -74,6 +75,10 @@ export const createServices = (router: any, repos: any) => ({
74
75
  router,
75
76
  repos['product-projection']
76
77
  ),
78
+ 'product-selection': new ProductSelectionService(
79
+ router,
80
+ repos['product-selection']
81
+ ),
77
82
  'shopping-list': new ShoppingListService(router, repos['shopping-list']),
78
83
  state: new StateService(router, repos['state']),
79
84
  store: new StoreService(router, repos['store']),
@@ -0,0 +1,36 @@
1
+ import type { ProductSelectionDraft } from '@commercetools/platform-sdk'
2
+ import supertest from 'supertest'
3
+ import { describe, expect, test } from 'vitest'
4
+ import { CommercetoolsMock } from '../index.js'
5
+
6
+ const ctMock = new CommercetoolsMock()
7
+
8
+ describe('product-selection', () => {
9
+ test('Create product selection', async () => {
10
+ const draft: ProductSelectionDraft = {
11
+ name: {
12
+ en: 'foo',
13
+ },
14
+ key: 'foo',
15
+ }
16
+ const response = await supertest(ctMock.app)
17
+ .post('/dummy/product-selections')
18
+ .send(draft)
19
+
20
+ expect(response.status).toBe(201)
21
+
22
+ expect(response.body).toEqual({
23
+ createdAt: expect.anything(),
24
+ id: expect.anything(),
25
+ lastModifiedAt: expect.anything(),
26
+ name: {
27
+ en: 'foo',
28
+ },
29
+ key: 'foo',
30
+ version: 1,
31
+ productCount: 0,
32
+ type: 'individual',
33
+ mode: 'Individual',
34
+ })
35
+ })
36
+ })
@@ -0,0 +1,16 @@
1
+ import { Router } from 'express'
2
+ import AbstractService from './abstract.js'
3
+ import { ProductSelectionRepository } from '../repositories/product-selection.js'
4
+
5
+ export class ProductSelectionService extends AbstractService {
6
+ public repository: ProductSelectionRepository
7
+
8
+ constructor(parent: Router, repository: ProductSelectionRepository) {
9
+ super(parent)
10
+ this.repository = repository
11
+ }
12
+
13
+ getBasePath() {
14
+ return 'product-selections'
15
+ }
16
+ }