@labdigital/commercetools-mock 2.52.0 → 2.53.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@labdigital/commercetools-mock",
3
- "version": "2.52.0",
3
+ "version": "2.53.1",
4
4
  "license": "MIT",
5
5
  "author": "Michael van Tellingen",
6
6
  "type": "module",
@@ -88,6 +88,15 @@ const resolveValue = (obj: any, val: TypeSymbol): any => {
88
88
  throw new PredicateError("Internal error");
89
89
  }
90
90
 
91
+ // variants() includes both masterVariant and variants for predicates
92
+ if (
93
+ val.value === "variants" &&
94
+ obj.masterVariant &&
95
+ obj.variants !== undefined
96
+ ) {
97
+ return [obj.masterVariant, ...(obj.variants ?? [])];
98
+ }
99
+
91
100
  if (!(val.value in obj)) {
92
101
  if (Array.isArray(obj)) {
93
102
  return Object.values(obj)
@@ -230,7 +239,7 @@ const generateMatchFunc = (predicate: string): MatchFunc => {
230
239
 
231
240
  .led("AND", 5, ({ left, bp }) => {
232
241
  const expr = parser.parse({ terminals: [bp - 1] });
233
- return (obj: any) => left(obj) && expr(obj);
242
+ return (obj: any, vars: object) => left(obj, vars) && expr(obj, vars);
234
243
  })
235
244
  .led("OR", 5, ({ left, token, bp }) => {
236
245
  const expr = parser.parse({ terminals: [bp - 1] });
@@ -346,4 +346,81 @@ describe("Product search filter", () => {
346
346
  }).isMatch,
347
347
  ).toBeTruthy();
348
348
  });
349
+
350
+ test("by availability.isOnStock", async () => {
351
+ const productWithAvailability: ProductProjection = {
352
+ ...exampleProduct,
353
+ masterVariant: {
354
+ ...exampleProduct.masterVariant,
355
+ availability: {
356
+ isOnStock: true,
357
+ availableQuantity: 10,
358
+ isOnStockForChannel: "test-channel",
359
+ } as any, // Cast to any since isOnStockForChannel is not in SDK type
360
+ },
361
+ };
362
+
363
+ // This should pass - current behavior
364
+ const result1 = match(
365
+ {
366
+ exact: {
367
+ field: "variants.availability.isOnStock",
368
+ value: true,
369
+ },
370
+ },
371
+ productWithAvailability,
372
+ );
373
+
374
+ expect(result1.isMatch).toBeTruthy();
375
+
376
+ const result2 = match(
377
+ {
378
+ exact: {
379
+ field: "variants.availability.isOnStock",
380
+ value: false,
381
+ },
382
+ },
383
+ productWithAvailability,
384
+ );
385
+
386
+ expect(result2.isMatch).toBeFalsy();
387
+ });
388
+
389
+ test("by availability.isOnStockForChannel", async () => {
390
+ const productWithAvailability: ProductProjection = {
391
+ ...exampleProduct,
392
+ masterVariant: {
393
+ ...exampleProduct.masterVariant,
394
+ availability: {
395
+ isOnStock: true,
396
+ availableQuantity: 10,
397
+ isOnStockForChannel: "test-channel",
398
+ } as any, // Cast to any since isOnStockForChannel is not in SDK type
399
+ },
400
+ };
401
+
402
+ expect(
403
+ match(
404
+ {
405
+ exact: {
406
+ field: "variants.availability.isOnStockForChannel",
407
+ value: "test-channel",
408
+ },
409
+ },
410
+ productWithAvailability,
411
+ ).isMatch,
412
+ ).toBeTruthy();
413
+
414
+ expect(
415
+ match(
416
+ {
417
+ exact: {
418
+ field: "variants.availability.isOnStockForChannel",
419
+ value: "other-channel",
420
+ },
421
+ },
422
+ productWithAvailability,
423
+ ).isMatch,
424
+ ).toBeFalsy();
425
+ });
349
426
  });
@@ -0,0 +1,242 @@
1
+ import type {
2
+ InventoryEntryDraft,
3
+ ProductDraft,
4
+ ProductPagedSearchResponse,
5
+ ProductSearchRequest,
6
+ } from "@commercetools/platform-sdk";
7
+ import supertest from "supertest";
8
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
9
+ import { CommercetoolsMock } from "./index";
10
+
11
+ describe("Product Search - Availability Filtering", () => {
12
+ const ctMock = new CommercetoolsMock();
13
+ let productId: string;
14
+
15
+ beforeEach(async () => {
16
+ // Create a product type first
17
+ const productTypeDraft = {
18
+ name: "Test Product Type",
19
+ key: "test-type",
20
+ description: "Test Product Type",
21
+ };
22
+
23
+ await supertest(ctMock.app)
24
+ .post("/dummy/product-types")
25
+ .send(productTypeDraft);
26
+
27
+ // Create a test product
28
+ const productDraft: ProductDraft = {
29
+ name: { "en-US": "Test Product" },
30
+ productType: {
31
+ typeId: "product-type",
32
+ key: "test-type",
33
+ },
34
+ slug: { "en-US": "test-product" },
35
+ masterVariant: {
36
+ sku: "TEST-SKU-001",
37
+ },
38
+ variants: [
39
+ {
40
+ sku: "TEST-SKU-002",
41
+ },
42
+ ],
43
+ };
44
+
45
+ const productResponse = await supertest(ctMock.app)
46
+ .post("/dummy/products")
47
+ .send(productDraft);
48
+
49
+ productId = productResponse.body.id;
50
+
51
+ // Publish the product
52
+ await supertest(ctMock.app)
53
+ .post(`/dummy/products/${productId}`)
54
+ .send({
55
+ version: productResponse.body.version,
56
+ actions: [{ action: "publish" }],
57
+ });
58
+ });
59
+
60
+ afterEach(() => {
61
+ ctMock.clear();
62
+ });
63
+
64
+ async function createInventoryEntry(
65
+ sku: string,
66
+ quantityOnStock: number,
67
+ channelId?: string,
68
+ ) {
69
+ const inventoryEntry: InventoryEntryDraft = {
70
+ sku,
71
+ quantityOnStock,
72
+ ...(channelId && {
73
+ supplyChannel: {
74
+ typeId: "channel",
75
+ id: channelId,
76
+ },
77
+ }),
78
+ };
79
+
80
+ await supertest(ctMock.app).post("/dummy/inventory").send(inventoryEntry);
81
+ }
82
+
83
+ async function searchProducts(
84
+ query?: any,
85
+ ): Promise<ProductPagedSearchResponse> {
86
+ const searchRequest: ProductSearchRequest = {
87
+ ...(query && { query }),
88
+ productProjectionParameters: {
89
+ staged: false,
90
+ },
91
+ };
92
+
93
+ const response = await supertest(ctMock.app)
94
+ .post("/dummy/products/search")
95
+ .send(searchRequest);
96
+
97
+ return response.body;
98
+ }
99
+
100
+ test("should filter products by variants.availability.isOnStock = true", async () => {
101
+ // Create inventory with stock for one variant
102
+ await createInventoryEntry("TEST-SKU-001", 10);
103
+
104
+ const result = await searchProducts({
105
+ exact: {
106
+ field: "variants.availability.isOnStock",
107
+ value: true,
108
+ },
109
+ });
110
+
111
+ expect(result.results).toHaveLength(1);
112
+ expect(
113
+ result.results[0].productProjection?.masterVariant?.availability
114
+ ?.isOnStock,
115
+ ).toBe(true);
116
+ });
117
+
118
+ test("should filter products by variants.availability.isOnStock = false", async () => {
119
+ // Create inventory with zero stock
120
+ await createInventoryEntry("TEST-SKU-001", 0);
121
+
122
+ const result = await searchProducts({
123
+ exact: {
124
+ field: "variants.availability.isOnStock",
125
+ value: false,
126
+ },
127
+ });
128
+
129
+ // Should find the product because it's not on stock
130
+ expect(result.results).toHaveLength(1);
131
+ expect(
132
+ result.results[0].productProjection?.masterVariant?.availability
133
+ ?.isOnStock,
134
+ ).toBe(false);
135
+ });
136
+
137
+ test("should filter products by variants.availability.isOnStockForChannel", async () => {
138
+ const channelId = "test-channel-1";
139
+
140
+ // Create inventory for specific channel
141
+ await createInventoryEntry("TEST-SKU-001", 5, channelId);
142
+
143
+ const result = await searchProducts({
144
+ exact: {
145
+ field: "variants.availability.isOnStockForChannel",
146
+ value: channelId,
147
+ },
148
+ });
149
+
150
+ expect(result.results).toHaveLength(1);
151
+ expect(
152
+ (result.results[0].productProjection?.masterVariant?.availability as any)
153
+ ?.isOnStockForChannel,
154
+ ).toBe(channelId);
155
+ });
156
+
157
+ test("should not find products when filtering by non-matching channel", async () => {
158
+ const channelId = "test-channel-1";
159
+ const otherChannelId = "test-channel-2";
160
+
161
+ // Create inventory for specific channel
162
+ await createInventoryEntry("TEST-SKU-001", 5, channelId);
163
+
164
+ const result = await searchProducts({
165
+ exact: {
166
+ field: "variants.availability.isOnStockForChannel",
167
+ value: otherChannelId,
168
+ },
169
+ });
170
+
171
+ expect(result.results).toHaveLength(0);
172
+ });
173
+
174
+ test("should handle products without inventory entries", async () => {
175
+ // Don't create any inventory entries
176
+
177
+ const result = await searchProducts({
178
+ exact: {
179
+ field: "variants.availability.isOnStock",
180
+ value: false,
181
+ },
182
+ });
183
+
184
+ // Should find the product because it has no stock
185
+ expect(result.results).toHaveLength(1);
186
+ expect(
187
+ result.results[0].productProjection?.masterVariant?.availability
188
+ ?.isOnStock,
189
+ ).toBe(false);
190
+ });
191
+
192
+ test("should work with OR queries for availability", async () => {
193
+ // Create inventory with stock
194
+ await createInventoryEntry("TEST-SKU-001", 10);
195
+
196
+ const result = await searchProducts({
197
+ or: [
198
+ {
199
+ exact: {
200
+ field: "variants.availability.isOnStock",
201
+ value: true,
202
+ },
203
+ },
204
+ {
205
+ exact: {
206
+ field: "variants.availability.isOnStock",
207
+ value: false,
208
+ },
209
+ },
210
+ ],
211
+ });
212
+
213
+ // Should find the product regardless of stock status
214
+ expect(result.results).toHaveLength(1);
215
+ });
216
+
217
+ test("should work with AND queries combining availability and other fields", async () => {
218
+ await createInventoryEntry("TEST-SKU-001", 10);
219
+
220
+ const result = await searchProducts({
221
+ and: [
222
+ {
223
+ exact: {
224
+ field: "variants.availability.isOnStock",
225
+ value: true,
226
+ },
227
+ },
228
+ {
229
+ exact: {
230
+ field: "variants.sku",
231
+ value: "TEST-SKU-001",
232
+ },
233
+ },
234
+ ],
235
+ });
236
+
237
+ expect(result.results).toHaveLength(1);
238
+ expect(result.results[0].productProjection?.masterVariant?.sku).toBe(
239
+ "TEST-SKU-001",
240
+ );
241
+ });
242
+ });
@@ -148,6 +148,23 @@ export class ProductSearch {
148
148
  ? product.masterData.current
149
149
  : product.masterData.staged;
150
150
 
151
+ const getVariantAvailability = (sku?: string) => {
152
+ if (!sku) {
153
+ return {
154
+ isOnStock: false,
155
+ availableQuantity: 0,
156
+ isOnStockForChannel: undefined,
157
+ };
158
+ }
159
+ return (
160
+ availabilityBySku.get(sku) || {
161
+ isOnStock: false,
162
+ availableQuantity: 0,
163
+ isOnStockForChannel: undefined,
164
+ }
165
+ );
166
+ };
167
+
151
168
  return {
152
169
  id: product.id,
153
170
  createdAt: product.createdAt,
@@ -159,12 +176,13 @@ export class ProductSearch {
159
176
  metaDescription: obj.metaDescription,
160
177
  slug: obj.slug,
161
178
  categories: obj.categories,
162
- masterVariant: obj.masterVariant,
179
+ masterVariant: {
180
+ ...obj.masterVariant,
181
+ availability: getVariantAvailability(obj.masterVariant.sku),
182
+ },
163
183
  variants: obj.variants.map((variant) => ({
164
184
  ...variant,
165
- availability: variant.sku
166
- ? availabilityBySku.get(variant.sku)
167
- : { isOnStock: false, availableQuantity: 0, isOnStockForChannel: [] },
185
+ availability: getVariantAvailability(variant.sku),
168
186
  })),
169
187
  productType: product.productType,
170
188
  hasStagedChanges: product.masterData.hasStagedChanges,
@@ -1,7 +1,10 @@
1
1
  import type {
2
+ BusinessUnitAddShippingAddressIdAction,
2
3
  BusinessUnitChangeNameAction,
3
4
  BusinessUnitChangeStatusAction,
5
+ BusinessUnitRemoveAddressAction,
4
6
  BusinessUnitSetContactEmailAction,
7
+ BusinessUnitSetDefaultShippingAddressAction,
5
8
  CompanyDraft,
6
9
  DivisionDraft,
7
10
  } from "@commercetools/platform-sdk";
@@ -250,6 +253,146 @@ describe("BusinessUnit Repository", () => {
250
253
  expect(result.version).toBe(businessUnit.version + 1);
251
254
  });
252
255
 
256
+ test("update business unit - setDefaultShippingAddress", () => {
257
+ const draft: CompanyDraft = {
258
+ key: "default-shipping-company",
259
+ unitType: "Company",
260
+ name: "Default Shipping Company",
261
+ addresses: [
262
+ {
263
+ country: "US",
264
+ city: "New York",
265
+ streetName: "5th Avenue",
266
+ streetNumber: "123",
267
+ postalCode: "10001",
268
+ },
269
+ {
270
+ country: "US",
271
+ city: "Boston",
272
+ streetName: "Boylston",
273
+ streetNumber: "456",
274
+ postalCode: "02116",
275
+ },
276
+ ],
277
+ shippingAddresses: [0, 1],
278
+ };
279
+
280
+ const ctx = { projectKey: "dummy" };
281
+ const businessUnit = repository.create(ctx, draft);
282
+ const addressId = businessUnit.addresses[1].id;
283
+
284
+ const result = repository.processUpdateActions(
285
+ ctx,
286
+ businessUnit,
287
+ businessUnit.version,
288
+ [
289
+ {
290
+ action: "setDefaultShippingAddress",
291
+ addressId,
292
+ } as BusinessUnitSetDefaultShippingAddressAction,
293
+ ],
294
+ );
295
+
296
+ expect(result.defaultShippingAddressId).toBe(addressId);
297
+ expect(result.version).toBe(businessUnit.version + 1);
298
+ });
299
+
300
+ test("update business unit - addShippingAddressId", () => {
301
+ const draft: CompanyDraft = {
302
+ key: "add-shipping-address-company",
303
+ unitType: "Company",
304
+ name: "Add Shipping Address Company",
305
+ addresses: [
306
+ {
307
+ country: "US",
308
+ city: "New York",
309
+ streetName: "5th Avenue",
310
+ streetNumber: "123",
311
+ postalCode: "10001",
312
+ },
313
+ {
314
+ country: "US",
315
+ city: "Boston",
316
+ streetName: "Boylston",
317
+ streetNumber: "456",
318
+ postalCode: "02116",
319
+ },
320
+ ],
321
+ };
322
+
323
+ const ctx = { projectKey: "dummy" };
324
+ const businessUnit = repository.create(ctx, draft);
325
+ const addressId = businessUnit.addresses[1].id;
326
+
327
+ const result = repository.processUpdateActions(
328
+ ctx,
329
+ businessUnit,
330
+ businessUnit.version,
331
+ [
332
+ {
333
+ action: "addShippingAddressId",
334
+ addressId,
335
+ } as BusinessUnitAddShippingAddressIdAction,
336
+ ],
337
+ );
338
+
339
+ expect(result.shippingAddressIds).toContain(addressId);
340
+ expect(result.version).toBe(businessUnit.version + 1);
341
+ });
342
+
343
+ test("update business unit - removeAddress", () => {
344
+ const draft: CompanyDraft = {
345
+ key: "remove-address-company",
346
+ unitType: "Company",
347
+ name: "Remove Address Company",
348
+ addresses: [
349
+ {
350
+ country: "US",
351
+ city: "New York",
352
+ streetName: "5th Avenue",
353
+ streetNumber: "123",
354
+ postalCode: "10001",
355
+ },
356
+ {
357
+ country: "US",
358
+ city: "Boston",
359
+ streetName: "Boylston",
360
+ streetNumber: "456",
361
+ postalCode: "02116",
362
+ },
363
+ ],
364
+ billingAddresses: [0, 1],
365
+ shippingAddresses: [0, 1],
366
+ defaultBillingAddress: 0,
367
+ defaultShippingAddress: 1,
368
+ };
369
+
370
+ const ctx = { projectKey: "dummy" };
371
+ const businessUnit = repository.create(ctx, draft);
372
+ const addressIdToRemove = businessUnit.addresses[0].id;
373
+ const remainingAddressId = businessUnit.addresses[1].id;
374
+
375
+ const result = repository.processUpdateActions(
376
+ ctx,
377
+ businessUnit,
378
+ businessUnit.version,
379
+ [
380
+ {
381
+ action: "removeAddress",
382
+ addressId: addressIdToRemove,
383
+ } as BusinessUnitRemoveAddressAction,
384
+ ],
385
+ );
386
+
387
+ expect(result.addresses).toHaveLength(1);
388
+ expect(result.addresses[0].id).toBe(remainingAddressId);
389
+ expect(result.billingAddressIds).toEqual([remainingAddressId]);
390
+ expect(result.shippingAddressIds).toEqual([remainingAddressId]);
391
+ expect(result.defaultBillingAddressId).toBeUndefined();
392
+ expect(result.defaultShippingAddressId).toBe(remainingAddressId);
393
+ expect(result.version).toBe(businessUnit.version + 1);
394
+ });
395
+
253
396
  test("get and delete business unit", () => {
254
397
  const draft: CompanyDraft = {
255
398
  key: "delete-test",
@@ -1,8 +1,10 @@
1
1
  import type {
2
+ BusinessUnitAddShippingAddressIdAction,
2
3
  BusinessUnitChangeApprovalRuleModeAction,
3
4
  BusinessUnitChangeAssociateModeAction,
4
5
  BusinessUnitChangeStatusAction,
5
6
  BusinessUnitSetCustomTypeAction,
7
+ BusinessUnitSetDefaultShippingAddressAction,
6
8
  BusinessUnitUpdateAction,
7
9
  CompanyDraft,
8
10
  DivisionDraft,
@@ -17,6 +19,7 @@ import type {
17
19
  BusinessUnitChangeNameAction,
18
20
  BusinessUnitChangeParentUnitAction,
19
21
  BusinessUnitDraft,
22
+ BusinessUnitRemoveAddressAction,
20
23
  BusinessUnitSetAssociatesAction,
21
24
  BusinessUnitSetContactEmailAction,
22
25
  BusinessUnitSetStoreModeAction,
@@ -289,4 +292,53 @@ class BusinessUnitUpdateHandler
289
292
  ) {
290
293
  resource.storeMode = storeMode;
291
294
  }
295
+
296
+ setDefaultShippingAddress(
297
+ context: RepositoryContext,
298
+ resource: Writable<BusinessUnit>,
299
+ { addressId }: BusinessUnitSetDefaultShippingAddressAction,
300
+ ) {
301
+ resource.defaultShippingAddressId = addressId;
302
+ }
303
+
304
+ addShippingAddressId(
305
+ context: RepositoryContext,
306
+ resource: Writable<BusinessUnit>,
307
+ { addressId }: BusinessUnitAddShippingAddressIdAction,
308
+ ) {
309
+ if (!resource.shippingAddressIds) {
310
+ resource.shippingAddressIds = [];
311
+ }
312
+ if (addressId) {
313
+ resource.shippingAddressIds.push(addressId);
314
+ }
315
+ }
316
+
317
+ removeAddress(
318
+ context: RepositoryContext,
319
+ resource: Writable<BusinessUnit>,
320
+ { addressId }: BusinessUnitRemoveAddressAction,
321
+ ) {
322
+ resource.addresses = resource.addresses.filter(
323
+ (addr) => addr.id !== addressId,
324
+ );
325
+
326
+ if (resource.shippingAddressIds) {
327
+ resource.shippingAddressIds = resource.shippingAddressIds.filter(
328
+ (id) => id !== addressId,
329
+ );
330
+ }
331
+ if (resource.billingAddressIds) {
332
+ resource.billingAddressIds = resource.billingAddressIds.filter(
333
+ (id) => id !== addressId,
334
+ );
335
+ }
336
+
337
+ if (resource.defaultShippingAddressId === addressId) {
338
+ resource.defaultShippingAddressId = undefined;
339
+ }
340
+ if (resource.defaultBillingAddressId === addressId) {
341
+ resource.defaultBillingAddressId = undefined;
342
+ }
343
+ }
292
344
  }
@@ -97,6 +97,10 @@ beforeEach(async () => {
97
97
  name: "number",
98
98
  value: 4 as any,
99
99
  },
100
+ {
101
+ name: "store",
102
+ value: ["test-store"],
103
+ },
100
104
  ],
101
105
  },
102
106
  variants: [
@@ -252,6 +256,30 @@ describe("Product Projection Query - Generic", () => {
252
256
  }
253
257
  });
254
258
 
259
+ test("Filter on complex query", async () => {
260
+ {
261
+ const response = await supertest(ctMock.app)
262
+ .get("/dummy/product-projections")
263
+ .query({
264
+ limit: 50,
265
+ where: [
266
+ 'slug(nl-NL=:slug) and variants(attributes(name="store" and value="test-store"))',
267
+ ],
268
+ "var.slug": "test-product",
269
+ "var.store": "test-store",
270
+ });
271
+
272
+ const result: ProductProjectionPagedSearchResponse = response.body;
273
+ expect(result).toEqual({
274
+ count: 1,
275
+ limit: 50,
276
+ offset: 0,
277
+ total: 1,
278
+ results: [productProjection],
279
+ });
280
+ }
281
+ });
282
+
255
283
  test("Filter on invalid slug", async () => {
256
284
  {
257
285
  const response = await supertest(ctMock.app)