@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,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter optional-filter proof — E2 `storefront-catalog-search-filters`
|
|
3
|
+
* task 2.4.
|
|
4
|
+
*
|
|
5
|
+
* Optional filters (price, attributes, `/store/search`) must be proven against
|
|
6
|
+
* the real endpoint with the applied query for the current scope, never from a
|
|
7
|
+
* capability boolean or returned facet metadata. When the optional endpoint is
|
|
8
|
+
* absent, the filter is held, a basic-only request is served by the equivalent
|
|
9
|
+
* basic endpoint `/store/products`, and an advanced request is rejected as a
|
|
10
|
+
* typed `unsupported-query` error — never silently served by the basic endpoint
|
|
11
|
+
* and never a partial success.
|
|
12
|
+
*
|
|
13
|
+
* The HTTP boundary is intercepted with msw against a controlled fixture, so the
|
|
14
|
+
* real request/response evidence is asserted directly.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
17
|
+
import { join, resolve } from 'node:path'
|
|
18
|
+
import { beforeAll, describe, expect, it } from 'vitest'
|
|
19
|
+
import { http, HttpResponse, passthrough } from 'msw'
|
|
20
|
+
import { server } from '../stelorder/client.test-helpers'
|
|
21
|
+
import { MedusaProvider } from './index'
|
|
22
|
+
|
|
23
|
+
const BASE = 'https://medusa.example.com'
|
|
24
|
+
const PUB_KEY = 'pk_test_abc123'
|
|
25
|
+
const CURRENCY = 'usd'
|
|
26
|
+
|
|
27
|
+
const CATEGORY_X = {
|
|
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
|
+
const MEDUSA_PRODUCT = {
|
|
38
|
+
id: 'prod_01',
|
|
39
|
+
handle: 'test-product',
|
|
40
|
+
title: 'Test Product',
|
|
41
|
+
subtitle: null,
|
|
42
|
+
description: 'A test product',
|
|
43
|
+
status: 'published',
|
|
44
|
+
thumbnail: 'https://example.com/thumb.jpg',
|
|
45
|
+
images: [{ id: 'img_01', url: 'https://example.com/img.jpg', metadata: null }],
|
|
46
|
+
tags: [],
|
|
47
|
+
collection: null,
|
|
48
|
+
categories: [{ id: 'pcat_x', name: 'Category X', handle: 'x' }],
|
|
49
|
+
options: [],
|
|
50
|
+
variants: [
|
|
51
|
+
{
|
|
52
|
+
id: 'variant_01',
|
|
53
|
+
title: 'Default',
|
|
54
|
+
product_id: 'prod_01',
|
|
55
|
+
sku: 'SKU-001',
|
|
56
|
+
inventory_quantity: 10,
|
|
57
|
+
prices: [{ id: 'price_01', amount: 2500, currency_code: 'usd' }],
|
|
58
|
+
calculated_price: { calculated_amount: 2500, currency_code: 'usd' },
|
|
59
|
+
options: [],
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
metadata: null,
|
|
63
|
+
created_at: '2026-01-01T00:00:00Z',
|
|
64
|
+
updated_at: '2026-01-01T00:00:00Z',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const FILTERABLE_ATTRIBUTE = {
|
|
68
|
+
attribute_id: 'attr_brand',
|
|
69
|
+
attribute: {
|
|
70
|
+
name: 'Brand',
|
|
71
|
+
handle: 'brand',
|
|
72
|
+
type: 'multi-select',
|
|
73
|
+
is_filterable: true,
|
|
74
|
+
is_active: true,
|
|
75
|
+
ranking: 40,
|
|
76
|
+
values: [{ id: 'val_acme', value: 'Acme', count: 3 }],
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A distinct product returned only by the advanced endpoint. */
|
|
81
|
+
const ADVANCED_PRODUCT = {
|
|
82
|
+
...MEDUSA_PRODUCT,
|
|
83
|
+
id: 'prod_advanced',
|
|
84
|
+
handle: 'advanced-product',
|
|
85
|
+
title: 'Advanced Product',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function makeProvider() {
|
|
89
|
+
return new MedusaProvider({
|
|
90
|
+
baseUrl: BASE,
|
|
91
|
+
publishableKey: PUB_KEY,
|
|
92
|
+
currencyCode: CURRENCY,
|
|
93
|
+
debug: false,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getParamValues(url: string, key: string): string[] {
|
|
98
|
+
const out: string[] = []
|
|
99
|
+
for (const [k, v] of new URL(url).searchParams.entries()) {
|
|
100
|
+
if (k === key || k === `${key}[]` || k.startsWith(`${key}[`)) out.push(v)
|
|
101
|
+
}
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface Fixture {
|
|
106
|
+
productListUrls: string[]
|
|
107
|
+
searchUrls: string[]
|
|
108
|
+
customAttributeUrls: string[]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Install the basic endpoint, category/collection lookups, custom-attributes
|
|
113
|
+
* metadata, and a configurable `/store/search` response.
|
|
114
|
+
*/
|
|
115
|
+
function installFixture(
|
|
116
|
+
search: { status: number; body?: unknown } = { status: 500 },
|
|
117
|
+
attributes: Array<Record<string, unknown>> = [],
|
|
118
|
+
): Fixture {
|
|
119
|
+
const fixture: Fixture = {
|
|
120
|
+
productListUrls: [],
|
|
121
|
+
searchUrls: [],
|
|
122
|
+
customAttributeUrls: [],
|
|
123
|
+
}
|
|
124
|
+
server.use(
|
|
125
|
+
http.get(`${BASE}/store/thor/capabilities`, () =>
|
|
126
|
+
HttpResponse.json({}, { status: 404 }),
|
|
127
|
+
),
|
|
128
|
+
http.get(`${BASE}/store/product-categories`, () =>
|
|
129
|
+
HttpResponse.json({
|
|
130
|
+
product_categories: [CATEGORY_X],
|
|
131
|
+
count: 1,
|
|
132
|
+
offset: 0,
|
|
133
|
+
limit: 1,
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
136
|
+
http.get(`${BASE}/store/collections`, () =>
|
|
137
|
+
HttpResponse.json({ collections: [], count: 0, offset: 0, limit: 1 }),
|
|
138
|
+
),
|
|
139
|
+
http.get(`${BASE}/store/custom-attributes/category/:categoryId`, ({ request }) => {
|
|
140
|
+
fixture.customAttributeUrls.push(request.url)
|
|
141
|
+
return HttpResponse.json({ attributes, count: attributes.length })
|
|
142
|
+
}),
|
|
143
|
+
http.get(`${BASE}/store/search`, ({ request }) => {
|
|
144
|
+
fixture.searchUrls.push(request.url)
|
|
145
|
+
// Mirror the backend route contract (`src/api/store/search/route.ts`):
|
|
146
|
+
// `attributes` must be a JSON string of an ARRAY of `{ id, values }`.
|
|
147
|
+
// A JSON object (or any non-array) is rejected with a 500, exactly as
|
|
148
|
+
// the real backend does, so a wrong request shape cannot pass silently.
|
|
149
|
+
const rawAttributes = new URL(request.url).searchParams.get('attributes')
|
|
150
|
+
if (rawAttributes !== null) {
|
|
151
|
+
let parsed: unknown
|
|
152
|
+
try {
|
|
153
|
+
parsed = JSON.parse(rawAttributes)
|
|
154
|
+
} catch {
|
|
155
|
+
return HttpResponse.json(
|
|
156
|
+
{ message: 'Invalid attributes format. Expected JSON array.' },
|
|
157
|
+
{ status: 500 },
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
if (!Array.isArray(parsed)) {
|
|
161
|
+
return HttpResponse.json(
|
|
162
|
+
{ message: 'Invalid attributes format. Expected JSON array.' },
|
|
163
|
+
{ status: 500 },
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return HttpResponse.json(search.body ?? {}, { status: search.status })
|
|
168
|
+
}),
|
|
169
|
+
http.get(`${BASE}/store/products`, ({ request }) => {
|
|
170
|
+
fixture.productListUrls.push(request.url)
|
|
171
|
+
return HttpResponse.json({
|
|
172
|
+
products: [MEDUSA_PRODUCT],
|
|
173
|
+
count: 1,
|
|
174
|
+
offset: 0,
|
|
175
|
+
limit: 50,
|
|
176
|
+
})
|
|
177
|
+
}),
|
|
178
|
+
)
|
|
179
|
+
return fixture
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
describe('MedusaProvider.getCatalogFacetSupport — optional endpoint absent', () => {
|
|
183
|
+
it('holds price/inStock/attr and offers only the basic facets, proven by /store/products', async () => {
|
|
184
|
+
const fixture = installFixture({ status: 500 })
|
|
185
|
+
const support = await makeProvider().getCatalogFacetSupport({
|
|
186
|
+
query: { categoryScope: 'x' },
|
|
187
|
+
categoryIds: ['pcat_x'],
|
|
188
|
+
scope: 'x',
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// The optional endpoint was really probed (with the applied query + scope).
|
|
192
|
+
expect(fixture.searchUrls).toHaveLength(1)
|
|
193
|
+
const probe = new URL(fixture.searchUrls[0])
|
|
194
|
+
expect(getParamValues(fixture.searchUrls[0], 'categories')).toEqual([
|
|
195
|
+
'pcat_x',
|
|
196
|
+
])
|
|
197
|
+
expect(probe.searchParams.get('limit')).toBe('1')
|
|
198
|
+
|
|
199
|
+
// Basic facets are offered with a real /store/products demonstration.
|
|
200
|
+
expect(support.offered).toEqual(
|
|
201
|
+
expect.arrayContaining(['categories', 'collections']),
|
|
202
|
+
)
|
|
203
|
+
expect(support.demonstrations?.categories).toMatchObject({
|
|
204
|
+
endpoint: '/store/products',
|
|
205
|
+
scope: 'x',
|
|
206
|
+
})
|
|
207
|
+
expect(support.demonstrations?.categories?.appliedQuery).toMatchObject({
|
|
208
|
+
categoryScope: 'x',
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// No advanced facet is offered from a capability boolean.
|
|
212
|
+
expect(support.offered).not.toContain('price')
|
|
213
|
+
expect(support.offered).not.toContain('inStock')
|
|
214
|
+
|
|
215
|
+
// Held advanced facets carry the real probe reason, not a declaration.
|
|
216
|
+
expect(support.held).toEqual(expect.arrayContaining(['price', 'inStock']))
|
|
217
|
+
expect(support.heldReasons?.price).toContain('/store/search')
|
|
218
|
+
expect(support.heldReasons?.price).toContain('500')
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('never invents a demonstration timestamp', async () => {
|
|
222
|
+
installFixture({ status: 500 })
|
|
223
|
+
const support = await makeProvider().getCatalogFacetSupport({
|
|
224
|
+
query: { categoryScope: 'x' },
|
|
225
|
+
categoryIds: ['pcat_x'],
|
|
226
|
+
scope: 'x',
|
|
227
|
+
})
|
|
228
|
+
for (const source of Object.values(support.demonstrations ?? {})) {
|
|
229
|
+
expect(source).not.toHaveProperty('demonstratedAt')
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
describe('MedusaProvider.getCatalogProducts — optional endpoint absent', () => {
|
|
235
|
+
it('serves a basic-only request through /store/products with held advanced facets', async () => {
|
|
236
|
+
const fixture = installFixture({ status: 500 })
|
|
237
|
+
const outcome = await makeProvider().getCatalogProducts({
|
|
238
|
+
text: 'taladro',
|
|
239
|
+
categoryScope: 'x',
|
|
240
|
+
page: 1,
|
|
241
|
+
pageSize: 50,
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
expect(outcome.ok).toBe(true)
|
|
245
|
+
if (!outcome.ok) return
|
|
246
|
+
expect(fixture.productListUrls).toHaveLength(1)
|
|
247
|
+
expect(outcome.results.products).toHaveLength(1)
|
|
248
|
+
expect(outcome.results.facetSupport?.offered).toEqual(
|
|
249
|
+
expect.arrayContaining(['categories', 'collections']),
|
|
250
|
+
)
|
|
251
|
+
expect(outcome.results.facetSupport?.held).toEqual(
|
|
252
|
+
expect.arrayContaining(['price', 'inStock']),
|
|
253
|
+
)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('rejects an advanced request as a typed unsupported-query with no basic fallback', async () => {
|
|
257
|
+
const fixture = installFixture({ status: 500 })
|
|
258
|
+
const outcome = await makeProvider().getCatalogProducts({
|
|
259
|
+
priceMin: 10,
|
|
260
|
+
priceMax: 99,
|
|
261
|
+
page: 1,
|
|
262
|
+
pageSize: 50,
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
expect(outcome.ok).toBe(false)
|
|
266
|
+
if (outcome.ok) return
|
|
267
|
+
expect(outcome.error.code).toBe('unsupported-query')
|
|
268
|
+
expect(outcome.error.fields).toEqual(
|
|
269
|
+
expect.arrayContaining(['priceMin', 'priceMax']),
|
|
270
|
+
)
|
|
271
|
+
expect(outcome.error.repair.remove).toEqual(
|
|
272
|
+
expect.arrayContaining(['priceMin', 'priceMax']),
|
|
273
|
+
)
|
|
274
|
+
// The basic endpoint is never used to silently serve an advanced request.
|
|
275
|
+
expect(fixture.productListUrls).toHaveLength(0)
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
describe('MedusaProvider.getCatalogFacetSupport — optional endpoint present', () => {
|
|
280
|
+
it('offers price and inStock from the real applied_filters keys, holding attributes', async () => {
|
|
281
|
+
installFixture(
|
|
282
|
+
{
|
|
283
|
+
status: 200,
|
|
284
|
+
body: {
|
|
285
|
+
hits: [],
|
|
286
|
+
// The real backend emits `price_range` (object) and `is_available`
|
|
287
|
+
// (boolean), and never echoes `attributes`.
|
|
288
|
+
applied_filters: {
|
|
289
|
+
price_range: { min: 1, max: 100 },
|
|
290
|
+
is_available: true,
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
[FILTERABLE_ATTRIBUTE],
|
|
295
|
+
)
|
|
296
|
+
const support = await makeProvider().getCatalogFacetSupport({
|
|
297
|
+
query: { categoryScope: 'x' },
|
|
298
|
+
categoryIds: ['pcat_x'],
|
|
299
|
+
scope: 'x',
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
expect(support.offered).toEqual(
|
|
303
|
+
expect.arrayContaining(['categories', 'collections', 'price', 'inStock']),
|
|
304
|
+
)
|
|
305
|
+
expect(support.demonstrations?.price?.endpoint).toBe('/store/search')
|
|
306
|
+
expect(support.demonstrations?.inStock?.endpoint).toBe('/store/search')
|
|
307
|
+
expect(support.held).not.toContain('price')
|
|
308
|
+
expect(support.held).not.toContain('inStock')
|
|
309
|
+
|
|
310
|
+
// The advertised attribute facet is not echoed as applied, so it stays held
|
|
311
|
+
// with a reason instead of being fabricated (E2 D3).
|
|
312
|
+
expect(support.offered).not.toContain('attr.brand')
|
|
313
|
+
expect(support.held).toContain('attr.brand')
|
|
314
|
+
expect(support.heldReasons?.['attr.brand']).toContain('not echoed')
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('still recognizes the legacy price_min/price_max and isAvailable aliases', async () => {
|
|
318
|
+
installFixture(
|
|
319
|
+
{
|
|
320
|
+
status: 200,
|
|
321
|
+
body: {
|
|
322
|
+
hits: [],
|
|
323
|
+
applied_filters: {
|
|
324
|
+
price_min: 1,
|
|
325
|
+
price_max: 100,
|
|
326
|
+
isAvailable: true,
|
|
327
|
+
attributes: { brand: ['acme'] },
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
[FILTERABLE_ATTRIBUTE],
|
|
332
|
+
)
|
|
333
|
+
const support = await makeProvider().getCatalogFacetSupport({
|
|
334
|
+
query: { categoryScope: 'x' },
|
|
335
|
+
categoryIds: ['pcat_x'],
|
|
336
|
+
scope: 'x',
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
expect(support.offered).toEqual(
|
|
340
|
+
expect.arrayContaining(['categories', 'collections', 'price', 'inStock', 'attr.brand']),
|
|
341
|
+
)
|
|
342
|
+
expect(support.demonstrations?.price?.endpoint).toBe('/store/search')
|
|
343
|
+
expect(support.demonstrations?.['attr.brand']?.endpoint).toBe('/store/search')
|
|
344
|
+
expect(support.held).not.toContain('price')
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('records only the basic fields as applied by /store/products (never price/stock/attrs)', async () => {
|
|
348
|
+
installFixture({ status: 500 })
|
|
349
|
+
const support = await makeProvider().getCatalogFacetSupport({
|
|
350
|
+
query: { categoryScope: 'x', priceMin: 10, inStock: true },
|
|
351
|
+
categoryIds: ['pcat_x'],
|
|
352
|
+
scope: 'x',
|
|
353
|
+
})
|
|
354
|
+
const basicQuery = support.demonstrations?.categories?.appliedQuery
|
|
355
|
+
expect(basicQuery).toEqual({ categoryScope: 'x' })
|
|
356
|
+
expect(basicQuery).not.toHaveProperty('priceMin')
|
|
357
|
+
expect(basicQuery).not.toHaveProperty('inStock')
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('marks support unverified when the endpoint returns no applied-filter evidence', async () => {
|
|
361
|
+
installFixture({ status: 200, body: { hits: [] } })
|
|
362
|
+
const support = await makeProvider().getCatalogFacetSupport({
|
|
363
|
+
query: { categoryScope: 'x' },
|
|
364
|
+
categoryIds: ['pcat_x'],
|
|
365
|
+
scope: 'x',
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
expect(support.offered).not.toContain('price')
|
|
369
|
+
expect(support.offered).not.toContain('inStock')
|
|
370
|
+
expect(support.held).toEqual(expect.arrayContaining(['price', 'inStock']))
|
|
371
|
+
expect(support.heldReasons?.price).toContain('did not echo applied filters')
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
describe('MedusaProvider.getCatalogProducts — advanced endpoint executes', () => {
|
|
376
|
+
it('requests price and returns the advanced product set, with the basic endpoint never called', async () => {
|
|
377
|
+
const fixture = installFixture({
|
|
378
|
+
status: 200,
|
|
379
|
+
body: {
|
|
380
|
+
hits: [ADVANCED_PRODUCT],
|
|
381
|
+
count: 1,
|
|
382
|
+
applied_filters: { price_range: { min: 10, max: 99 } },
|
|
383
|
+
},
|
|
384
|
+
})
|
|
385
|
+
const outcome = await makeProvider().getCatalogProducts({
|
|
386
|
+
priceMin: 10,
|
|
387
|
+
priceMax: 99,
|
|
388
|
+
page: 1,
|
|
389
|
+
pageSize: 50,
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
expect(outcome.ok).toBe(true)
|
|
393
|
+
if (!outcome.ok) return
|
|
394
|
+
// The result comes from the advanced endpoint, not the basic one.
|
|
395
|
+
expect(outcome.results.products.map((p) => p.id)).toEqual(['prod_advanced'])
|
|
396
|
+
expect(outcome.results.total).toBe(1)
|
|
397
|
+
expect(outcome.results.appliedFilters).toMatchObject({
|
|
398
|
+
priceMin: 10,
|
|
399
|
+
priceMax: 99,
|
|
400
|
+
})
|
|
401
|
+
// The basic endpoint is never used to serve an advanced request.
|
|
402
|
+
expect(fixture.productListUrls).toHaveLength(0)
|
|
403
|
+
// The advanced endpoint received the requested values (the execution call,
|
|
404
|
+
// not just the support probe).
|
|
405
|
+
const execution = fixture.searchUrls.find(
|
|
406
|
+
(url) => new URL(url).searchParams.get('limit') === '50',
|
|
407
|
+
)
|
|
408
|
+
expect(execution).toBeDefined()
|
|
409
|
+
const params = new URL(execution as string).searchParams
|
|
410
|
+
expect(params.get('price_min')).toBe('10')
|
|
411
|
+
expect(params.get('price_max')).toBe('99')
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('resolves a thumbnail carried by an advanced hit to featuredImage', async () => {
|
|
415
|
+
installFixture({
|
|
416
|
+
status: 200,
|
|
417
|
+
body: {
|
|
418
|
+
hits: [ADVANCED_PRODUCT],
|
|
419
|
+
count: 1,
|
|
420
|
+
applied_filters: { price_range: { min: 10, max: 99 } },
|
|
421
|
+
},
|
|
422
|
+
})
|
|
423
|
+
const outcome = await makeProvider().getCatalogProducts({
|
|
424
|
+
priceMin: 10,
|
|
425
|
+
priceMax: 99,
|
|
426
|
+
page: 1,
|
|
427
|
+
pageSize: 50,
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
expect(outcome.ok).toBe(true)
|
|
431
|
+
if (!outcome.ok) return
|
|
432
|
+
const product = outcome.results.products[0]
|
|
433
|
+
expect(product.id).toBe('prod_advanced')
|
|
434
|
+
expect(product.featuredImage?.url).toBe('https://example.com/thumb.jpg')
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('requests an attribute and returns the advanced product set with the attribute forwarded', async () => {
|
|
438
|
+
const fixture = installFixture(
|
|
439
|
+
{
|
|
440
|
+
status: 200,
|
|
441
|
+
body: {
|
|
442
|
+
hits: [ADVANCED_PRODUCT],
|
|
443
|
+
count: 1,
|
|
444
|
+
applied_filters: { attributes: { brand: ['acme'] } },
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
[FILTERABLE_ATTRIBUTE],
|
|
448
|
+
)
|
|
449
|
+
const outcome = await makeProvider().getCatalogProducts({
|
|
450
|
+
categoryScope: 'x',
|
|
451
|
+
attributes: { brand: ['acme'] },
|
|
452
|
+
page: 1,
|
|
453
|
+
pageSize: 50,
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
expect(outcome.ok).toBe(true)
|
|
457
|
+
if (!outcome.ok) return
|
|
458
|
+
expect(outcome.results.products.map((p) => p.id)).toEqual(['prod_advanced'])
|
|
459
|
+
expect(outcome.results.appliedFilters.attributes).toEqual({ brand: ['acme'] })
|
|
460
|
+
expect(fixture.productListUrls).toHaveLength(0)
|
|
461
|
+
const execution = fixture.searchUrls.find(
|
|
462
|
+
(url) => new URL(url).searchParams.get('limit') === '50',
|
|
463
|
+
)
|
|
464
|
+
// The emitted request parameter is the backend's ARRAY shape
|
|
465
|
+
// (`Array<{ id, values }>`), not the canonical `Record` object.
|
|
466
|
+
const emitted = new URL(execution as string).searchParams.get('attributes')
|
|
467
|
+
expect(emitted).not.toBeNull()
|
|
468
|
+
const parsed = JSON.parse(emitted as string)
|
|
469
|
+
expect(Array.isArray(parsed)).toBe(true)
|
|
470
|
+
expect(parsed).toEqual([{ id: 'brand', values: ['acme'] }])
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
it('rejects when the executed response did not apply the requested filter (no partial success)', async () => {
|
|
474
|
+
const fixture = installFixture({ status: 200, body: {} })
|
|
475
|
+
// Probe (limit=1) echoes the filter, so support is offered; the execution
|
|
476
|
+
// (limit=50) does not apply it, so the result must be a typed rejection.
|
|
477
|
+
server.use(
|
|
478
|
+
http.get(`${BASE}/store/search`, ({ request }) => {
|
|
479
|
+
fixture.searchUrls.push(request.url)
|
|
480
|
+
const limit = new URL(request.url).searchParams.get('limit')
|
|
481
|
+
if (limit === '1') {
|
|
482
|
+
return HttpResponse.json({ hits: [], applied_filters: { price_range: { min: 10, max: 100 } } })
|
|
483
|
+
}
|
|
484
|
+
return HttpResponse.json({ hits: [ADVANCED_PRODUCT], applied_filters: {} })
|
|
485
|
+
}),
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
const outcome = await makeProvider().getCatalogProducts({
|
|
489
|
+
priceMin: 10,
|
|
490
|
+
page: 1,
|
|
491
|
+
pageSize: 50,
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
expect(outcome.ok).toBe(false)
|
|
495
|
+
if (outcome.ok) return
|
|
496
|
+
expect(outcome.error.code).toBe('unsupported-query')
|
|
497
|
+
expect(outcome.error.fields).toContain('priceMin')
|
|
498
|
+
expect(fixture.productListUrls).toHaveLength(0)
|
|
499
|
+
})
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
describe('MedusaProvider.getCatalogProducts — held advanced with empty scope', () => {
|
|
503
|
+
it('rejects a held advanced filter even when the category intersection is empty', async () => {
|
|
504
|
+
const fixture = installFixture({ status: 500 })
|
|
505
|
+
const outcome = await makeProvider().getCatalogProducts({
|
|
506
|
+
categoryScope: 'x',
|
|
507
|
+
categories: ['y'],
|
|
508
|
+
priceMin: 10,
|
|
509
|
+
page: 1,
|
|
510
|
+
pageSize: 50,
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
expect(outcome.ok).toBe(false)
|
|
514
|
+
if (outcome.ok) return
|
|
515
|
+
expect(outcome.error.code).toBe('unsupported-query')
|
|
516
|
+
expect(outcome.error.fields).toContain('priceMin')
|
|
517
|
+
expect(fixture.productListUrls).toHaveLength(0)
|
|
518
|
+
})
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
describe('MedusaProvider.searchProductsAdvanced — faithful fallback', () => {
|
|
522
|
+
it('rejects an advanced request instead of silently returning unfiltered products', async () => {
|
|
523
|
+
installFixture({ status: 404 })
|
|
524
|
+
await expect(
|
|
525
|
+
makeProvider().searchProductsAdvanced({ query: 'taladro', priceMin: 10 }),
|
|
526
|
+
).rejects.toMatchObject({
|
|
527
|
+
message: expect.stringContaining('priceMin'),
|
|
528
|
+
})
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
it('still serves a basic-only caller through the getProducts fallback', async () => {
|
|
532
|
+
const fixture = installFixture({ status: 404 })
|
|
533
|
+
const result = await makeProvider().searchProductsAdvanced({
|
|
534
|
+
query: 'taladro',
|
|
535
|
+
first: 5,
|
|
536
|
+
})
|
|
537
|
+
expect(fixture.productListUrls).toHaveLength(1)
|
|
538
|
+
expect(result.products).toHaveLength(1)
|
|
539
|
+
expect(result.meta?.hybridSearchUsed).toBe(false)
|
|
540
|
+
})
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
describe('MedusaProvider.getCatalogFacetSupport — HTTP 404 absence is surfaced', () => {
|
|
544
|
+
it('holds advanced facets with a reason naming the 404, never swallowing it', async () => {
|
|
545
|
+
installFixture({ status: 404 })
|
|
546
|
+
const support = await makeProvider().getCatalogFacetSupport({
|
|
547
|
+
query: { categoryScope: 'x' },
|
|
548
|
+
categoryIds: ['pcat_x'],
|
|
549
|
+
scope: 'x',
|
|
550
|
+
})
|
|
551
|
+
expect(support.offered).not.toContain('price')
|
|
552
|
+
expect(support.held).toEqual(expect.arrayContaining(['price', 'inStock']))
|
|
553
|
+
expect(support.heldReasons?.price).toContain('404')
|
|
554
|
+
})
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
// ---------------------------------------------------------------------------
|
|
558
|
+
// Live-gated: real Medusa + Meilisearch backend (E2 advanced facets).
|
|
559
|
+
//
|
|
560
|
+
// Mirrors the E2 gating pattern used by
|
|
561
|
+
// `packages/storefront/tests/category-filter-integration.contract.test.ts`:
|
|
562
|
+
// auto-live when the private fixture env exists, otherwise skipped cleanly. It
|
|
563
|
+
// only reads; it never seeds, resets, or writes to the backend.
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
// `yarn workspace @thorprovider/adapters test` runs with cwd at the package
|
|
567
|
+
// root (`packages/adapters`); `import.meta` is not permitted by the adapters
|
|
568
|
+
// tsconfig, so the repo root is derived from the process cwd.
|
|
569
|
+
const pkgRoot = process.cwd()
|
|
570
|
+
const repoRoot = resolve(pkgRoot, '..', '..')
|
|
571
|
+
|
|
572
|
+
function resolveFixtureEnvPath(): string {
|
|
573
|
+
const explicit = process.env.ELECTROSTORE_FIXTURE_ENV
|
|
574
|
+
if (explicit && existsSync(explicit)) return explicit
|
|
575
|
+
const acceptanceRoot =
|
|
576
|
+
process.env.ELECTROSTORE_ACCEPTANCE_ROOT ??
|
|
577
|
+
resolve(repoRoot, '..', '..', 'electrostore-mvp-acceptance')
|
|
578
|
+
return join(acceptanceRoot, 'extended-e2', 'task-7.1', 'fixture.env')
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function readEnvFile(path: string): Record<string, string> {
|
|
582
|
+
const out: Record<string, string> = {}
|
|
583
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
584
|
+
const trimmed = line.trim()
|
|
585
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
586
|
+
const eq = trimmed.indexOf('=')
|
|
587
|
+
if (eq === -1) continue
|
|
588
|
+
let value = trimmed.slice(eq + 1).trim()
|
|
589
|
+
if (
|
|
590
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
591
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
592
|
+
) {
|
|
593
|
+
value = value.slice(1, -1)
|
|
594
|
+
}
|
|
595
|
+
out[trimmed.slice(0, eq).trim()] = value
|
|
596
|
+
}
|
|
597
|
+
return out
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const fixtureEnvPath = resolveFixtureEnvPath()
|
|
601
|
+
|
|
602
|
+
describe.skipIf(process.env.RUN_MEDUSA_CATALOG_LIVE !== '1')(
|
|
603
|
+
'real Medusa + Meilisearch: advanced facets reflect backend truth',
|
|
604
|
+
() => {
|
|
605
|
+
beforeAll(() => {
|
|
606
|
+
if (!existsSync(fixtureEnvPath)) {
|
|
607
|
+
throw new Error(
|
|
608
|
+
`RUN_MEDUSA_CATALOG_LIVE=1 but the fixture env file is missing or unreadable: ${fixtureEnvPath}`,
|
|
609
|
+
)
|
|
610
|
+
}
|
|
611
|
+
let env: Record<string, string>
|
|
612
|
+
try {
|
|
613
|
+
env = readEnvFile(fixtureEnvPath)
|
|
614
|
+
} catch {
|
|
615
|
+
throw new Error(
|
|
616
|
+
`RUN_MEDUSA_CATALOG_LIVE=1 but the fixture env file is unreadable: ${fixtureEnvPath}`,
|
|
617
|
+
)
|
|
618
|
+
}
|
|
619
|
+
for (const name of ['MEDUSA_BASE_URL', 'MEDUSA_PUBLISHABLE_KEY', 'MEDUSA_REGION_ID']) {
|
|
620
|
+
if (!env[name]) {
|
|
621
|
+
throw new Error(
|
|
622
|
+
`RUN_MEDUSA_CATALOG_LIVE=1 but the fixture is missing required name: ${name}`,
|
|
623
|
+
)
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
})
|
|
627
|
+
it('price narrows the result set and echoes price_range; inStock offered; attributes held', async () => {
|
|
628
|
+
// Read the private fixture env inside the test so the skipped suite never
|
|
629
|
+
// touches the file (mirrors the E2 gating pattern).
|
|
630
|
+
const env = readEnvFile(fixtureEnvPath)
|
|
631
|
+
const baseUrl = (env.MEDUSA_BASE_URL ?? '').replace(/\/$/, '')
|
|
632
|
+
|
|
633
|
+
const provider = new MedusaProvider({
|
|
634
|
+
baseUrl,
|
|
635
|
+
publishableKey: env.MEDUSA_PUBLISHABLE_KEY,
|
|
636
|
+
currencyCode: env.MEDUSA_CURRENCY_CODE ?? 'usd',
|
|
637
|
+
regionId: env.MEDUSA_REGION_ID,
|
|
638
|
+
debug: false,
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
const searchRaw = async (query: string): Promise<Record<string, any>> => {
|
|
642
|
+
const response = await fetch(`${baseUrl}/store/search?${query}`, {
|
|
643
|
+
headers: { 'x-publishable-api-key': env.MEDUSA_PUBLISHABLE_KEY },
|
|
644
|
+
}).catch((cause) => {
|
|
645
|
+
throw new Error(`live backend unreachable at ${baseUrl}: ${String(cause)}`)
|
|
646
|
+
})
|
|
647
|
+
expect(response.status).toBe(200)
|
|
648
|
+
return (await response.json()) as Record<string, any>
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Let the real backend through msw (the suite intercepts with
|
|
652
|
+
// `onUnhandledRequest: 'error'`); no fixture handler is installed here.
|
|
653
|
+
server.use(http.all(`${baseUrl}/*`, () => passthrough()))
|
|
654
|
+
|
|
655
|
+
// Raw evidence: baseline vs price request, and the echoed applied_filters.
|
|
656
|
+
const baseline = await searchRaw('limit=100')
|
|
657
|
+
const priced = await searchRaw('limit=100&price_min=5&price_max=20')
|
|
658
|
+
expect(baseline.count).toBe(100)
|
|
659
|
+
expect(priced.count).toBe(32)
|
|
660
|
+
expect(priced.count).toBeLessThan(baseline.count)
|
|
661
|
+
expect(priced.applied_filters?.price_range).toEqual({ min: 5, max: 20 })
|
|
662
|
+
// The backend never echoes `attributes`; no attribute facet can be proven.
|
|
663
|
+
expect(
|
|
664
|
+
Object.prototype.hasOwnProperty.call(
|
|
665
|
+
priced.applied_filters ?? {},
|
|
666
|
+
'attributes',
|
|
667
|
+
),
|
|
668
|
+
).toBe(false)
|
|
669
|
+
|
|
670
|
+
// `is_available` is echoed when requested.
|
|
671
|
+
const available = await searchRaw('limit=1&isAvailable=true')
|
|
672
|
+
expect(available.applied_filters?.is_available).toBe(true)
|
|
673
|
+
|
|
674
|
+
// The adapter executes the price request and returns a narrowed, non-empty
|
|
675
|
+
// set (pageSize 50; the backend's hit count at limit=50 is 29 while the
|
|
676
|
+
// canonical limit=100 probe above is 32, a Meilisearch pagination quirk).
|
|
677
|
+
const outcome = await provider.getCatalogProducts({
|
|
678
|
+
priceMin: 5,
|
|
679
|
+
priceMax: 20,
|
|
680
|
+
page: 1,
|
|
681
|
+
pageSize: 50,
|
|
682
|
+
})
|
|
683
|
+
expect(outcome.ok).toBe(true)
|
|
684
|
+
if (!outcome.ok) return
|
|
685
|
+
expect(outcome.results.products.length).toBeGreaterThan(0)
|
|
686
|
+
expect(outcome.results.products.length).toBeLessThan(50)
|
|
687
|
+
|
|
688
|
+
// The support probe offers price + inStock from the real keys and holds
|
|
689
|
+
// attributes (no `attr.*` is offered because the response does not echo it).
|
|
690
|
+
const support = await provider.getCatalogFacetSupport({
|
|
691
|
+
query: { priceMin: 5, priceMax: 20, inStock: true },
|
|
692
|
+
})
|
|
693
|
+
expect(support.offered).toEqual(
|
|
694
|
+
expect.arrayContaining(['price', 'inStock']),
|
|
695
|
+
)
|
|
696
|
+
expect(support.held).not.toContain('price')
|
|
697
|
+
expect(support.held).not.toContain('inStock')
|
|
698
|
+
expect(support.offered.some((facet) => facet.startsWith('attr.'))).toBe(
|
|
699
|
+
false,
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
// An attribute request is never silently dropped: the backend does not
|
|
703
|
+
// demonstrate it, so the adapter rejects it as unsupported-query.
|
|
704
|
+
const attributeOutcome = await provider.getCatalogProducts({
|
|
705
|
+
attributes: { brand: ['acme'] },
|
|
706
|
+
page: 1,
|
|
707
|
+
pageSize: 50,
|
|
708
|
+
})
|
|
709
|
+
expect(attributeOutcome.ok).toBe(false)
|
|
710
|
+
if (attributeOutcome.ok) return
|
|
711
|
+
expect(attributeOutcome.error.code).toBe('unsupported-query')
|
|
712
|
+
expect(attributeOutcome.error.fields).toContain('attr.brand')
|
|
713
|
+
})
|
|
714
|
+
},
|
|
715
|
+
)
|