@thorprovider/adapters 5.0.10 → 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.
Files changed (37) hide show
  1. package/AGENTS.md +1 -1
  2. package/dist/index.d.mts +376 -16
  3. package/dist/index.d.ts +376 -16
  4. package/dist/index.js +1687 -528
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +1680 -530
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +3 -2
  9. package/src/core/errors.ts +31 -0
  10. package/src/factory/index.ts +16 -6
  11. package/src/factory/provider-types.test.ts +32 -0
  12. package/src/factory/provider-types.ts +176 -0
  13. package/src/index.ts +6 -4
  14. package/src/providers/index.ts +1 -1
  15. package/src/providers/medusa/auth.test.ts +148 -25
  16. package/src/providers/medusa/auth.ts +129 -16
  17. package/src/providers/medusa/available-filters.test.ts +280 -0
  18. package/src/providers/medusa/catalog-facet-support.test.ts +715 -0
  19. package/src/providers/medusa/catalog-query.test.ts +422 -0
  20. package/src/providers/medusa/catalog-sort.test.ts +175 -0
  21. package/src/providers/medusa/customer-orders.test.ts +142 -0
  22. package/src/providers/medusa/index.test.ts +480 -0
  23. package/src/providers/medusa/index.ts +1592 -538
  24. package/src/providers/medusa/live-smoke.test.ts +68 -0
  25. package/src/providers/medusa/transforms.test.ts +222 -0
  26. package/src/providers/medusa/transforms.ts +152 -66
  27. package/src/providers/mock/catalog-query.test.ts +491 -0
  28. package/src/providers/mock/index.test.ts +66 -0
  29. package/src/providers/mock/index.ts +43 -1
  30. package/src/providers/mock/mock.ts +513 -3
  31. package/src/types/completion.ts +57 -0
  32. package/src/types/index.ts +7 -0
  33. package/.turbo/turbo-build.log +0 -18
  34. package/.turbo/turbo-test.log +0 -372
  35. package/.turbo/turbo-type-check.log +0 -0
  36. package/dist/.tsbuildinfo +0 -1
  37. package/publish.log +0 -93
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Mock provider canonical catalog query — E2 `storefront-catalog-search-filters`
3
+ * task 2.5.
4
+ *
5
+ * The mock must satisfy the same canonical query contract the Medusa adapter
6
+ * honors so mock mode is a faithful stand-in: route scope ∩ `categories`
7
+ * (an explicit empty intersection is a valid zero-result query), collection
8
+ * resolution by id or handle, case-insensitive text, global ordering before
9
+ * pagination, the real full match count (never the page length), a truthful
10
+ * facet-support descriptor, and a typed `unsupported-query` for any filter the
11
+ * mock cannot apply.
12
+ *
13
+ * These assert fixed expected fixture sets — not the implementation — so a
14
+ * regression in scope, ordering, pagination, or truthfulness fails them.
15
+ */
16
+ import { describe, expect, it } from 'vitest'
17
+ import type { CatalogQueryOutcome } from '@thorprovider/types'
18
+ import { MockProvider } from './index'
19
+ import { MOCK_CATALOG_SORT_KEYS } from './mock'
20
+
21
+ const FIXTURE_ORDER = [
22
+ 'aurora-jacket',
23
+ 'solis-dress',
24
+ 'helios-lamp',
25
+ 'luna-bag',
26
+ ]
27
+
28
+ const NAME_ORDER = [
29
+ 'aurora-jacket',
30
+ 'helios-lamp',
31
+ 'luna-bag',
32
+ 'solis-dress',
33
+ ]
34
+
35
+ const NEWEST_ORDER = [
36
+ 'aurora-jacket',
37
+ 'helios-lamp',
38
+ 'solis-dress',
39
+ 'luna-bag',
40
+ ]
41
+
42
+ function makeProvider() {
43
+ return new MockProvider({ currencyCode: 'USD' })
44
+ }
45
+
46
+ function handles(outcome: CatalogQueryOutcome): string[] {
47
+ if (!outcome.ok) {
48
+ throw new Error(`expected ok outcome, got: ${outcome.error.message}`)
49
+ }
50
+ return outcome.results.products.map((product) => product.handle)
51
+ }
52
+
53
+ describe('MockProvider — canonical catalog sort advertisement', () => {
54
+ it('advertises exactly the canonical sorts it can apply, with no price/rating sort', () => {
55
+ const provider = makeProvider()
56
+
57
+ expect(provider.supportedCatalogSortKeys).toEqual([
58
+ 'featured',
59
+ 'name',
60
+ 'newest',
61
+ ])
62
+ expect(MOCK_CATALOG_SORT_KEYS).toEqual(['featured', 'name', 'newest'])
63
+ expect(provider.supportedCatalogSortKeys).not.toContain('price-asc')
64
+ expect(provider.supportedCatalogSortKeys).not.toContain('price-desc')
65
+ expect(provider.supportedCatalogSortKeys).not.toContain('rating')
66
+ })
67
+ })
68
+
69
+ describe('MockProvider.getCatalogProducts — scope', () => {
70
+ it('returns the full fixture catalog with no filters (no filter leak)', async () => {
71
+ const outcome = await makeProvider().getCatalogProducts({})
72
+
73
+ const productHandles = handles(outcome)
74
+ expect(productHandles.slice(0, 4)).toEqual(FIXTURE_ORDER)
75
+ expect(productHandles).toHaveLength(39)
76
+ if (!outcome.ok) return
77
+ expect(outcome.results.total).toBe(39)
78
+ })
79
+
80
+ it('retains the route category scope and resolves the handle to its products', async () => {
81
+ const outcome = await makeProvider().getCatalogProducts({
82
+ categoryScope: 'jackets',
83
+ })
84
+
85
+ expect(handles(outcome)).toContain('aurora-jacket')
86
+ if (!outcome.ok) return
87
+ expect(outcome.results.appliedFilters.categories).toEqual(['jackets'])
88
+ expect(outcome.results.total).toBe(12)
89
+ expect(
90
+ outcome.results.products.every(
91
+ (product) => product.metadata?.categoryHandle === 'jackets',
92
+ ),
93
+ ).toBe(true)
94
+ })
95
+
96
+ it('intersects the categories filter with the route scope', async () => {
97
+ const outcome = await makeProvider().getCatalogProducts({
98
+ categoryScope: 'jackets',
99
+ categories: ['jackets', 'dresses'],
100
+ })
101
+
102
+ expect(handles(outcome)).toContain('aurora-jacket')
103
+ if (!outcome.ok) return
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)
110
+ })
111
+
112
+ it('treats an empty route-scope ∩ categories intersection as zero results, not unfiltered', async () => {
113
+ const outcome = await makeProvider().getCatalogProducts({
114
+ categoryScope: 'jackets',
115
+ categories: ['dresses'],
116
+ })
117
+
118
+ expect(handles(outcome)).toEqual([])
119
+ if (!outcome.ok) return
120
+ expect(outcome.results.appliedFilters.categories).toEqual([])
121
+ })
122
+
123
+ it('treats an unknown category handle as zero results, not unfiltered', async () => {
124
+ const outcome = await makeProvider().getCatalogProducts({
125
+ categories: ['does-not-exist'],
126
+ })
127
+
128
+ expect(handles(outcome)).toEqual([])
129
+ if (!outcome.ok) return
130
+ expect(outcome.results.appliedFilters.categories).toEqual([
131
+ 'does-not-exist',
132
+ ])
133
+ })
134
+
135
+ it('resolves collections by handle and by id and reports them applied', async () => {
136
+ const byHandle = await makeProvider().getCatalogProducts({
137
+ collections: ['best-sellers'],
138
+ })
139
+ const byId = await makeProvider().getCatalogProducts({
140
+ collections: ['mock-collection-best-sellers'],
141
+ })
142
+
143
+ expect(handles(byHandle)).toEqual(handles(byId))
144
+ expect(handles(byHandle)).toContain('aurora-jacket')
145
+ expect(handles(byHandle)).toContain('helios-lamp')
146
+ if (!byHandle.ok) return
147
+ expect(byHandle.results.appliedFilters.collections).toEqual([
148
+ 'best-sellers',
149
+ ])
150
+ expect(
151
+ byHandle.results.products.every((product) =>
152
+ (product.collections ?? []).some(
153
+ (collection) => collection.handle === 'best-sellers',
154
+ ),
155
+ ),
156
+ ).toBe(true)
157
+ })
158
+
159
+ it('treats an unknown collection handle as zero results, not unfiltered', async () => {
160
+ const outcome = await makeProvider().getCatalogProducts({
161
+ collections: ['does-not-exist'],
162
+ })
163
+
164
+ expect(handles(outcome)).toEqual([])
165
+ })
166
+ })
167
+
168
+ describe('MockProvider.getCatalogProducts — text', () => {
169
+ it('matches text case-insensitively and reports it as applied', async () => {
170
+ const outcome = await makeProvider().getCatalogProducts({ text: 'HELIOS' })
171
+
172
+ expect(handles(outcome)).toEqual(['helios-lamp'])
173
+ if (!outcome.ok) return
174
+ expect(outcome.results.appliedFilters.text).toBe('HELIOS')
175
+ })
176
+
177
+ it('keeps text search within the route category scope', async () => {
178
+ const unscoped = await makeProvider().getCatalogProducts({ text: 'dress' })
179
+ const scoped = await makeProvider().getCatalogProducts({
180
+ categoryScope: 'jackets',
181
+ text: 'dress',
182
+ })
183
+
184
+ expect(handles(unscoped)).toContain('solis-dress')
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)
192
+ })
193
+
194
+ it('excludes the other category for a term that matches two categories (CU-09)', async () => {
195
+ // "light" matches "Lightweight" (aurora-jacket, jackets) and "lighting"
196
+ // (helios-lamp, home): unscoped the term spans two categories, so the route
197
+ // scope must return only the current category's matches and never the other
198
+ // category's — the exact within-category-search exclusion of CU-09.
199
+ const unscoped = await makeProvider().getCatalogProducts({ text: 'light' })
200
+ const inJackets = await makeProvider().getCatalogProducts({
201
+ categoryScope: 'jackets',
202
+ text: 'light',
203
+ })
204
+ const inHome = await makeProvider().getCatalogProducts({
205
+ categoryScope: 'home',
206
+ text: 'light',
207
+ })
208
+
209
+ expect(handles(unscoped)).toEqual(['aurora-jacket', 'helios-lamp'])
210
+ expect(handles(inJackets)).toEqual(['aurora-jacket'])
211
+ expect(handles(inHome)).toEqual(['helios-lamp'])
212
+ if (!inJackets.ok || !inHome.ok) return
213
+ expect(inJackets.results.appliedFilters).toMatchObject({
214
+ text: 'light',
215
+ categories: ['jackets'],
216
+ })
217
+ expect(inHome.results.appliedFilters).toMatchObject({
218
+ text: 'light',
219
+ categories: ['home'],
220
+ })
221
+ })
222
+ })
223
+
224
+ describe('MockProvider.getCatalogProducts — global ordering before pagination', () => {
225
+ it('orders by name across pages and reports the full total, not the page length', async () => {
226
+ const page1 = await makeProvider().getCatalogProducts({
227
+ sort: 'name',
228
+ page: 1,
229
+ pageSize: 2,
230
+ })
231
+ const page2 = await makeProvider().getCatalogProducts({
232
+ sort: 'name',
233
+ page: 2,
234
+ pageSize: 2,
235
+ })
236
+
237
+ expect(handles(page1)).toEqual(['alpine-jacket', 'amber-dress'])
238
+ expect(handles(page2)).toEqual(['atlas-lamp', 'aurora-jacket'])
239
+ if (!page1.ok || !page2.ok) return
240
+ expect(page1.results.total).toBe(39)
241
+ expect(page2.results.total).toBe(39)
242
+ })
243
+
244
+ it('orders by newest (fixture createdAt desc) across pages', async () => {
245
+ const page1 = await makeProvider().getCatalogProducts({
246
+ sort: 'newest',
247
+ page: 1,
248
+ pageSize: 2,
249
+ })
250
+ const page2 = await makeProvider().getCatalogProducts({
251
+ sort: 'newest',
252
+ page: 2,
253
+ pageSize: 2,
254
+ })
255
+
256
+ expect(handles(page1)).toEqual(['aurora-jacket', 'helios-lamp'])
257
+ expect(handles(page2)).toEqual(['solis-dress', 'luna-bag'])
258
+ })
259
+
260
+ it('keeps the stable fixture order for featured across pages', async () => {
261
+ const page1 = await makeProvider().getCatalogProducts({
262
+ sort: 'featured',
263
+ page: 1,
264
+ pageSize: 2,
265
+ })
266
+ const page2 = await makeProvider().getCatalogProducts({
267
+ sort: 'featured',
268
+ page: 2,
269
+ pageSize: 2,
270
+ })
271
+
272
+ expect(handles(page1)).toEqual(['aurora-jacket', 'solis-dress'])
273
+ expect(handles(page2)).toEqual(['helios-lamp', 'luna-bag'])
274
+ })
275
+
276
+ it('orders a collection listing globally before paginating', async () => {
277
+ const page1 = await makeProvider().getCatalogProducts({
278
+ collections: ['best-sellers'],
279
+ sort: 'name',
280
+ page: 1,
281
+ pageSize: 1,
282
+ })
283
+ const page2 = await makeProvider().getCatalogProducts({
284
+ collections: ['best-sellers'],
285
+ sort: 'name',
286
+ page: 2,
287
+ pageSize: 1,
288
+ })
289
+
290
+ expect(handles(page1)).toEqual(['alpine-jacket'])
291
+ expect(handles(page2)).toEqual(['aurora-jacket'])
292
+ if (!page1.ok) return
293
+ expect(page1.results.total).toBe(17)
294
+ })
295
+
296
+ it('defaults to page 1 and the canonical page size of 50', async () => {
297
+ const outcome = await makeProvider().getCatalogProducts({})
298
+ const productHandles = handles(outcome)
299
+ expect(productHandles).toHaveLength(39)
300
+ expect(productHandles.slice(0, 4)).toEqual(FIXTURE_ORDER)
301
+ })
302
+ })
303
+
304
+ describe('MockProvider.getCatalogProducts — truthfulness', () => {
305
+ it('reflects every applied basic filter (text + categories + collections)', async () => {
306
+ const outcome = await makeProvider().getCatalogProducts({
307
+ text: 'jacket',
308
+ categoryScope: 'jackets',
309
+ categories: ['jackets'],
310
+ collections: ['best-sellers'],
311
+ })
312
+
313
+ expect(handles(outcome)).toEqual([
314
+ 'aurora-jacket',
315
+ 'alpine-jacket',
316
+ 'boreal-jacket',
317
+ 'ember-jacket',
318
+ 'meridian-jacket',
319
+ 'vantage-jacket',
320
+ ])
321
+ if (!outcome.ok) return
322
+ expect(outcome.results.appliedFilters).toEqual({
323
+ text: 'jacket',
324
+ categories: ['jackets'],
325
+ collections: ['best-sellers'],
326
+ })
327
+ })
328
+
329
+ it('attaches a mock-scoped facet-support descriptor that never claims Medusa support', async () => {
330
+ const outcome = await makeProvider().getCatalogProducts({})
331
+ if (!outcome.ok) return
332
+
333
+ const support = outcome.results.facetSupport
334
+ expect(support?.offered).toEqual(['categories', 'collections'])
335
+ expect(support?.held).toEqual(['price', 'inStock'])
336
+ expect(support?.demonstrations?.categories.endpoint).toBe(
337
+ 'mock://catalog/products',
338
+ )
339
+ expect(support?.demonstrations?.collections.endpoint).toBe(
340
+ 'mock://catalog/products',
341
+ )
342
+ expect(support?.heldReasons?.price).toBeTruthy()
343
+ expect(support?.heldReasons?.inStock).toBeTruthy()
344
+ expect(JSON.stringify(support)).not.toContain('medusa')
345
+ })
346
+
347
+ it('rejects an unadvertised sort with a typed unsupported-query', async () => {
348
+ const outcome = await makeProvider().getCatalogProducts({
349
+ sort: 'price-asc',
350
+ } as never)
351
+
352
+ expect(outcome.ok).toBe(false)
353
+ if (outcome.ok) return
354
+ expect(outcome.error.code).toBe('unsupported-query')
355
+ expect(outcome.error.fields).toContain('sort')
356
+ expect(outcome.error.repair.remove).toContain('sort')
357
+ })
358
+
359
+ it.each([
360
+ ['priceMin', { priceMin: 10 }],
361
+ ['priceMax', { priceMax: 99 }],
362
+ ['inStock', { inStock: true }],
363
+ ['attr.brand', { attributes: { brand: ['acme'] } }],
364
+ ])(
365
+ 'rejects an unsupported requested filter (%s) with a typed unsupported-query and no partial result',
366
+ async (field, partial) => {
367
+ const outcome = await makeProvider().getCatalogProducts({
368
+ ...(partial as Record<string, unknown>),
369
+ } as never)
370
+
371
+ expect(outcome.ok).toBe(false)
372
+ if (outcome.ok) return
373
+ expect(outcome.error.code).toBe('unsupported-query')
374
+ expect(outcome.error.fields).toContain(field)
375
+ expect(outcome.error.repair.remove).toContain(field)
376
+ },
377
+ )
378
+
379
+ it('rejects an unsupported filter even on an empty-intersection zero-result scope', async () => {
380
+ const outcome = await makeProvider().getCatalogProducts({
381
+ categoryScope: 'jackets',
382
+ categories: ['dresses'],
383
+ inStock: true,
384
+ } as never)
385
+
386
+ expect(outcome.ok).toBe(false)
387
+ if (outcome.ok) return
388
+ expect(outcome.error.fields).toContain('inStock')
389
+ })
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
+ })
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Mock provider — selected shipping option identity.
3
+ *
4
+ * Regression for the host highlight: the mock cart must expose
5
+ * `selectedShippingOptionId` (the `so_` identity the checkout UI compares
6
+ * against `getShippingMethods()`), coherent with the mock method ids, while
7
+ * preserving the guard that rejects an unknown option.
8
+ */
9
+ import { describe, expect, it } from 'vitest'
10
+ import { MockProvider } from './index'
11
+ import { getMockShippingMethods } from './mock'
12
+
13
+ const REGION = 'mock-region-us'
14
+
15
+ describe('MockProvider — selected shipping option', () => {
16
+ it('is absent before a method is applied', async () => {
17
+ const provider = new MockProvider({ currencyCode: 'USD' })
18
+ const cart = await provider.createCart({ regionId: REGION })
19
+ expect(cart.selectedShippingOptionId).toBeUndefined()
20
+ })
21
+
22
+ it('mirrors the applied method id on add and on retrieve', async () => {
23
+ const provider = new MockProvider({ currencyCode: 'USD' })
24
+ const cart = await provider.createCart({ regionId: REGION })
25
+ const methodId = getMockShippingMethods(REGION)[0].id
26
+
27
+ const updated = await provider.addShippingMethod(cart.id as string, methodId)
28
+
29
+ expect(updated.selectedShippingOptionId).toBe(methodId)
30
+ expect(updated.selectedShippingMethodId).toBe(methodId)
31
+
32
+ const fetched = await provider.getCart(cart.id as string)
33
+ expect(fetched?.selectedShippingOptionId).toBe(methodId)
34
+ expect(getMockShippingMethods(REGION).map((method) => method.id)).toContain(
35
+ updated.selectedShippingOptionId,
36
+ )
37
+ })
38
+
39
+ it('keeps the method guard: an unknown option is rejected', async () => {
40
+ const provider = new MockProvider({ currencyCode: 'USD' })
41
+ const cart = await provider.createCart({ regionId: REGION })
42
+
43
+ await expect(
44
+ provider.addShippingMethod(cart.id as string, 'so_not_allowed'),
45
+ ).rejects.toThrow()
46
+ })
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
+ })
@@ -2,6 +2,8 @@ import type {
2
2
  Cart,
3
3
  CartLineInput,
4
4
  CartLineUpdate,
5
+ CatalogQuery,
6
+ CatalogQueryOutcome,
5
7
  Collection,
6
8
  CollectionProductsOptions,
7
9
  GetCategoriesOptions,
@@ -22,6 +24,7 @@ import type {
22
24
  PaymentMethod,
23
25
  } from '@thorprovider/types';
24
26
  import type { CommerceProvider, ProviderConfig } from '../../types/provider';
27
+ import type { CartCompletionCapable, CartCompletionResult } from '../../types/completion';
25
28
  import { ProviderAPIError, ResourceNotFoundError } from '../../core/errors';
26
29
  import { MOCK_CAPABILITIES } from '../../types/capabilities.types';
27
30
  import {
@@ -30,6 +33,7 @@ import {
30
33
  createMockCart,
31
34
  getMockAdminOrders,
32
35
  getMockAdminRegions,
36
+ getMockCatalogProducts,
33
37
  getMockCategories,
34
38
  getMockCategory,
35
39
  getMockCollection,
@@ -46,6 +50,7 @@ import {
46
50
  getMockRegions,
47
51
  getMockShippingMethods,
48
52
  getMockStockLocations,
53
+ MOCK_CATALOG_SORT_KEYS,
49
54
  saveMockCartState,
50
55
  saveMockOrder,
51
56
  validateMockCartStock,
@@ -56,11 +61,14 @@ export interface MockProviderConfig extends ProviderConfig {
56
61
  siteUrl?: string;
57
62
  }
58
63
 
59
- export class MockProvider implements CommerceProvider {
64
+ export { MOCK_SALES_CHANNEL_ID } from './mock';
65
+
66
+ export class MockProvider implements CommerceProvider, CartCompletionCapable {
60
67
  readonly name = 'mock';
61
68
  readonly version = '1.0.0';
62
69
  readonly capabilities = MOCK_CAPABILITIES;
63
70
  readonly isMock = true;
71
+ readonly supportedCatalogSortKeys = MOCK_CATALOG_SORT_KEYS;
64
72
 
65
73
  private readonly currencyCode: string;
66
74
  private readonly siteUrl: string;
@@ -97,6 +105,19 @@ export class MockProvider implements CommerceProvider {
97
105
  });
98
106
  }
99
107
 
108
+ /**
109
+ * Canonical catalog query path (E2 task 2.5).
110
+ *
111
+ * Delegates to the fixture-backed canonical implementation so mock mode is a
112
+ * faithful stand-in for the same contract the Medusa adapter honors: scope ∩
113
+ * categories, collection resolution, global ordering before pagination, the
114
+ * real full match count, and a typed `unsupported-query` for advanced filters
115
+ * the mock does not implement.
116
+ */
117
+ async getCatalogProducts(query: CatalogQuery): Promise<CatalogQueryOutcome> {
118
+ return getMockCatalogProducts(query, { currencyCode: this.currencyCode });
119
+ }
120
+
100
121
  async getProductRecommendations(productId: string): Promise<Product[]> {
101
122
  return getMockProductRecommendations(productId);
102
123
  }
@@ -274,6 +295,27 @@ export class MockProvider implements CommerceProvider {
274
295
  return order;
275
296
  }
276
297
 
298
+ /**
299
+ * Discriminated completion for the fixture provider.
300
+ *
301
+ * Completion is idempotent by construction (the fixture order id derives from
302
+ * the cart id), so completing an already-completed cart returns the same
303
+ * persisted order — mirroring the native reconcile path without a second
304
+ * order.
305
+ */
306
+ async completeCart(cartId: string): Promise<CartCompletionResult> {
307
+ const cart = getMockCartState(cartId);
308
+ if (!cart) {
309
+ return {
310
+ type: 'unresolved',
311
+ error: { message: `Cart not found: ${cartId}`, code: 'CART_NOT_FOUND', status: 404 },
312
+ };
313
+ }
314
+
315
+ const order = completeMockCheckout(cart, this.currencyCode, this.siteUrl);
316
+ return { type: 'order', order };
317
+ }
318
+
277
319
  async getShippingMethods(cartId: string): Promise<ShippingMethod[]> {
278
320
  const cart = getMockCartState(cartId);
279
321
  return getMockShippingMethods(cart?.regionId);