@thorprovider/adapters 5.0.11 → 5.0.12

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.
@@ -31,6 +31,8 @@ import type {
31
31
  Customer,
32
32
  GetCustomersOptions,
33
33
  GetOrdersOptions,
34
+ GetCustomerOrdersPageOptions,
35
+ CustomerOrdersPage,
34
36
  AdminProduct,
35
37
  Region,
36
38
  Address,
@@ -62,13 +64,42 @@ import { MedusaAuthProvider } from './auth';
62
64
  import { getPaymentMethods } from './payment';
63
65
  import {
64
66
  buildChannelScopeMetadata,
65
- filterByChannel,
66
67
  validateChannelScopeAssignment,
67
68
  } from '../../metadata';
68
69
 
69
70
  const MEDUSA_CART_RETRIEVE_FIELDS =
70
71
  '+items,+items.variant,+items.variant.product,+items.variant.product.thumbnail,+items.variant.options,+items.variant.options.option,+items.total,+items.unit_price,+items.item_subtotal,+items.item_total,+shipping_methods,+shipping_methods.id,+shipping_methods.shipping_option_id';
71
72
 
73
+ /** Default window size for the channel-scoped order-history route. */
74
+ const CUSTOMER_ORDERS_DEFAULT_PAGE_SIZE = 10;
75
+
76
+ /**
77
+ * Upper bound on a single order-history window. The route is never asked for an
78
+ * unbounded/all-orders result (design D14); a caller requesting more is clamped.
79
+ */
80
+ const CUSTOMER_ORDERS_MAX_PAGE_SIZE = 100;
81
+
82
+ /**
83
+ * Normalize a 1-based page + pageSize into the backend's `limit`/`offset`.
84
+ * Non-integer or non-positive values fall back to the canonical defaults and the
85
+ * page size is clamped to {@link CUSTOMER_ORDERS_MAX_PAGE_SIZE}.
86
+ */
87
+ function normalizeCustomerOrdersPage(options: GetCustomerOrdersPageOptions): {
88
+ page: number;
89
+ pageSize: number;
90
+ limit: number;
91
+ offset: number;
92
+ } {
93
+ const page =
94
+ Number.isInteger(options.page) && (options.page as number) > 0 ? (options.page as number) : 1;
95
+ const requested =
96
+ Number.isInteger(options.pageSize) && (options.pageSize as number) > 0
97
+ ? (options.pageSize as number)
98
+ : CUSTOMER_ORDERS_DEFAULT_PAGE_SIZE;
99
+ const pageSize = Math.min(requested, CUSTOMER_ORDERS_MAX_PAGE_SIZE);
100
+ return { page, pageSize, limit: pageSize, offset: (page - 1) * pageSize };
101
+ }
102
+
72
103
  /**
73
104
  * Fields requested when completing a cart into an order.
74
105
  *
@@ -777,7 +808,7 @@ export class MedusaProvider implements CommerceProvider, CartCompletionCapable {
777
808
  /** Fields needed to price a product variant for the current region. */
778
809
  private buildProductFields(regionId?: string): string {
779
810
  // calculated_price requires region_id in Medusa v2 — only request it when available
780
- const baseFields = '+variants,+variants.inventory_quantity,+options,+images,+tags';
811
+ const baseFields = '+variants,+variants.inventory_quantity,+options,+images,+tags,+categories.*';
781
812
  const pricingFields = ',+variants.calculated_price,+variants.prices';
782
813
  return regionId ? baseFields + pricingFields : baseFields;
783
814
  }
@@ -2862,13 +2893,17 @@ export class MedusaProvider implements CommerceProvider, CartCompletionCapable {
2862
2893
  // ============================================
2863
2894
 
2864
2895
  /**
2865
- * Get all orders for a customer (storefront).
2896
+ * Get all orders for a customer (storefront) as an array (legacy surface).
2866
2897
  *
2867
- * Note: This retrieves orders from the storefront API (store.order.list),
2868
- * which returns orders only for the authenticated customer in the JWT.
2898
+ * Delegates to the channel-scoped paginated contract
2899
+ * ({@link customerOrdersPage}) and returns that window's orders. The backend
2900
+ * (`GET /store/thor/orders`) scopes by BOTH the authenticated customer and
2901
+ * the sales channel resolved from the publishable key, so cross-channel
2902
+ * orders are excluded by the backend before pagination and **no client-side
2903
+ * channel filter runs**.
2869
2904
  *
2870
- * @param options - Order query options (supports callbacks for custom backends)
2871
- * @returns Array of orders for the authenticated customer
2905
+ * @param options - Order query options (supports a custom fetcher)
2906
+ * @returns Array of orders for the authenticated customer on the store channel
2872
2907
  *
2873
2908
  * @example
2874
2909
  * ```typescript
@@ -2885,40 +2920,87 @@ export class MedusaProvider implements CommerceProvider, CartCompletionCapable {
2885
2920
  return customFetcher(options);
2886
2921
  }
2887
2922
 
2888
- this.logger.info('Getting orders for authenticated customer');
2923
+ const page = await this.customerOrdersPage({
2924
+ page: 1,
2925
+ pageSize: options.limit ?? CUSTOMER_ORDERS_MAX_PAGE_SIZE,
2926
+ salesChannelId,
2927
+ });
2928
+ return page.orders;
2929
+ }
2930
+
2931
+ /**
2932
+ * Channel-scoped, paginated order history for the authenticated customer.
2933
+ *
2934
+ * Calls the backend route `GET /store/thor/orders`, which requires the
2935
+ * customer session and scopes `order` by `customer_id` **and** the
2936
+ * `sales_channel_id` resolved from the publishable key, returning
2937
+ * `{ orders, count, limit, offset }` with the backend's real total computed
2938
+ * before pagination.
2939
+ *
2940
+ * The `salesChannelId` option is the store's explicitly configured channel.
2941
+ * When supplied, every returned order is asserted to belong to it; a
2942
+ * foreign-channel order (for example from a multi-channel publishable key that
2943
+ * silently resolved an arbitrary first id) fails closed with a typed error
2944
+ * rather than being presented as the customer's history.
2945
+ *
2946
+ * @param options - Bounded pagination window and the store's sales channel
2947
+ * @returns The backend's `{ orders, count, limit, offset }`
2948
+ */
2949
+ async customerOrdersPage(
2950
+ options: GetCustomerOrdersPageOptions = {},
2951
+ ): Promise<CustomerOrdersPage> {
2952
+ const { page, pageSize, limit, offset } = normalizeCustomerOrdersPage(options);
2953
+ const { salesChannelId } = options;
2954
+
2955
+ this.logger.info('Getting channel-scoped customer orders', { page, pageSize });
2889
2956
 
2890
2957
  try {
2891
- const result = await this.withRetry(
2958
+ const response = await this.withRetry(
2892
2959
  async () =>
2893
- this.sdk.store.order.list({
2894
- // Medusa v2 JS-SDK automatically includes authorization header if JWT is present
2895
- fields: '+items,+items.variant,+items.variant.product,+shipping_address,+billing_address,+customer',
2960
+ this.sdk.client.fetch<{
2961
+ orders?: any[];
2962
+ count?: number;
2963
+ limit?: number;
2964
+ offset?: number;
2965
+ }>('/store/thor/orders', {
2966
+ method: 'GET',
2967
+ query: { limit, offset },
2896
2968
  }),
2897
- 'getOrders',
2969
+ 'customerOrdersPage',
2898
2970
  {},
2899
2971
  );
2900
2972
 
2901
- const orders = result.orders ?? [];
2902
- this.logger.debug(`Found ${orders.length} orders for customer`);
2973
+ const rawOrders = response.orders ?? [];
2974
+ this.logger.debug(`Found ${rawOrders.length} channel-scoped orders for customer`);
2903
2975
 
2904
- const transformed: Order[] = orders.map((order: any) => transformOrder(order));
2905
-
2906
- // Multi-tenant: filter by sales channel if requested
2907
- const channelId = salesChannelId ?? process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
2908
- if (channelId) {
2909
- const scoped = filterByChannel<Order>(transformed, channelId);
2910
- this.logger.debug(
2911
- `Channel scoping: ${scoped.length}/${transformed.length} orders match channel ${channelId}`,
2976
+ // Fail closed if the backend scoped to a channel other than the store's
2977
+ // configured one (a multi-channel key must never be silently accepted).
2978
+ if (salesChannelId) {
2979
+ const foreign = rawOrders.find(
2980
+ (order: any) =>
2981
+ typeof order?.sales_channel_id === 'string' &&
2982
+ order.sales_channel_id !== salesChannelId,
2912
2983
  );
2913
- return scoped;
2984
+ if (foreign) {
2985
+ throw new ProviderAPIError(
2986
+ `Channel-scoped order history returned an order outside the store's sales channel (${salesChannelId}).`,
2987
+ 'medusa',
2988
+ );
2989
+ }
2914
2990
  }
2915
2991
 
2916
- return transformed;
2992
+ return {
2993
+ orders: rawOrders.map((order: any) => transformOrder(order)),
2994
+ count: typeof response.count === 'number' ? response.count : rawOrders.length,
2995
+ limit: typeof response.limit === 'number' ? response.limit : limit,
2996
+ offset: typeof response.offset === 'number' ? response.offset : offset,
2997
+ };
2917
2998
  } catch (error) {
2999
+ if (error instanceof ProviderAPIError) throw error;
2918
3000
  const status = (error as any)?.status || (error as any)?.statusCode;
2919
3001
  const errorMessage = extractErrorMessage(error);
2920
3002
 
2921
- this.logger.error('Error getting orders', { error: errorMessage });
3003
+ this.logger.error('Error getting channel-scoped orders', { error: errorMessage });
2922
3004
  throw new ProviderAPIError(
2923
3005
  `Failed to fetch orders: ${errorMessage}`,
2924
3006
  'medusa',
@@ -174,4 +174,49 @@ describe('transformProduct', () => {
174
174
  expect(product.priceRange.minVariantPrice.amount).toBe('')
175
175
  expect(product.priceRange.maxVariantPrice.amount).toBe('')
176
176
  })
177
+
178
+ it('maps the backend category relation, preserving hierarchy', () => {
179
+ const product = transformProduct({
180
+ id: 'prod_cat',
181
+ handle: 'with-categories',
182
+ title: 'With Categories',
183
+ description: '',
184
+ images: [],
185
+ options: [],
186
+ variants: [],
187
+ categories: [
188
+ { id: 'cat_tools', name: 'Tools', handle: 'tools' },
189
+ {
190
+ id: 'cat_drills',
191
+ name: 'Drills',
192
+ handle: 'drills',
193
+ parent_category_id: 'cat_tools',
194
+ },
195
+ ],
196
+ })
197
+
198
+ expect(product.categories).toEqual([
199
+ { id: 'cat_tools', name: 'Tools', handle: 'tools', parentCategoryId: undefined },
200
+ {
201
+ id: 'cat_drills',
202
+ name: 'Drills',
203
+ handle: 'drills',
204
+ parentCategoryId: 'cat_tools',
205
+ },
206
+ ])
207
+ })
208
+
209
+ it('omits categories when the backend supplies none (never fabricated)', () => {
210
+ const product = transformProduct({
211
+ id: 'prod_no_cat',
212
+ handle: 'no-categories',
213
+ title: 'No Categories',
214
+ description: '',
215
+ images: [],
216
+ options: [],
217
+ variants: [],
218
+ })
219
+
220
+ expect(product.categories).toBeUndefined()
221
+ })
177
222
  })
@@ -278,6 +278,13 @@ export function transformProduct(
278
278
  handle: product.collection.handle ?? '',
279
279
  }] : undefined,
280
280
 
281
+ categories: product.categories?.map((category: any) => ({
282
+ id: category.id,
283
+ name: category.name ?? '',
284
+ handle: category.handle ?? '',
285
+ parentCategoryId: category.parent_category_id ?? undefined,
286
+ })),
287
+
281
288
  productType: product.type?.value ?? undefined,
282
289
  vendor: undefined, // Medusa doesn't have vendor field
283
290
  tags: product.tags?.map((t: any) => t.value ?? '') ?? [],
@@ -70,9 +70,11 @@ describe('MockProvider.getCatalogProducts — scope', () => {
70
70
  it('returns the full fixture catalog with no filters (no filter leak)', async () => {
71
71
  const outcome = await makeProvider().getCatalogProducts({})
72
72
 
73
- expect(handles(outcome)).toEqual(FIXTURE_ORDER)
73
+ const productHandles = handles(outcome)
74
+ expect(productHandles.slice(0, 4)).toEqual(FIXTURE_ORDER)
75
+ expect(productHandles).toHaveLength(39)
74
76
  if (!outcome.ok) return
75
- expect(outcome.results.total).toBe(4)
77
+ expect(outcome.results.total).toBe(39)
76
78
  })
77
79
 
78
80
  it('retains the route category scope and resolves the handle to its products', async () => {
@@ -80,10 +82,15 @@ describe('MockProvider.getCatalogProducts — scope', () => {
80
82
  categoryScope: 'jackets',
81
83
  })
82
84
 
83
- expect(handles(outcome)).toEqual(['aurora-jacket'])
85
+ expect(handles(outcome)).toContain('aurora-jacket')
84
86
  if (!outcome.ok) return
85
87
  expect(outcome.results.appliedFilters.categories).toEqual(['jackets'])
86
- expect(outcome.results.total).toBe(1)
88
+ expect(outcome.results.total).toBe(12)
89
+ expect(
90
+ outcome.results.products.every(
91
+ (product) => product.metadata?.categoryHandle === 'jackets',
92
+ ),
93
+ ).toBe(true)
87
94
  })
88
95
 
89
96
  it('intersects the categories filter with the route scope', async () => {
@@ -92,9 +99,14 @@ describe('MockProvider.getCatalogProducts — scope', () => {
92
99
  categories: ['jackets', 'dresses'],
93
100
  })
94
101
 
95
- expect(handles(outcome)).toEqual(['aurora-jacket'])
102
+ expect(handles(outcome)).toContain('aurora-jacket')
96
103
  if (!outcome.ok) return
97
104
  expect(outcome.results.appliedFilters.categories).toEqual(['jackets'])
105
+ expect(
106
+ outcome.results.products.every(
107
+ (product) => product.metadata?.categoryHandle === 'jackets',
108
+ ),
109
+ ).toBe(true)
98
110
  })
99
111
 
100
112
  it('treats an empty route-scope ∩ categories intersection as zero results, not unfiltered', async () => {
@@ -128,12 +140,20 @@ describe('MockProvider.getCatalogProducts — scope', () => {
128
140
  collections: ['mock-collection-best-sellers'],
129
141
  })
130
142
 
131
- expect(handles(byHandle)).toEqual(['aurora-jacket', 'helios-lamp'])
132
- expect(handles(byId)).toEqual(['aurora-jacket', 'helios-lamp'])
143
+ expect(handles(byHandle)).toEqual(handles(byId))
144
+ expect(handles(byHandle)).toContain('aurora-jacket')
145
+ expect(handles(byHandle)).toContain('helios-lamp')
133
146
  if (!byHandle.ok) return
134
147
  expect(byHandle.results.appliedFilters.collections).toEqual([
135
148
  'best-sellers',
136
149
  ])
150
+ expect(
151
+ byHandle.results.products.every((product) =>
152
+ (product.collections ?? []).some(
153
+ (collection) => collection.handle === 'best-sellers',
154
+ ),
155
+ ),
156
+ ).toBe(true)
137
157
  })
138
158
 
139
159
  it('treats an unknown collection handle as zero results, not unfiltered', async () => {
@@ -161,8 +181,14 @@ describe('MockProvider.getCatalogProducts — text', () => {
161
181
  text: 'dress',
162
182
  })
163
183
 
164
- expect(handles(unscoped)).toEqual(['solis-dress'])
184
+ expect(handles(unscoped)).toContain('solis-dress')
165
185
  expect(handles(scoped)).toEqual([])
186
+ if (!unscoped.ok) return
187
+ expect(
188
+ unscoped.results.products.every(
189
+ (product) => product.metadata?.categoryHandle === 'dresses',
190
+ ),
191
+ ).toBe(true)
166
192
  })
167
193
 
168
194
  it('excludes the other category for a term that matches two categories (CU-09)', async () => {
@@ -208,11 +234,11 @@ describe('MockProvider.getCatalogProducts — global ordering before pagination'
208
234
  pageSize: 2,
209
235
  })
210
236
 
211
- expect(handles(page1)).toEqual(['aurora-jacket', 'helios-lamp'])
212
- expect(handles(page2)).toEqual(['luna-bag', 'solis-dress'])
237
+ expect(handles(page1)).toEqual(['alpine-jacket', 'amber-dress'])
238
+ expect(handles(page2)).toEqual(['atlas-lamp', 'aurora-jacket'])
213
239
  if (!page1.ok || !page2.ok) return
214
- expect(page1.results.total).toBe(4)
215
- expect(page2.results.total).toBe(4)
240
+ expect(page1.results.total).toBe(39)
241
+ expect(page2.results.total).toBe(39)
216
242
  })
217
243
 
218
244
  it('orders by newest (fixture createdAt desc) across pages', async () => {
@@ -261,15 +287,17 @@ describe('MockProvider.getCatalogProducts — global ordering before pagination'
261
287
  pageSize: 1,
262
288
  })
263
289
 
264
- expect(handles(page1)).toEqual(['aurora-jacket'])
265
- expect(handles(page2)).toEqual(['helios-lamp'])
290
+ expect(handles(page1)).toEqual(['alpine-jacket'])
291
+ expect(handles(page2)).toEqual(['aurora-jacket'])
266
292
  if (!page1.ok) return
267
- expect(page1.results.total).toBe(2)
293
+ expect(page1.results.total).toBe(17)
268
294
  })
269
295
 
270
296
  it('defaults to page 1 and the canonical page size of 50', async () => {
271
297
  const outcome = await makeProvider().getCatalogProducts({})
272
- expect(handles(outcome)).toEqual(FIXTURE_ORDER)
298
+ const productHandles = handles(outcome)
299
+ expect(productHandles).toHaveLength(39)
300
+ expect(productHandles.slice(0, 4)).toEqual(FIXTURE_ORDER)
273
301
  })
274
302
  })
275
303
 
@@ -282,7 +310,14 @@ describe('MockProvider.getCatalogProducts — truthfulness', () => {
282
310
  collections: ['best-sellers'],
283
311
  })
284
312
 
285
- expect(handles(outcome)).toEqual(['aurora-jacket'])
313
+ expect(handles(outcome)).toEqual([
314
+ 'aurora-jacket',
315
+ 'alpine-jacket',
316
+ 'boreal-jacket',
317
+ 'ember-jacket',
318
+ 'meridian-jacket',
319
+ 'vantage-jacket',
320
+ ])
286
321
  if (!outcome.ok) return
287
322
  expect(outcome.results.appliedFilters).toEqual({
288
323
  text: 'jacket',
@@ -353,3 +388,104 @@ describe('MockProvider.getCatalogProducts — truthfulness', () => {
353
388
  expect(outcome.error.fields).toContain('inStock')
354
389
  })
355
390
  })
391
+
392
+ describe('MockProvider.getCatalogProducts — enriched demo seed (storefront-mock-demo-catalog)', () => {
393
+ const SEEDED_CATEGORIES = ['jackets', 'dresses', 'home', 'accessories']
394
+ const SEEDED_COLLECTIONS = ['new-arrivals', 'best-sellers', 'studio-essentials']
395
+
396
+ it('seeds every category with 8-12 products', async () => {
397
+ const provider = makeProvider()
398
+
399
+ for (const category of SEEDED_CATEGORIES) {
400
+ const outcome = await provider.getCatalogProducts({ categories: [category] })
401
+ if (!outcome.ok) throw new Error(outcome.error.message)
402
+ expect(outcome.results.total).toBeGreaterThanOrEqual(8)
403
+ expect(outcome.results.total).toBeLessThanOrEqual(12)
404
+ // The default page size (50) holds the whole category, so page length
405
+ // equals the real match count.
406
+ expect(outcome.results.products.length).toBe(outcome.results.total)
407
+ }
408
+ })
409
+
410
+ it('mints unique handles, ids, and titles across the whole seed', async () => {
411
+ const outcome = await makeProvider().getCatalogProducts({})
412
+ if (!outcome.ok) throw new Error(outcome.error.message)
413
+
414
+ const { products } = outcome.results
415
+ const handles = products.map((product) => product.handle)
416
+ const ids = products.map((product) => product.id)
417
+ const titles = products.map((product) => product.title)
418
+
419
+ expect(products.length).toBe(39)
420
+ expect(new Set(handles).size).toBe(handles.length)
421
+ expect(new Set(ids).size).toBe(ids.length)
422
+ expect(new Set(titles).size).toBe(titles.length)
423
+ })
424
+
425
+ it('spreads products across collections (each >= 2, at least one in two)', async () => {
426
+ const provider = makeProvider()
427
+
428
+ for (const collection of SEEDED_COLLECTIONS) {
429
+ const outcome = await provider.getCatalogProducts({ collections: [collection] })
430
+ if (!outcome.ok) throw new Error(outcome.error.message)
431
+ expect(outcome.results.total).toBeGreaterThanOrEqual(2)
432
+ }
433
+
434
+ const all = await provider.getCatalogProducts({})
435
+ if (!all.ok) throw new Error(all.error.message)
436
+ expect(
437
+ all.results.products.some((product) => (product.collections?.length ?? 0) >= 2),
438
+ ).toBe(true)
439
+ })
440
+
441
+ it('narrows the result set with a text query', async () => {
442
+ const provider = makeProvider()
443
+ const all = await provider.getCatalogProducts({})
444
+ const narrowed = await provider.getCatalogProducts({ text: 'jacket' })
445
+ if (!all.ok || !narrowed.ok) throw new Error('expected ok outcome')
446
+
447
+ expect(narrowed.results.total).toBeGreaterThan(0)
448
+ expect(narrowed.results.total ?? 0).toBeLessThan(all.results.total ?? 0)
449
+ })
450
+
451
+ it('produces different orderings for different sort keys', async () => {
452
+ const provider = makeProvider()
453
+ const featured = await provider.getCatalogProducts({ sort: 'featured' })
454
+ const name = await provider.getCatalogProducts({ sort: 'name' })
455
+ const newest = await provider.getCatalogProducts({ sort: 'newest' })
456
+ if (!featured.ok || !name.ok || !newest.ok) throw new Error('expected ok outcome')
457
+
458
+ // Featured keeps the seeded heroes first, name reorders alphabetically.
459
+ expect(featured.results.products[0]?.handle).toBe('aurora-jacket')
460
+ expect(name.results.products[0]?.handle).toBe('alpine-jacket')
461
+ expect(newest.results.products[0]?.handle).toBe('aurora-jacket')
462
+ expect(name.results.products[0]?.handle).not.toBe(
463
+ featured.results.products[0]?.handle,
464
+ )
465
+ })
466
+
467
+ it('returns a filtered category as a strict subset of the unfiltered catalog', async () => {
468
+ const provider = makeProvider()
469
+ const all = await provider.getCatalogProducts({})
470
+ const jackets = await provider.getCatalogProducts({ categories: ['jackets'] })
471
+ if (!all.ok || !jackets.ok) throw new Error('expected ok outcome')
472
+
473
+ const allHandles = new Set(all.results.products.map((product) => product.handle))
474
+ expect(jackets.results.total ?? 0).toBeLessThan(all.results.total ?? 0)
475
+ for (const product of jackets.results.products) {
476
+ expect(allHandles.has(product.handle)).toBe(true)
477
+ expect(product.metadata?.categoryHandle).toBe('jackets')
478
+ }
479
+ })
480
+
481
+ it('resolves a collection filter to a different set than the category filter', async () => {
482
+ const provider = makeProvider()
483
+ const jackets = await provider.getCatalogProducts({ categories: ['jackets'] })
484
+ const bestSellers = await provider.getCatalogProducts({
485
+ collections: ['best-sellers'],
486
+ })
487
+ if (!jackets.ok || !bestSellers.ok) throw new Error('expected ok outcome')
488
+
489
+ expect(handles(bestSellers)).not.toEqual(handles(jackets))
490
+ })
491
+ })
@@ -45,3 +45,22 @@ describe('MockProvider — selected shipping option', () => {
45
45
  ).rejects.toThrow()
46
46
  })
47
47
  })
48
+
49
+ describe('MockProvider — product category references', () => {
50
+ it('exposes the seed category reference on products', async () => {
51
+ const provider = new MockProvider({ currencyCode: 'USD' })
52
+ const products = await provider.getProducts({ first: 100 })
53
+ const withCategory = products.find(
54
+ (product) => product.categories && product.categories.length > 0,
55
+ )
56
+
57
+ expect(withCategory).toBeDefined()
58
+ expect(withCategory?.categories?.[0]).toEqual(
59
+ expect.objectContaining({
60
+ id: expect.any(String),
61
+ name: expect.any(String),
62
+ handle: expect.any(String),
63
+ }),
64
+ )
65
+ })
66
+ })