@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.
- package/AGENTS.md +1 -1
- package/dist/index.d.mts +376 -16
- package/dist/index.d.ts +376 -16
- package/dist/index.js +1687 -528
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1680 -530
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
- package/src/core/errors.ts +31 -0
- package/src/factory/index.ts +16 -6
- package/src/factory/provider-types.test.ts +32 -0
- package/src/factory/provider-types.ts +176 -0
- package/src/index.ts +6 -4
- package/src/providers/index.ts +1 -1
- package/src/providers/medusa/auth.test.ts +148 -25
- package/src/providers/medusa/auth.ts +129 -16
- package/src/providers/medusa/available-filters.test.ts +280 -0
- package/src/providers/medusa/catalog-facet-support.test.ts +715 -0
- package/src/providers/medusa/catalog-query.test.ts +422 -0
- package/src/providers/medusa/catalog-sort.test.ts +175 -0
- package/src/providers/medusa/customer-orders.test.ts +142 -0
- package/src/providers/medusa/index.test.ts +480 -0
- package/src/providers/medusa/index.ts +1592 -538
- package/src/providers/medusa/live-smoke.test.ts +68 -0
- package/src/providers/medusa/transforms.test.ts +222 -0
- package/src/providers/medusa/transforms.ts +152 -66
- package/src/providers/mock/catalog-query.test.ts +491 -0
- package/src/providers/mock/index.test.ts +66 -0
- package/src/providers/mock/index.ts +43 -1
- package/src/providers/mock/mock.ts +513 -3
- package/src/types/completion.ts +57 -0
- package/src/types/index.ts +7 -0
- package/.turbo/turbo-build.log +0 -18
- package/.turbo/turbo-test.log +0 -372
- package/.turbo/turbo-type-check.log +0 -0
- package/dist/.tsbuildinfo +0 -1
- 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
|
|
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);
|