@thorprovider/adapters 5.0.9 → 5.0.11

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.
@@ -0,0 +1,355 @@
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
+ expect(handles(outcome)).toEqual(FIXTURE_ORDER)
74
+ if (!outcome.ok) return
75
+ expect(outcome.results.total).toBe(4)
76
+ })
77
+
78
+ it('retains the route category scope and resolves the handle to its products', async () => {
79
+ const outcome = await makeProvider().getCatalogProducts({
80
+ categoryScope: 'jackets',
81
+ })
82
+
83
+ expect(handles(outcome)).toEqual(['aurora-jacket'])
84
+ if (!outcome.ok) return
85
+ expect(outcome.results.appliedFilters.categories).toEqual(['jackets'])
86
+ expect(outcome.results.total).toBe(1)
87
+ })
88
+
89
+ it('intersects the categories filter with the route scope', async () => {
90
+ const outcome = await makeProvider().getCatalogProducts({
91
+ categoryScope: 'jackets',
92
+ categories: ['jackets', 'dresses'],
93
+ })
94
+
95
+ expect(handles(outcome)).toEqual(['aurora-jacket'])
96
+ if (!outcome.ok) return
97
+ expect(outcome.results.appliedFilters.categories).toEqual(['jackets'])
98
+ })
99
+
100
+ it('treats an empty route-scope ∩ categories intersection as zero results, not unfiltered', async () => {
101
+ const outcome = await makeProvider().getCatalogProducts({
102
+ categoryScope: 'jackets',
103
+ categories: ['dresses'],
104
+ })
105
+
106
+ expect(handles(outcome)).toEqual([])
107
+ if (!outcome.ok) return
108
+ expect(outcome.results.appliedFilters.categories).toEqual([])
109
+ })
110
+
111
+ it('treats an unknown category handle as zero results, not unfiltered', async () => {
112
+ const outcome = await makeProvider().getCatalogProducts({
113
+ categories: ['does-not-exist'],
114
+ })
115
+
116
+ expect(handles(outcome)).toEqual([])
117
+ if (!outcome.ok) return
118
+ expect(outcome.results.appliedFilters.categories).toEqual([
119
+ 'does-not-exist',
120
+ ])
121
+ })
122
+
123
+ it('resolves collections by handle and by id and reports them applied', async () => {
124
+ const byHandle = await makeProvider().getCatalogProducts({
125
+ collections: ['best-sellers'],
126
+ })
127
+ const byId = await makeProvider().getCatalogProducts({
128
+ collections: ['mock-collection-best-sellers'],
129
+ })
130
+
131
+ expect(handles(byHandle)).toEqual(['aurora-jacket', 'helios-lamp'])
132
+ expect(handles(byId)).toEqual(['aurora-jacket', 'helios-lamp'])
133
+ if (!byHandle.ok) return
134
+ expect(byHandle.results.appliedFilters.collections).toEqual([
135
+ 'best-sellers',
136
+ ])
137
+ })
138
+
139
+ it('treats an unknown collection handle as zero results, not unfiltered', async () => {
140
+ const outcome = await makeProvider().getCatalogProducts({
141
+ collections: ['does-not-exist'],
142
+ })
143
+
144
+ expect(handles(outcome)).toEqual([])
145
+ })
146
+ })
147
+
148
+ describe('MockProvider.getCatalogProducts — text', () => {
149
+ it('matches text case-insensitively and reports it as applied', async () => {
150
+ const outcome = await makeProvider().getCatalogProducts({ text: 'HELIOS' })
151
+
152
+ expect(handles(outcome)).toEqual(['helios-lamp'])
153
+ if (!outcome.ok) return
154
+ expect(outcome.results.appliedFilters.text).toBe('HELIOS')
155
+ })
156
+
157
+ it('keeps text search within the route category scope', async () => {
158
+ const unscoped = await makeProvider().getCatalogProducts({ text: 'dress' })
159
+ const scoped = await makeProvider().getCatalogProducts({
160
+ categoryScope: 'jackets',
161
+ text: 'dress',
162
+ })
163
+
164
+ expect(handles(unscoped)).toEqual(['solis-dress'])
165
+ expect(handles(scoped)).toEqual([])
166
+ })
167
+
168
+ it('excludes the other category for a term that matches two categories (CU-09)', async () => {
169
+ // "light" matches "Lightweight" (aurora-jacket, jackets) and "lighting"
170
+ // (helios-lamp, home): unscoped the term spans two categories, so the route
171
+ // scope must return only the current category's matches and never the other
172
+ // category's — the exact within-category-search exclusion of CU-09.
173
+ const unscoped = await makeProvider().getCatalogProducts({ text: 'light' })
174
+ const inJackets = await makeProvider().getCatalogProducts({
175
+ categoryScope: 'jackets',
176
+ text: 'light',
177
+ })
178
+ const inHome = await makeProvider().getCatalogProducts({
179
+ categoryScope: 'home',
180
+ text: 'light',
181
+ })
182
+
183
+ expect(handles(unscoped)).toEqual(['aurora-jacket', 'helios-lamp'])
184
+ expect(handles(inJackets)).toEqual(['aurora-jacket'])
185
+ expect(handles(inHome)).toEqual(['helios-lamp'])
186
+ if (!inJackets.ok || !inHome.ok) return
187
+ expect(inJackets.results.appliedFilters).toMatchObject({
188
+ text: 'light',
189
+ categories: ['jackets'],
190
+ })
191
+ expect(inHome.results.appliedFilters).toMatchObject({
192
+ text: 'light',
193
+ categories: ['home'],
194
+ })
195
+ })
196
+ })
197
+
198
+ describe('MockProvider.getCatalogProducts — global ordering before pagination', () => {
199
+ it('orders by name across pages and reports the full total, not the page length', async () => {
200
+ const page1 = await makeProvider().getCatalogProducts({
201
+ sort: 'name',
202
+ page: 1,
203
+ pageSize: 2,
204
+ })
205
+ const page2 = await makeProvider().getCatalogProducts({
206
+ sort: 'name',
207
+ page: 2,
208
+ pageSize: 2,
209
+ })
210
+
211
+ expect(handles(page1)).toEqual(['aurora-jacket', 'helios-lamp'])
212
+ expect(handles(page2)).toEqual(['luna-bag', 'solis-dress'])
213
+ if (!page1.ok || !page2.ok) return
214
+ expect(page1.results.total).toBe(4)
215
+ expect(page2.results.total).toBe(4)
216
+ })
217
+
218
+ it('orders by newest (fixture createdAt desc) across pages', async () => {
219
+ const page1 = await makeProvider().getCatalogProducts({
220
+ sort: 'newest',
221
+ page: 1,
222
+ pageSize: 2,
223
+ })
224
+ const page2 = await makeProvider().getCatalogProducts({
225
+ sort: 'newest',
226
+ page: 2,
227
+ pageSize: 2,
228
+ })
229
+
230
+ expect(handles(page1)).toEqual(['aurora-jacket', 'helios-lamp'])
231
+ expect(handles(page2)).toEqual(['solis-dress', 'luna-bag'])
232
+ })
233
+
234
+ it('keeps the stable fixture order for featured across pages', async () => {
235
+ const page1 = await makeProvider().getCatalogProducts({
236
+ sort: 'featured',
237
+ page: 1,
238
+ pageSize: 2,
239
+ })
240
+ const page2 = await makeProvider().getCatalogProducts({
241
+ sort: 'featured',
242
+ page: 2,
243
+ pageSize: 2,
244
+ })
245
+
246
+ expect(handles(page1)).toEqual(['aurora-jacket', 'solis-dress'])
247
+ expect(handles(page2)).toEqual(['helios-lamp', 'luna-bag'])
248
+ })
249
+
250
+ it('orders a collection listing globally before paginating', async () => {
251
+ const page1 = await makeProvider().getCatalogProducts({
252
+ collections: ['best-sellers'],
253
+ sort: 'name',
254
+ page: 1,
255
+ pageSize: 1,
256
+ })
257
+ const page2 = await makeProvider().getCatalogProducts({
258
+ collections: ['best-sellers'],
259
+ sort: 'name',
260
+ page: 2,
261
+ pageSize: 1,
262
+ })
263
+
264
+ expect(handles(page1)).toEqual(['aurora-jacket'])
265
+ expect(handles(page2)).toEqual(['helios-lamp'])
266
+ if (!page1.ok) return
267
+ expect(page1.results.total).toBe(2)
268
+ })
269
+
270
+ it('defaults to page 1 and the canonical page size of 50', async () => {
271
+ const outcome = await makeProvider().getCatalogProducts({})
272
+ expect(handles(outcome)).toEqual(FIXTURE_ORDER)
273
+ })
274
+ })
275
+
276
+ describe('MockProvider.getCatalogProducts — truthfulness', () => {
277
+ it('reflects every applied basic filter (text + categories + collections)', async () => {
278
+ const outcome = await makeProvider().getCatalogProducts({
279
+ text: 'jacket',
280
+ categoryScope: 'jackets',
281
+ categories: ['jackets'],
282
+ collections: ['best-sellers'],
283
+ })
284
+
285
+ expect(handles(outcome)).toEqual(['aurora-jacket'])
286
+ if (!outcome.ok) return
287
+ expect(outcome.results.appliedFilters).toEqual({
288
+ text: 'jacket',
289
+ categories: ['jackets'],
290
+ collections: ['best-sellers'],
291
+ })
292
+ })
293
+
294
+ it('attaches a mock-scoped facet-support descriptor that never claims Medusa support', async () => {
295
+ const outcome = await makeProvider().getCatalogProducts({})
296
+ if (!outcome.ok) return
297
+
298
+ const support = outcome.results.facetSupport
299
+ expect(support?.offered).toEqual(['categories', 'collections'])
300
+ expect(support?.held).toEqual(['price', 'inStock'])
301
+ expect(support?.demonstrations?.categories.endpoint).toBe(
302
+ 'mock://catalog/products',
303
+ )
304
+ expect(support?.demonstrations?.collections.endpoint).toBe(
305
+ 'mock://catalog/products',
306
+ )
307
+ expect(support?.heldReasons?.price).toBeTruthy()
308
+ expect(support?.heldReasons?.inStock).toBeTruthy()
309
+ expect(JSON.stringify(support)).not.toContain('medusa')
310
+ })
311
+
312
+ it('rejects an unadvertised sort with a typed unsupported-query', async () => {
313
+ const outcome = await makeProvider().getCatalogProducts({
314
+ sort: 'price-asc',
315
+ } as never)
316
+
317
+ expect(outcome.ok).toBe(false)
318
+ if (outcome.ok) return
319
+ expect(outcome.error.code).toBe('unsupported-query')
320
+ expect(outcome.error.fields).toContain('sort')
321
+ expect(outcome.error.repair.remove).toContain('sort')
322
+ })
323
+
324
+ it.each([
325
+ ['priceMin', { priceMin: 10 }],
326
+ ['priceMax', { priceMax: 99 }],
327
+ ['inStock', { inStock: true }],
328
+ ['attr.brand', { attributes: { brand: ['acme'] } }],
329
+ ])(
330
+ 'rejects an unsupported requested filter (%s) with a typed unsupported-query and no partial result',
331
+ async (field, partial) => {
332
+ const outcome = await makeProvider().getCatalogProducts({
333
+ ...(partial as Record<string, unknown>),
334
+ } as never)
335
+
336
+ expect(outcome.ok).toBe(false)
337
+ if (outcome.ok) return
338
+ expect(outcome.error.code).toBe('unsupported-query')
339
+ expect(outcome.error.fields).toContain(field)
340
+ expect(outcome.error.repair.remove).toContain(field)
341
+ },
342
+ )
343
+
344
+ it('rejects an unsupported filter even on an empty-intersection zero-result scope', async () => {
345
+ const outcome = await makeProvider().getCatalogProducts({
346
+ categoryScope: 'jackets',
347
+ categories: ['dresses'],
348
+ inStock: true,
349
+ } as never)
350
+
351
+ expect(outcome.ok).toBe(false)
352
+ if (outcome.ok) return
353
+ expect(outcome.error.fields).toContain('inStock')
354
+ })
355
+ })
@@ -0,0 +1,47 @@
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
+ })
@@ -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);