@thorprovider/adapters 5.0.10 → 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.
- package/.turbo/turbo-build.log +9 -9
- package/.turbo/turbo-test.log +564 -39
- package/AGENTS.md +1 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/index.d.mts +348 -11
- package/dist/index.d.ts +348 -11
- package/dist/index.js +1393 -496
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1386 -498
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- 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/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/index.test.ts +480 -0
- package/src/providers/medusa/index.ts +1490 -518
- package/src/providers/medusa/live-smoke.test.ts +68 -0
- package/src/providers/medusa/transforms.test.ts +177 -0
- package/src/providers/medusa/transforms.ts +145 -66
- package/src/providers/mock/catalog-query.test.ts +355 -0
- package/src/providers/mock/index.test.ts +47 -0
- package/src/providers/mock/index.ts +43 -1
- package/src/providers/mock/mock.ts +324 -2
- package/src/types/completion.ts +57 -0
- package/src/types/index.ts +7 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter canonical query forwarding — E2 `storefront-catalog-search-filters`
|
|
3
|
+
* task 2.2.
|
|
4
|
+
*
|
|
5
|
+
* `getCatalogProducts` must accept a validated `CatalogQuery` and forward its
|
|
6
|
+
* scope, text, collections, sort, and pagination to the Medusa Store
|
|
7
|
+
* `/store/products` endpoint, reporting only the filters it actually applied.
|
|
8
|
+
* Any requested filter the backend cannot apply (price/stock/attributes) must
|
|
9
|
+
* return a typed `unsupported-query` outcome naming the fields, never a partial
|
|
10
|
+
* success, and must not issue a provider query. An empty route-scope ∩
|
|
11
|
+
* `categories` intersection is a valid zero-result query, never an unfiltered
|
|
12
|
+
* request.
|
|
13
|
+
*
|
|
14
|
+
* The HTTP boundary is intercepted with msw against a controlled fixture, so the
|
|
15
|
+
* emitted query parameters are asserted directly — the meaningful SDK seam.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, it } from 'vitest'
|
|
18
|
+
import { http, HttpResponse } from 'msw'
|
|
19
|
+
import { server } from '../stelorder/client.test-helpers'
|
|
20
|
+
import { MedusaProvider } from './index'
|
|
21
|
+
|
|
22
|
+
const BASE = 'https://medusa.example.com'
|
|
23
|
+
const PUB_KEY = 'pk_test_abc123'
|
|
24
|
+
const CURRENCY = 'usd'
|
|
25
|
+
|
|
26
|
+
const CATEGORIES = [
|
|
27
|
+
{
|
|
28
|
+
id: 'pcat_x',
|
|
29
|
+
name: 'Category X',
|
|
30
|
+
handle: 'x',
|
|
31
|
+
description: null,
|
|
32
|
+
parent_category_id: null,
|
|
33
|
+
product_count: 3,
|
|
34
|
+
metadata: null,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'pcat_y',
|
|
38
|
+
name: 'Category Y',
|
|
39
|
+
handle: 'y',
|
|
40
|
+
description: null,
|
|
41
|
+
parent_category_id: null,
|
|
42
|
+
product_count: 2,
|
|
43
|
+
metadata: null,
|
|
44
|
+
},
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
const COLLECTIONS = [
|
|
48
|
+
{ id: 'pcol_c1', handle: 'c1', title: 'Collection One', metadata: null },
|
|
49
|
+
{ id: 'pcol_c2', handle: 'c2', title: 'Collection Two', metadata: null },
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
const MEDUSA_PRODUCT = {
|
|
53
|
+
id: 'prod_01',
|
|
54
|
+
handle: 'test-product',
|
|
55
|
+
title: 'Test Product',
|
|
56
|
+
subtitle: null,
|
|
57
|
+
description: 'A test product',
|
|
58
|
+
status: 'published',
|
|
59
|
+
thumbnail: 'https://example.com/thumb.jpg',
|
|
60
|
+
images: [{ id: 'img_01', url: 'https://example.com/img.jpg', metadata: null }],
|
|
61
|
+
tags: [],
|
|
62
|
+
collection: null,
|
|
63
|
+
categories: [{ id: 'pcat_x', name: 'Category X', handle: 'x' }],
|
|
64
|
+
options: [],
|
|
65
|
+
variants: [
|
|
66
|
+
{
|
|
67
|
+
id: 'variant_01',
|
|
68
|
+
title: 'Default',
|
|
69
|
+
product_id: 'prod_01',
|
|
70
|
+
sku: 'SKU-001',
|
|
71
|
+
inventory_quantity: 10,
|
|
72
|
+
prices: [{ id: 'price_01', amount: 2500, currency_code: 'usd' }],
|
|
73
|
+
calculated_price: { calculated_amount: 2500, currency_code: 'usd' },
|
|
74
|
+
options: [],
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
metadata: null,
|
|
78
|
+
created_at: '2026-01-01T00:00:00Z',
|
|
79
|
+
updated_at: '2026-01-01T00:00:00Z',
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function makeProvider() {
|
|
83
|
+
return new MedusaProvider({
|
|
84
|
+
baseUrl: BASE,
|
|
85
|
+
publishableKey: PUB_KEY,
|
|
86
|
+
currencyCode: CURRENCY,
|
|
87
|
+
debug: false,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Read a query parameter regardless of how the SDK serializes it: a scalar as
|
|
93
|
+
* `key`, or a list as `key[0]`/`key[]` (qs default array format).
|
|
94
|
+
*/
|
|
95
|
+
function getParam(url: string, key: string): string | null {
|
|
96
|
+
const params = new URL(url).searchParams
|
|
97
|
+
if (params.has(key)) return params.get(key)
|
|
98
|
+
for (const [k, v] of params.entries()) {
|
|
99
|
+
if (k === `${key}[]` || k.startsWith(`${key}[`)) return v
|
|
100
|
+
}
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getParamValues(url: string, key: string): string[] {
|
|
105
|
+
const out: string[] = []
|
|
106
|
+
for (const [k, v] of new URL(url).searchParams.entries()) {
|
|
107
|
+
if (k === key || k === `${key}[]` || k.startsWith(`${key}[`)) out.push(v)
|
|
108
|
+
}
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface Fixture {
|
|
113
|
+
/** Every `/store/products` request URL (the provider query under test). */
|
|
114
|
+
productListUrls: string[]
|
|
115
|
+
/** Whether the backend supplies a total count on the product list. */
|
|
116
|
+
withCount: boolean
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function installFixture(options: { withCount?: boolean } = {}): Fixture {
|
|
120
|
+
const fixture: Fixture = {
|
|
121
|
+
productListUrls: [],
|
|
122
|
+
withCount: options.withCount ?? true,
|
|
123
|
+
}
|
|
124
|
+
server.use(
|
|
125
|
+
// Thor capability probe is unrelated to the product listing; answer 404 so
|
|
126
|
+
// the fixture stays quiet and only product-list calls are captured.
|
|
127
|
+
http.get(`${BASE}/store/thor/capabilities`, () =>
|
|
128
|
+
HttpResponse.json({}, { status: 404 }),
|
|
129
|
+
),
|
|
130
|
+
http.get(`${BASE}/store/product-categories`, ({ request }) => {
|
|
131
|
+
const handle = getParam(request.url, 'handle')
|
|
132
|
+
const match = handle
|
|
133
|
+
? CATEGORIES.filter((c) => c.handle === handle)
|
|
134
|
+
: CATEGORIES
|
|
135
|
+
return HttpResponse.json({
|
|
136
|
+
product_categories: match,
|
|
137
|
+
count: match.length,
|
|
138
|
+
offset: 0,
|
|
139
|
+
limit: 1,
|
|
140
|
+
})
|
|
141
|
+
}),
|
|
142
|
+
http.get(`${BASE}/store/collections`, ({ request }) => {
|
|
143
|
+
const handle = getParam(request.url, 'handle')
|
|
144
|
+
const match = handle
|
|
145
|
+
? COLLECTIONS.filter((c) => c.handle === handle)
|
|
146
|
+
: COLLECTIONS
|
|
147
|
+
return HttpResponse.json({
|
|
148
|
+
collections: match,
|
|
149
|
+
count: match.length,
|
|
150
|
+
offset: 0,
|
|
151
|
+
limit: 1,
|
|
152
|
+
})
|
|
153
|
+
}),
|
|
154
|
+
http.get(`${BASE}/store/products`, ({ request }) => {
|
|
155
|
+
fixture.productListUrls.push(request.url)
|
|
156
|
+
return HttpResponse.json({
|
|
157
|
+
products: [MEDUSA_PRODUCT],
|
|
158
|
+
...(fixture.withCount ? { count: 42 } : {}),
|
|
159
|
+
offset: 0,
|
|
160
|
+
limit: 50,
|
|
161
|
+
})
|
|
162
|
+
}),
|
|
163
|
+
// The optional advanced endpoint is absent on this fixture: the facet
|
|
164
|
+
// support probe must hold price/stock/attribute filters.
|
|
165
|
+
http.get(`${BASE}/store/search`, () =>
|
|
166
|
+
HttpResponse.json(
|
|
167
|
+
{ error: 'Failed to search products', message: 'search unavailable' },
|
|
168
|
+
{ status: 500 },
|
|
169
|
+
),
|
|
170
|
+
),
|
|
171
|
+
http.get(`${BASE}/store/custom-attributes/category/:categoryId`, () =>
|
|
172
|
+
HttpResponse.json({ attributes: [], count: 0 }),
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
return fixture
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
describe('MedusaProvider.getCatalogProducts — canonical query forwarding', () => {
|
|
179
|
+
it('forwards text as q and reports it as an applied filter', async () => {
|
|
180
|
+
const fixture = installFixture()
|
|
181
|
+
const outcome = await makeProvider().getCatalogProducts({
|
|
182
|
+
text: 'taladro',
|
|
183
|
+
sort: 'featured',
|
|
184
|
+
page: 1,
|
|
185
|
+
pageSize: 50,
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
expect(outcome.ok).toBe(true)
|
|
189
|
+
if (!outcome.ok) return
|
|
190
|
+
expect(fixture.productListUrls).toHaveLength(1)
|
|
191
|
+
expect(getParam(fixture.productListUrls[0], 'q')).toBe('taladro')
|
|
192
|
+
expect(outcome.results.appliedFilters.text).toBe('taladro')
|
|
193
|
+
expect(outcome.results.products).toHaveLength(1)
|
|
194
|
+
expect(outcome.results.total).toBe(42)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('retains the route category scope and resolves its handle to a backend ID', async () => {
|
|
198
|
+
const fixture = installFixture()
|
|
199
|
+
const outcome = await makeProvider().getCatalogProducts({
|
|
200
|
+
categoryScope: 'x',
|
|
201
|
+
page: 1,
|
|
202
|
+
pageSize: 50,
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
expect(outcome.ok).toBe(true)
|
|
206
|
+
if (!outcome.ok) return
|
|
207
|
+
expect(getParamValues(fixture.productListUrls[0], 'category_id')).toEqual([
|
|
208
|
+
'pcat_x',
|
|
209
|
+
])
|
|
210
|
+
expect(outcome.results.appliedFilters.categories).toEqual(['x'])
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('intersects the categories filter with the route scope', async () => {
|
|
214
|
+
const fixture = installFixture()
|
|
215
|
+
const outcome = await makeProvider().getCatalogProducts({
|
|
216
|
+
categoryScope: 'x',
|
|
217
|
+
categories: ['x', 'y'],
|
|
218
|
+
page: 1,
|
|
219
|
+
pageSize: 50,
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
expect(outcome.ok).toBe(true)
|
|
223
|
+
if (!outcome.ok) return
|
|
224
|
+
expect(getParamValues(fixture.productListUrls[0], 'category_id')).toEqual([
|
|
225
|
+
'pcat_x',
|
|
226
|
+
])
|
|
227
|
+
expect(outcome.results.appliedFilters.categories).toEqual(['x'])
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('treats an empty route-scope ∩ categories intersection as zero results with no unfiltered request', async () => {
|
|
231
|
+
const fixture = installFixture()
|
|
232
|
+
const outcome = await makeProvider().getCatalogProducts({
|
|
233
|
+
categoryScope: 'x',
|
|
234
|
+
categories: ['y'],
|
|
235
|
+
page: 1,
|
|
236
|
+
pageSize: 50,
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
expect(outcome.ok).toBe(true)
|
|
240
|
+
if (!outcome.ok) return
|
|
241
|
+
expect(outcome.results.products).toEqual([])
|
|
242
|
+
expect(outcome.results.appliedFilters.categories).toEqual([])
|
|
243
|
+
expect(fixture.productListUrls).toHaveLength(0)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('resolves collection handles to backend IDs and reports them applied', async () => {
|
|
247
|
+
const fixture = installFixture()
|
|
248
|
+
const outcome = await makeProvider().getCatalogProducts({
|
|
249
|
+
collections: ['c1'],
|
|
250
|
+
page: 1,
|
|
251
|
+
pageSize: 50,
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
expect(outcome.ok).toBe(true)
|
|
255
|
+
if (!outcome.ok) return
|
|
256
|
+
expect(getParamValues(fixture.productListUrls[0], 'collection_id')).toEqual([
|
|
257
|
+
'pcol_c1',
|
|
258
|
+
])
|
|
259
|
+
expect(outcome.results.appliedFilters.collections).toEqual(['c1'])
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('translates canonical sort keys to the backend order and omits featured', async () => {
|
|
263
|
+
const cases: Array<[string, string | null]> = [
|
|
264
|
+
['name', 'title'],
|
|
265
|
+
['newest', '-created_at'],
|
|
266
|
+
['featured', null],
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
for (const [sort, order] of cases) {
|
|
270
|
+
const fixture = installFixture()
|
|
271
|
+
const outcome = await makeProvider().getCatalogProducts({
|
|
272
|
+
sort: sort as 'name' | 'newest' | 'featured',
|
|
273
|
+
page: 1,
|
|
274
|
+
pageSize: 50,
|
|
275
|
+
})
|
|
276
|
+
expect(outcome.ok).toBe(true)
|
|
277
|
+
expect(getParam(fixture.productListUrls[0], 'order')).toBe(order)
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('maps page/pageSize to limit and offset = (page - 1) * pageSize', async () => {
|
|
282
|
+
const fixture = installFixture()
|
|
283
|
+
await makeProvider().getCatalogProducts({ page: 3, pageSize: 20 })
|
|
284
|
+
|
|
285
|
+
const params = new URL(fixture.productListUrls[0]).searchParams
|
|
286
|
+
expect(params.get('limit')).toBe('20')
|
|
287
|
+
expect(params.get('offset')).toBe('40')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('defaults pagination to the canonical page size and first page', async () => {
|
|
291
|
+
const fixture = installFixture()
|
|
292
|
+
await makeProvider().getCatalogProducts({})
|
|
293
|
+
|
|
294
|
+
const params = new URL(fixture.productListUrls[0]).searchParams
|
|
295
|
+
expect(params.get('limit')).toBe('50')
|
|
296
|
+
expect(params.get('offset')).toBe('0')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('reflects every applied requested filter (text + categories + collections)', async () => {
|
|
300
|
+
const fixture = installFixture()
|
|
301
|
+
const outcome = await makeProvider().getCatalogProducts({
|
|
302
|
+
text: 'term',
|
|
303
|
+
categoryScope: 'x',
|
|
304
|
+
categories: ['x'],
|
|
305
|
+
collections: ['c1'],
|
|
306
|
+
sort: 'name',
|
|
307
|
+
page: 2,
|
|
308
|
+
pageSize: 10,
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
expect(outcome.ok).toBe(true)
|
|
312
|
+
if (!outcome.ok) return
|
|
313
|
+
expect(outcome.results.appliedFilters).toEqual({
|
|
314
|
+
text: 'term',
|
|
315
|
+
categories: ['x'],
|
|
316
|
+
collections: ['c1'],
|
|
317
|
+
})
|
|
318
|
+
const params = new URL(fixture.productListUrls[0]).searchParams
|
|
319
|
+
expect(params.get('q')).toBe('term')
|
|
320
|
+
expect(getParamValues(fixture.productListUrls[0], 'category_id')).toEqual([
|
|
321
|
+
'pcat_x',
|
|
322
|
+
])
|
|
323
|
+
expect(getParamValues(fixture.productListUrls[0], 'collection_id')).toEqual([
|
|
324
|
+
'pcol_c1',
|
|
325
|
+
])
|
|
326
|
+
expect(params.get('limit')).toBe('10')
|
|
327
|
+
expect(params.get('offset')).toBe('10')
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('requests the product thumbnail so a listing can resolve a featured image', async () => {
|
|
331
|
+
const fixture = installFixture()
|
|
332
|
+
await makeProvider().getCatalogProducts({ page: 1, pageSize: 50 })
|
|
333
|
+
|
|
334
|
+
const fields = getParam(fixture.productListUrls[0], 'fields')
|
|
335
|
+
expect(fields).not.toBeNull()
|
|
336
|
+
// `+thumbnail` ADDS to Medusa's defaults (a bare `thumbnail` would replace
|
|
337
|
+
// every default field, dropping variants/prices).
|
|
338
|
+
expect(fields).toContain('+thumbnail')
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('maps a returned thumbnail to featuredImage on a basic catalog product', async () => {
|
|
342
|
+
const fixture = installFixture()
|
|
343
|
+
const outcome = await makeProvider().getCatalogProducts({
|
|
344
|
+
page: 1,
|
|
345
|
+
pageSize: 50,
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
expect(outcome.ok).toBe(true)
|
|
349
|
+
if (!outcome.ok) return
|
|
350
|
+
expect(fixture.productListUrls).toHaveLength(1)
|
|
351
|
+
const product = outcome.results.products[0]
|
|
352
|
+
expect(product.featuredImage?.url).toBe('https://example.com/thumb.jpg')
|
|
353
|
+
expect(product.images?.map((img) => img.url)).toEqual([
|
|
354
|
+
'https://example.com/img.jpg',
|
|
355
|
+
])
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('omits total when the backend supplies none (never manufactured)', async () => {
|
|
359
|
+
const fixture = installFixture({ withCount: false })
|
|
360
|
+
const outcome = await makeProvider().getCatalogProducts({ page: 1, pageSize: 50 })
|
|
361
|
+
|
|
362
|
+
expect(outcome.ok).toBe(true)
|
|
363
|
+
if (!outcome.ok) return
|
|
364
|
+
expect(fixture.productListUrls).toHaveLength(1)
|
|
365
|
+
expect(outcome.results.total).toBeUndefined()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('rejects an unadvertised sort instead of silently defaulting', async () => {
|
|
369
|
+
const fixture = installFixture()
|
|
370
|
+
const outcome = await makeProvider().getCatalogProducts({
|
|
371
|
+
sort: 'price-asc',
|
|
372
|
+
} as never)
|
|
373
|
+
|
|
374
|
+
expect(outcome.ok).toBe(false)
|
|
375
|
+
if (outcome.ok) return
|
|
376
|
+
expect(outcome.error.code).toBe('unsupported-query')
|
|
377
|
+
expect(outcome.error.fields).toContain('sort')
|
|
378
|
+
expect(fixture.productListUrls).toHaveLength(0)
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it.each([
|
|
382
|
+
['priceMin', { priceMin: 10 }],
|
|
383
|
+
['priceMax', { priceMax: 99 }],
|
|
384
|
+
['inStock', { inStock: true }],
|
|
385
|
+
['attr.brand', { attributes: { brand: ['acme'] } }],
|
|
386
|
+
])(
|
|
387
|
+
'rejects an unsupported requested filter (%s) with a typed unsupported-query and no provider query',
|
|
388
|
+
async (field, partial) => {
|
|
389
|
+
const fixture = installFixture()
|
|
390
|
+
const outcome = await makeProvider().getCatalogProducts({
|
|
391
|
+
page: 1,
|
|
392
|
+
pageSize: 50,
|
|
393
|
+
...(partial as Record<string, unknown>),
|
|
394
|
+
} as never)
|
|
395
|
+
|
|
396
|
+
expect(outcome.ok).toBe(false)
|
|
397
|
+
if (outcome.ok) return
|
|
398
|
+
expect(outcome.error.code).toBe('unsupported-query')
|
|
399
|
+
expect(outcome.error.fields).toContain(field)
|
|
400
|
+
expect(outcome.error.repair.remove).toContain(field)
|
|
401
|
+
// No partial success and no provider query call at all.
|
|
402
|
+
expect(fixture.productListUrls).toHaveLength(0)
|
|
403
|
+
},
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
it('rejects when several unsupported filters are requested, naming every field', async () => {
|
|
407
|
+
const fixture = installFixture()
|
|
408
|
+
const outcome = await makeProvider().getCatalogProducts({
|
|
409
|
+
priceMin: 10,
|
|
410
|
+
inStock: true,
|
|
411
|
+
page: 1,
|
|
412
|
+
pageSize: 50,
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
expect(outcome.ok).toBe(false)
|
|
416
|
+
if (outcome.ok) return
|
|
417
|
+
expect(outcome.error.fields).toEqual(
|
|
418
|
+
expect.arrayContaining(['priceMin', 'inStock']),
|
|
419
|
+
)
|
|
420
|
+
expect(fixture.productListUrls).toHaveLength(0)
|
|
421
|
+
})
|
|
422
|
+
})
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter sort truthfulness — E2 `storefront-catalog-search-filters` task 2.1.
|
|
3
|
+
*
|
|
4
|
+
* `getCategoryProducts` must translate canonical Thor sort keys through the
|
|
5
|
+
* same mapping `getProducts` uses (`name → title`, `newest → -created_at`,
|
|
6
|
+
* `featured → default`), never pass the raw canonical key to Medusa's `order`
|
|
7
|
+
* field (the Store API rejects a disallowed field with a 400). The adapter must
|
|
8
|
+
* advertise exactly the sorts it can apply, with no global price/rating sort.
|
|
9
|
+
*
|
|
10
|
+
* The HTTP boundary is intercepted with msw, so the emitted `order` query
|
|
11
|
+
* parameter is asserted directly — the meaningful SDK seam.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, it } from 'vitest'
|
|
14
|
+
import { http, HttpResponse } from 'msw'
|
|
15
|
+
import { server } from '../stelorder/client.test-helpers'
|
|
16
|
+
import { MedusaProvider } from './index'
|
|
17
|
+
|
|
18
|
+
const BASE = 'https://medusa.example.com'
|
|
19
|
+
const PUB_KEY = 'pk_test_abc123'
|
|
20
|
+
const CURRENCY = 'usd'
|
|
21
|
+
|
|
22
|
+
const MEDUSA_CATEGORY = {
|
|
23
|
+
id: 'pcat_01',
|
|
24
|
+
name: 'Electronics',
|
|
25
|
+
handle: 'electronics',
|
|
26
|
+
description: null,
|
|
27
|
+
parent_category_id: null,
|
|
28
|
+
product_count: 1,
|
|
29
|
+
metadata: null,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const MEDUSA_PRODUCT = {
|
|
33
|
+
id: 'prod_01',
|
|
34
|
+
handle: 'test-product',
|
|
35
|
+
title: 'Test Product',
|
|
36
|
+
subtitle: null,
|
|
37
|
+
description: 'A test product',
|
|
38
|
+
status: 'published',
|
|
39
|
+
thumbnail: 'https://example.com/thumb.jpg',
|
|
40
|
+
images: [{ id: 'img_01', url: 'https://example.com/img.jpg', metadata: null }],
|
|
41
|
+
tags: [],
|
|
42
|
+
collection: null,
|
|
43
|
+
categories: [{ id: 'pcat_01', name: 'Electronics', handle: 'electronics' }],
|
|
44
|
+
options: [],
|
|
45
|
+
variants: [
|
|
46
|
+
{
|
|
47
|
+
id: 'variant_01',
|
|
48
|
+
title: 'Default',
|
|
49
|
+
product_id: 'prod_01',
|
|
50
|
+
sku: 'SKU-001',
|
|
51
|
+
inventory_quantity: 10,
|
|
52
|
+
prices: [{ id: 'price_01', amount: 2500, currency_code: 'usd' }],
|
|
53
|
+
calculated_price: { calculated_amount: 2500, currency_code: 'usd' },
|
|
54
|
+
options: [],
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
metadata: null,
|
|
58
|
+
created_at: '2026-01-01T00:00:00Z',
|
|
59
|
+
updated_at: '2026-01-01T00:00:00Z',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeProvider() {
|
|
63
|
+
return new MedusaProvider({
|
|
64
|
+
baseUrl: BASE,
|
|
65
|
+
publishableKey: PUB_KEY,
|
|
66
|
+
currencyCode: CURRENCY,
|
|
67
|
+
debug: false,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Intercept category lookup + product listing and capture the emitted `order`
|
|
73
|
+
* query parameter of the product-list request.
|
|
74
|
+
*/
|
|
75
|
+
function captureProductListOrder(): { orders: (string | null)[] } {
|
|
76
|
+
const orders: (string | null)[] = []
|
|
77
|
+
server.use(
|
|
78
|
+
http.get(`${BASE}/store/product-categories`, () =>
|
|
79
|
+
HttpResponse.json({
|
|
80
|
+
product_categories: [MEDUSA_CATEGORY],
|
|
81
|
+
count: 1,
|
|
82
|
+
offset: 0,
|
|
83
|
+
limit: 1,
|
|
84
|
+
}),
|
|
85
|
+
),
|
|
86
|
+
http.get(`${BASE}/store/products`, ({ request }) => {
|
|
87
|
+
const url = new URL(request.url)
|
|
88
|
+
orders.push(url.searchParams.get('order'))
|
|
89
|
+
return HttpResponse.json({
|
|
90
|
+
products: [MEDUSA_PRODUCT],
|
|
91
|
+
count: 1,
|
|
92
|
+
offset: 0,
|
|
93
|
+
limit: 50,
|
|
94
|
+
})
|
|
95
|
+
}),
|
|
96
|
+
)
|
|
97
|
+
return { orders }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe('MedusaProvider — catalog sort translation and advertisement', () => {
|
|
101
|
+
it('advertises exactly the canonical sorts it can apply, with no price/rating sort', () => {
|
|
102
|
+
const provider = makeProvider()
|
|
103
|
+
|
|
104
|
+
expect(provider.supportedCatalogSortKeys).toEqual(['featured', 'name', 'newest'])
|
|
105
|
+
expect(provider.supportedCatalogSortKeys).not.toContain('price-asc')
|
|
106
|
+
expect(provider.supportedCatalogSortKeys).not.toContain('price-desc')
|
|
107
|
+
expect(provider.supportedCatalogSortKeys).not.toContain('rating')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('getCategoryProducts maps name → title', async () => {
|
|
111
|
+
const { orders } = captureProductListOrder()
|
|
112
|
+
await makeProvider().getCategoryProducts('electronics', { sortKey: 'name' })
|
|
113
|
+
expect(orders).toEqual(['title'])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('getCategoryProducts maps newest → -created_at', async () => {
|
|
117
|
+
const { orders } = captureProductListOrder()
|
|
118
|
+
await makeProvider().getCategoryProducts('electronics', { sortKey: 'newest' })
|
|
119
|
+
expect(orders).toEqual(['-created_at'])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('getCategoryProducts omits order for featured (server default)', async () => {
|
|
123
|
+
const { orders } = captureProductListOrder()
|
|
124
|
+
await makeProvider().getCategoryProducts('electronics', { sortKey: 'featured' })
|
|
125
|
+
expect(orders).toEqual([null])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('never emits a raw canonical key as the Medusa order field', async () => {
|
|
129
|
+
for (const sortKey of ['featured', 'name', 'newest'] as const) {
|
|
130
|
+
const { orders } = captureProductListOrder()
|
|
131
|
+
await makeProvider().getCategoryProducts('electronics', { sortKey })
|
|
132
|
+
expect(orders[0]).not.toBe('featured')
|
|
133
|
+
expect(orders[0]).not.toBe('name')
|
|
134
|
+
expect(orders[0]).not.toBe('newest')
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('omits a page-local price/rating sort instead of sending it as a global order (no default price sort, not mislabelled)', async () => {
|
|
139
|
+
// The legacy `SortOption` price/rating keys are page-local orderings the
|
|
140
|
+
// backend cannot apply globally. Pre-change `getCategoryProducts` forwarded
|
|
141
|
+
// the raw key to `order` (`order=price-asc`), presenting a page-local price
|
|
142
|
+
// sort as a global backend sort. They must be omitted (server default).
|
|
143
|
+
for (const sortKey of ['price-asc', 'price-desc', 'rating'] as const) {
|
|
144
|
+
const { orders } = captureProductListOrder()
|
|
145
|
+
await makeProvider().getCategoryProducts('electronics', { sortKey })
|
|
146
|
+
expect(orders).toEqual([null])
|
|
147
|
+
expect(orders[0]).not.toBe(sortKey)
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('getProducts maps the same canonical keys through the same mapping', async () => {
|
|
152
|
+
const expected: Record<string, string | null> = {
|
|
153
|
+
featured: null,
|
|
154
|
+
name: 'title',
|
|
155
|
+
newest: '-created_at',
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const [sortKey, order] of Object.entries(expected)) {
|
|
159
|
+
const orders: (string | null)[] = []
|
|
160
|
+
server.use(
|
|
161
|
+
http.get(`${BASE}/store/products`, ({ request }) => {
|
|
162
|
+
orders.push(new URL(request.url).searchParams.get('order'))
|
|
163
|
+
return HttpResponse.json({
|
|
164
|
+
products: [MEDUSA_PRODUCT],
|
|
165
|
+
count: 1,
|
|
166
|
+
offset: 0,
|
|
167
|
+
limit: 50,
|
|
168
|
+
})
|
|
169
|
+
}),
|
|
170
|
+
)
|
|
171
|
+
await makeProvider().getProducts({ sortKey })
|
|
172
|
+
expect(orders).toEqual([order])
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
})
|