@labdigital/commercetools-mock 2.8.0 → 2.9.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.
@@ -1,24 +1,48 @@
1
1
  import type {
2
+ Cart,
3
+ CartDraft,
2
4
  ShippingMethodDraft,
3
5
  TaxCategoryDraft,
6
+ ZoneDraft,
4
7
  } from '@commercetools/platform-sdk'
5
8
  import supertest from 'supertest'
6
9
  import { afterEach, beforeEach, describe, expect, test } from 'vitest'
7
10
  import { CommercetoolsMock } from '../index.js'
11
+ import { isType } from '../types.js'
8
12
 
9
13
  const ctMock = new CommercetoolsMock()
10
14
 
11
15
  describe('Shipping method', () => {
12
16
  beforeEach(async () => {
13
- const draft: TaxCategoryDraft = {
14
- name: 'foo',
15
- key: 'standard',
16
- rates: [],
17
- }
18
- const createResponse = await supertest(ctMock.app)
17
+ await supertest(ctMock.app)
19
18
  .post('/dummy/tax-categories')
20
- .send(draft)
21
- expect(createResponse.status).toEqual(201)
19
+ .send(
20
+ isType<TaxCategoryDraft>({
21
+ name: 'foo',
22
+ key: 'standard',
23
+ rates: [],
24
+ })
25
+ )
26
+ .then((res) => {
27
+ expect(res.status).toEqual(201)
28
+ })
29
+
30
+ await supertest(ctMock.app)
31
+ .post('/dummy/zones')
32
+ .send(
33
+ isType<ZoneDraft>({
34
+ name: 'The Netherlands',
35
+ key: 'NL',
36
+ locations: [
37
+ {
38
+ country: 'NL',
39
+ },
40
+ ],
41
+ })
42
+ )
43
+ .then((res) => {
44
+ expect(res.status).toEqual(201)
45
+ })
22
46
  })
23
47
 
24
48
  afterEach(async () => {
@@ -75,28 +99,103 @@ describe('Shipping method', () => {
75
99
  })
76
100
 
77
101
  test('Get shipping methods matching cart', async () => {
78
- const draft: ShippingMethodDraft = {
79
- name: 'foo',
80
- taxCategory: { typeId: 'tax-category', key: 'standard' },
81
- isDefault: true,
82
- zoneRates: [],
83
- }
84
- const createResponse = await supertest(ctMock.app)
102
+ const cart = await supertest(ctMock.app)
103
+ .post('/dummy/carts')
104
+ .send(
105
+ isType<CartDraft>({
106
+ currency: 'EUR',
107
+ shippingAddress: {
108
+ country: 'NL',
109
+ },
110
+ })
111
+ )
112
+ .then((res) => res.body as Cart)
113
+
114
+ await supertest(ctMock.app)
85
115
  .post('/dummy/shipping-methods')
86
- .send(draft)
116
+ .send(
117
+ isType<ShippingMethodDraft>({
118
+ name: 'NL',
119
+ taxCategory: { typeId: 'tax-category', key: 'standard' },
120
+ isDefault: true,
121
+ zoneRates: [
122
+ {
123
+ zone: {
124
+ typeId: 'zone',
125
+ key: 'NL',
126
+ },
127
+ shippingRates: [
128
+ {
129
+ price: {
130
+ currencyCode: 'EUR',
131
+ centAmount: 495,
132
+ },
133
+ },
134
+ ],
135
+ },
136
+ ],
137
+ })
138
+ )
139
+ .then((res) => {
140
+ expect(res.status).toEqual(201)
141
+ })
87
142
 
88
- expect(createResponse.status).toBe(201)
143
+ await supertest(ctMock.app)
144
+ .post('/dummy/shipping-methods')
145
+ .send(
146
+ isType<ShippingMethodDraft>({
147
+ name: 'NL/GBP',
148
+ taxCategory: { typeId: 'tax-category', key: 'standard' },
149
+ isDefault: true,
150
+ zoneRates: [
151
+ {
152
+ zone: {
153
+ typeId: 'zone',
154
+ key: 'NL',
155
+ },
156
+ shippingRates: [
157
+ {
158
+ price: {
159
+ currencyCode: 'GBP',
160
+ centAmount: 495,
161
+ },
162
+ },
163
+ ],
164
+ },
165
+ ],
166
+ })
167
+ )
168
+ .then((res) => {
169
+ expect(res.status).toEqual(201)
170
+ })
89
171
 
90
172
  const response = await supertest(ctMock.app).get(
91
- `/dummy/shipping-methods/matching-cart?cartId=fake-cart-id`
173
+ `/dummy/shipping-methods/matching-cart?cartId=${cart.id}`
92
174
  )
93
175
 
94
- expect(response.status).toBe(200)
95
- expect(response.body).toEqual({
176
+ expect(response.status, JSON.stringify(response.body)).toBe(200)
177
+ expect(response.body).toMatchObject({
96
178
  count: 1,
97
179
  limit: 20,
98
180
  offset: 0,
99
- results: [createResponse.body],
181
+ results: [
182
+ {
183
+ name: 'NL',
184
+ zoneRates: [
185
+ {
186
+ shippingRates: [
187
+ {
188
+ isMatching: true,
189
+ price: {
190
+ currencyCode: 'EUR',
191
+ centAmount: 495,
192
+ },
193
+ },
194
+ ],
195
+ },
196
+ ],
197
+ },
198
+ ],
100
199
  total: 1,
101
200
  })
102
201
  })
@@ -1,6 +1,8 @@
1
- import { Router } from 'express'
1
+ import { Request, Response, Router } from 'express'
2
2
  import { ShippingMethodRepository } from '../repositories/shipping-method.js'
3
3
  import AbstractService from './abstract.js'
4
+ import { getRepositoryContext } from '../repositories/helpers.js'
5
+ import { queryParamsValue } from '../helpers.js'
4
6
 
5
7
  export class ShippingMethodService extends AbstractService {
6
8
  public repository: ShippingMethodRepository
@@ -16,6 +18,21 @@ export class ShippingMethodService extends AbstractService {
16
18
  }
17
19
 
18
20
  extraRoutes(parent: Router) {
19
- parent.get('/matching-cart', this.get.bind(this))
21
+ parent.get('/matching-cart', this.matchingCart.bind(this))
22
+ }
23
+
24
+ matchingCart(request: Request, response: Response) {
25
+ const cartId = queryParamsValue(request.query.cartId)
26
+ if (!cartId) {
27
+ return response.status(400).send()
28
+ }
29
+ const result = this.repository.matchingCart(
30
+ getRepositoryContext(request),
31
+ cartId,
32
+ {
33
+ expand: this._parseParam(request.query.expand),
34
+ }
35
+ )
36
+ return response.status(200).send(result)
20
37
  }
21
38
  }
@@ -0,0 +1,280 @@
1
+ import {
2
+ Cart,
3
+ ShippingRate,
4
+ ShippingRatePriceTier,
5
+ } from '@commercetools/platform-sdk'
6
+ import { describe, expect, it } from 'vitest'
7
+ import {
8
+ markMatchingShippingRate,
9
+ markMatchingShippingRatePriceTiers,
10
+ } from './shippingCalculator'
11
+
12
+ // describe('markMatchingShippingMethods', () => {
13
+ // const zones: Record<string, Zone> = {
14
+ // NL: {
15
+ // id: '45b39469-4f3d-4d03-9e24-ed6bd5c038d9',
16
+ // createdAt: '2021-08-05T09:00:00.000Z',
17
+ // lastModifiedAt: '2021-08-05T09:00:00.000Z',
18
+ // version: 1,
19
+ // name: 'NL',
20
+ // description: '',
21
+ // locations: [
22
+ // {
23
+ // country: 'NL',
24
+ // },
25
+ // ],
26
+ // },
27
+ // DE: {
28
+ // id: '45b39469-4f3d-4d03-9e24-ed6bd5c038d9',
29
+ // createdAt: '2021-08-05T09:00:00.000Z',
30
+ // lastModifiedAt: '2021-08-05T09:00:00.000Z',
31
+ // version: 1,
32
+ // name: 'DE',
33
+ // description: '',
34
+ // locations: [
35
+ // {
36
+ // country: 'DE',
37
+ // },
38
+ // ],
39
+ // },
40
+ // }
41
+
42
+ // const shippingMethods: Record<string, Partial<ShippingMethod>> = {
43
+ // default: {
44
+ // id: '1c39b73f-186c-4711-8fd9-de60ec561ac0',
45
+ // key: 'default',
46
+ // zoneRates: [
47
+ // {
48
+ // zone: {
49
+ // typeId: 'zone',
50
+ // id: '45b39469-4f3d-4d03-9e24-ed6bd5c038d9',
51
+ // obj: zones['NL'],
52
+ // },
53
+ // shippingRates: [
54
+ // {
55
+ // price: {
56
+ // type: 'centPrecision',
57
+ // currencyCode: 'EUR',
58
+ // centAmount: 495,
59
+ // fractionDigits: 2,
60
+ // },
61
+ // freeAbove: {
62
+ // type: 'centPrecision',
63
+ // currencyCode: 'USD',
64
+ // centAmount: 5000,
65
+ // fractionDigits: 2,
66
+ // },
67
+ // tiers: [],
68
+ // },
69
+ // ],
70
+ // },
71
+ // ],
72
+ // isDefault: true,
73
+ // },
74
+ // tiered: {
75
+ // id: '2c39b73f-186c-4711-8fd9-de60ec561ac0',
76
+ // key: 'tiered',
77
+ // description: 'More expensive optional one',
78
+ // zoneRates: [
79
+ // {
80
+ // zone: {
81
+ // typeId: 'zone',
82
+ // id: '45b39469-4f3d-4d03-9e24-ed6bd5c038d9',
83
+ // obj: zones['NL'],
84
+ // },
85
+ // shippingRates: [
86
+ // {
87
+ // price: {
88
+ // type: 'centPrecision',
89
+ // currencyCode: 'EUR',
90
+ // centAmount: 495,
91
+ // fractionDigits: 2,
92
+ // },
93
+ // freeAbove: {
94
+ // type: 'centPrecision',
95
+ // currencyCode: 'USD',
96
+ // centAmount: 5000,
97
+ // fractionDigits: 2,
98
+ // },
99
+ // tiers: [],
100
+ // },
101
+ // ],
102
+ // },
103
+ // ],
104
+ // isDefault: false,
105
+ // },
106
+ // german: {
107
+ // id: '8c39b73f-186c-4711-8fd9-de60ec561ac0',
108
+ // key: 'tiered',
109
+ // description: 'More expensive optional one',
110
+ // zoneRates: [
111
+ // {
112
+ // zone: {
113
+ // typeId: 'zone',
114
+ // id: '45b39469-4f3d-4d03-9e24-ed6bd5c038d9',
115
+ // obj: zones['DE'],
116
+ // },
117
+ // shippingRates: [
118
+ // {
119
+ // price: {
120
+ // type: 'centPrecision',
121
+ // currencyCode: 'EUR',
122
+ // centAmount: 495,
123
+ // fractionDigits: 2,
124
+ // },
125
+ // freeAbove: {
126
+ // type: 'centPrecision',
127
+ // currencyCode: 'USD',
128
+ // centAmount: 5000,
129
+ // fractionDigits: 2,
130
+ // },
131
+ // tiers: [],
132
+ // },
133
+ // ],
134
+ // },
135
+ // ],
136
+ // isDefault: false,
137
+ // },
138
+ // }
139
+
140
+ // it('should mark the default shipping method', () => {
141
+ // const cart: Partial<Cart> = {
142
+ // id: '1',
143
+ // version: 1,
144
+ // totalPrice: {
145
+ // currencyCode: 'EUR',
146
+ // centAmount: 1000,
147
+ // fractionDigits: 2,
148
+ // type: 'centPrecision',
149
+ // },
150
+ // shippingAddress: {
151
+ // country: 'NL',
152
+ // },
153
+ // lineItems: [],
154
+ // customLineItems: [],
155
+ // }
156
+
157
+ // const data = markMatchingShippingMethods(
158
+ // cart as Cart,
159
+ // Object.values(shippingMethods) as ShippingMethod[]
160
+ // )
161
+ // })
162
+ // })
163
+
164
+ describe('markMatchingShippingRate', () => {
165
+ const rate: ShippingRate = {
166
+ price: {
167
+ type: 'centPrecision',
168
+ currencyCode: 'EUR',
169
+ centAmount: 495,
170
+ fractionDigits: 2,
171
+ },
172
+ freeAbove: {
173
+ type: 'centPrecision',
174
+ currencyCode: 'USD',
175
+ centAmount: 5000,
176
+ fractionDigits: 2,
177
+ },
178
+ tiers: [],
179
+ }
180
+
181
+ it('should mark the shipping rate as matching', () => {
182
+ const cart: Partial<Cart> = {
183
+ totalPrice: {
184
+ currencyCode: 'EUR',
185
+ centAmount: 1000,
186
+ fractionDigits: 2,
187
+ type: 'centPrecision',
188
+ },
189
+ }
190
+
191
+ const result = markMatchingShippingRate(cart as Cart, rate)
192
+ expect(result).toMatchObject({
193
+ ...rate,
194
+ isMatching: true,
195
+ })
196
+ })
197
+
198
+ it('should mark the shipping rate as not matching', () => {
199
+ const cart: Partial<Cart> = {
200
+ totalPrice: {
201
+ currencyCode: 'USD',
202
+ centAmount: 1000,
203
+ fractionDigits: 2,
204
+ type: 'centPrecision',
205
+ },
206
+ }
207
+
208
+ const result = markMatchingShippingRate(cart as Cart, rate)
209
+ expect(result).toMatchObject({
210
+ ...rate,
211
+ isMatching: false,
212
+ })
213
+ })
214
+ })
215
+
216
+ describe('markMatchingShippingRatePriceTiers', () => {
217
+ it('should handle CartValue types', () => {
218
+ const tiers: ShippingRatePriceTier[] = [
219
+ // Above 100 euro shipping is 4 euro
220
+ {
221
+ type: 'CartValue',
222
+ minimumCentAmount: 10000,
223
+ price: {
224
+ type: 'centPrecision',
225
+ currencyCode: 'EUR',
226
+ centAmount: 400,
227
+ fractionDigits: 2,
228
+ },
229
+ },
230
+ // Above 200 euro shipping is 3 euro
231
+ {
232
+ type: 'CartValue',
233
+ minimumCentAmount: 20000,
234
+ price: {
235
+ type: 'centPrecision',
236
+ currencyCode: 'EUR',
237
+ centAmount: 300,
238
+ fractionDigits: 2,
239
+ },
240
+ },
241
+ // Above 50 euro shipping is 5 euro
242
+ {
243
+ type: 'CartValue',
244
+ minimumCentAmount: 500,
245
+ price: {
246
+ type: 'centPrecision',
247
+ currencyCode: 'EUR',
248
+ centAmount: 700,
249
+ fractionDigits: 2,
250
+ },
251
+ },
252
+ ]
253
+
254
+ // Create a cart with a total price of 90 euro
255
+ const cart: Partial<Cart> = {
256
+ totalPrice: {
257
+ currencyCode: 'EUR',
258
+ centAmount: 9000,
259
+ fractionDigits: 2,
260
+ type: 'centPrecision',
261
+ },
262
+ }
263
+
264
+ const result = markMatchingShippingRatePriceTiers(cart as Cart, tiers)
265
+ expect(result).toMatchObject([
266
+ {
267
+ minimumCentAmount: 10000,
268
+ isMatching: false,
269
+ },
270
+ {
271
+ minimumCentAmount: 20000,
272
+ isMatching: false,
273
+ },
274
+ {
275
+ minimumCentAmount: 500,
276
+ isMatching: true,
277
+ },
278
+ ])
279
+ })
280
+ })
@@ -0,0 +1,74 @@
1
+ import {
2
+ Cart,
3
+ CartValueTier,
4
+ ShippingRate,
5
+ ShippingRatePriceTier,
6
+ } from '@commercetools/platform-sdk'
7
+
8
+ export const markMatchingShippingRate = (
9
+ cart: Cart,
10
+ shippingRate: ShippingRate
11
+ ): ShippingRate => {
12
+ const isMatching =
13
+ shippingRate.price.currencyCode === cart.totalPrice.currencyCode
14
+ return {
15
+ ...shippingRate,
16
+ tiers: markMatchingShippingRatePriceTiers(cart, shippingRate.tiers),
17
+ isMatching: isMatching,
18
+ }
19
+ }
20
+
21
+ export const markMatchingShippingRatePriceTiers = (
22
+ cart: Cart,
23
+ tiers: ShippingRatePriceTier[]
24
+ ): ShippingRatePriceTier[] => {
25
+ if (tiers.length === 0) {
26
+ return []
27
+ }
28
+
29
+ if (new Set(tiers.map((tier) => tier.type)).size > 1) {
30
+ throw new Error("Can't handle multiple types of tiers")
31
+ }
32
+
33
+ const tierType = tiers[0].type
34
+ switch (tierType) {
35
+ case 'CartValue':
36
+ return markMatchingCartValueTiers(cart, tiers as CartValueTier[])
37
+ // case 'CartClassification':
38
+ // return markMatchingCartClassificationTiers(cart, tiers)
39
+ // case 'CartScore':
40
+ // return markMatchingCartScoreTiers(cart, tiers)
41
+ default:
42
+ throw new Error(`Unsupported tier type: ${tierType}`)
43
+ }
44
+ }
45
+
46
+ const markMatchingCartValueTiers = (
47
+ cart: Cart,
48
+ tiers: readonly CartValueTier[]
49
+ ): ShippingRatePriceTier[] => {
50
+ // Sort tiers from high to low since we only want to match the highest tier
51
+ const sortedTiers = [...tiers].sort(
52
+ (a, b) => b.minimumCentAmount - a.minimumCentAmount
53
+ )
54
+
55
+ // Find the first tier that matches the cart and set the flag. We push
56
+ // the results into a map so that we can output the tiers in the same order as
57
+ // we received them.
58
+ const result: Record<number, ShippingRatePriceTier> = {}
59
+ let hasMatchingTier = false
60
+ for (const tier of sortedTiers) {
61
+ const isMatching =
62
+ !hasMatchingTier &&
63
+ cart.totalPrice.currencyCode === tier.price.currencyCode &&
64
+ cart.totalPrice.centAmount >= tier.minimumCentAmount
65
+
66
+ if (isMatching) hasMatchingTier = true
67
+ result[tier.minimumCentAmount] = {
68
+ ...tier,
69
+ isMatching: isMatching,
70
+ }
71
+ }
72
+
73
+ return tiers.map((tier) => result[tier.minimumCentAmount])
74
+ }
@@ -68,7 +68,7 @@ export abstract class AbstractStorage {
68
68
  abstract getByResourceIdentifier<RT extends ResourceType>(
69
69
  projectKey: string,
70
70
  identifier: ResourceIdentifier
71
- ): ResourceMap[RT] | null
71
+ ): ResourceMap[RT]
72
72
 
73
73
  abstract expand<T>(
74
74
  projectKey: string,