@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
|
@@ -179,7 +179,13 @@ export class MedusaAuthProvider implements AuthProvider {
|
|
|
179
179
|
// Note: For session-based auth, the SDK automatically handles cookies
|
|
180
180
|
// when auth.login() is called with auth.type='session' in SDK config
|
|
181
181
|
|
|
182
|
-
// Step 3:
|
|
182
|
+
// Step 3: Link the customer to the publishable-key sales channel
|
|
183
|
+
// (idempotent). The order-history route is channel-scoped, so login must
|
|
184
|
+
// ensure the customer's channel link before any history read; a route that
|
|
185
|
+
// is unavailable is surfaced as a typed error rather than silently skipped.
|
|
186
|
+
await this.linkCustomerChannel();
|
|
187
|
+
|
|
188
|
+
// Step 4: Fetch customer data
|
|
183
189
|
const customer = await this.getCurrentCustomer();
|
|
184
190
|
|
|
185
191
|
this.logger.info('Login successful', { customerId: customer?.id });
|
|
@@ -189,6 +195,8 @@ export class MedusaAuthProvider implements AuthProvider {
|
|
|
189
195
|
customer: customer ?? undefined,
|
|
190
196
|
};
|
|
191
197
|
} catch (error: any) {
|
|
198
|
+
// A typed channel-account failure is truthful and must not be masked.
|
|
199
|
+
if (error instanceof ProviderAPIError) throw error;
|
|
192
200
|
this.logger.error('Login failed', error);
|
|
193
201
|
throw new ProviderAPIError(
|
|
194
202
|
`Authentication failed: ${error.message}`,
|
|
@@ -202,24 +210,29 @@ export class MedusaAuthProvider implements AuthProvider {
|
|
|
202
210
|
this.logger.debug('register', { email: data.email });
|
|
203
211
|
|
|
204
212
|
try {
|
|
205
|
-
// Step 1: Register auth identity with Medusa
|
|
206
|
-
// v2.16.0: register no longer accepts returnVerification option.
|
|
207
|
-
//
|
|
208
|
-
|
|
213
|
+
// Step 1: Register auth identity with Medusa.
|
|
214
|
+
// v2.16.0: register no longer accepts returnVerification option. It
|
|
215
|
+
// returns the registration JWT token and writes it into the SDK's
|
|
216
|
+
// request-scoped token storage (the caller's mutable custom storage).
|
|
217
|
+
const registrationToken = await this.sdk.auth.register('customer', 'emailpass', {
|
|
209
218
|
email: data.email,
|
|
210
219
|
password: data.password,
|
|
211
220
|
});
|
|
212
221
|
|
|
213
|
-
// Step 2:
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
222
|
+
// Step 2: Persist the registration token explicitly through the
|
|
223
|
+
// request-scoped MUTABLE custom storage so the customer-linking call
|
|
224
|
+
// carries it. The storage's `setItem` genuinely mutates the per-request
|
|
225
|
+
// holder (it is not a no-op), so the linking call is authenticated.
|
|
226
|
+
if (this.authConfig.method === 'jwt' && typeof registrationToken === 'string') {
|
|
227
|
+
await this.storageManager.setToken(registrationToken);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Step 3: Create + link the customer to the publishable-key sales channel
|
|
231
|
+
// through the channel-linking route. The native `POST /store/customers`
|
|
232
|
+
// is NOT used: it does not link a channel and would mis-scope history.
|
|
233
|
+
const linked = await this.createChannelLinkedCustomer(data);
|
|
221
234
|
|
|
222
|
-
// Step
|
|
235
|
+
// Step 4: Login to obtain the actual session/token.
|
|
223
236
|
// v2.16.0: verification status is now returned by login, not register.
|
|
224
237
|
const loginResult = await this.sdk.auth.login('customer', 'emailpass', {
|
|
225
238
|
email: data.email,
|
|
@@ -250,19 +263,31 @@ export class MedusaAuthProvider implements AuthProvider {
|
|
|
250
263
|
|
|
251
264
|
const token = loginResult;
|
|
252
265
|
|
|
253
|
-
// Step
|
|
266
|
+
// Step 5: Store actual auth token based on auth method
|
|
254
267
|
if (this.authConfig.method === 'jwt') {
|
|
255
268
|
await this.storageManager.setToken(token);
|
|
256
269
|
}
|
|
257
270
|
// Note: For session-based auth, the SDK automatically handles cookies
|
|
258
271
|
|
|
272
|
+
// Step 6: An auth identity alone is NOT a complete customer. Resolve the
|
|
273
|
+
// linked customer and require it to be the one just created/linked.
|
|
274
|
+
const customer = await this.getCurrentCustomer();
|
|
275
|
+
if (!customer || customer.id !== linked.id) {
|
|
276
|
+
throw new ProviderAPIError(
|
|
277
|
+
'Registration created an auth identity without a linked customer.',
|
|
278
|
+
'CUSTOMER_LINK_INCOMPLETE',
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
259
282
|
this.logger.info('Registration successful', { customerId: customer.id });
|
|
260
283
|
|
|
261
284
|
return {
|
|
262
285
|
token: this.authConfig.method === 'jwt' ? token : undefined,
|
|
263
|
-
customer
|
|
286
|
+
customer,
|
|
264
287
|
};
|
|
265
288
|
} catch (error: any) {
|
|
289
|
+
// A typed channel-account failure is truthful and must not be masked.
|
|
290
|
+
if (error instanceof ProviderAPIError) throw error;
|
|
266
291
|
this.logger.error('Registration failed', error);
|
|
267
292
|
throw new ProviderAPIError(
|
|
268
293
|
`Registration failed: ${error.message}`,
|
|
@@ -271,6 +296,94 @@ export class MedusaAuthProvider implements AuthProvider {
|
|
|
271
296
|
);
|
|
272
297
|
}
|
|
273
298
|
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Create and channel-link a customer through `POST /store/thor/customers`.
|
|
302
|
+
*
|
|
303
|
+
* The request carries the registration token (read by the SDK from the
|
|
304
|
+
* request-scoped custom storage) and the publishable key; the backend links
|
|
305
|
+
* the customer to the publishable-key sales channel. A route that is
|
|
306
|
+
* unavailable fails closed with a typed error — there is no silent native
|
|
307
|
+
* fallback that would leave the customer unlinked.
|
|
308
|
+
*/
|
|
309
|
+
private async createChannelLinkedCustomer(data: RegisterData): Promise<{ id: string }> {
|
|
310
|
+
const response = await this.fetchChannelAccountRoute<{ customer?: { id?: string } }>(
|
|
311
|
+
'/store/thor/customers',
|
|
312
|
+
{
|
|
313
|
+
method: 'POST',
|
|
314
|
+
body: {
|
|
315
|
+
email: data.email,
|
|
316
|
+
first_name: data.first_name,
|
|
317
|
+
last_name: data.last_name,
|
|
318
|
+
phone: data.phone,
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
const id = response?.customer?.id;
|
|
323
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
324
|
+
throw new ProviderAPIError(
|
|
325
|
+
'Channel-linked customer creation returned no customer id.',
|
|
326
|
+
'CUSTOMER_LINK_INCOMPLETE',
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
return { id };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Link the authenticated customer to the publishable-key sales channel
|
|
334
|
+
* through `POST /store/thor/customers/me/channels` (idempotent). Called after
|
|
335
|
+
* login so history is channel-scoped for the current storefront.
|
|
336
|
+
*/
|
|
337
|
+
private async linkCustomerChannel(): Promise<void> {
|
|
338
|
+
const response = await this.fetchChannelAccountRoute<{
|
|
339
|
+
linked?: boolean;
|
|
340
|
+
reason?: string;
|
|
341
|
+
}>('/store/thor/customers/me/channels', { method: 'POST' });
|
|
342
|
+
|
|
343
|
+
// `linked:true` means the link was created now; `linked:false` is only valid
|
|
344
|
+
// when the link already existed. Any other shape is an incomplete link.
|
|
345
|
+
if (response?.linked === false && response?.reason !== 'already_linked') {
|
|
346
|
+
throw new ProviderAPIError(
|
|
347
|
+
'Customer channel link did not complete.',
|
|
348
|
+
'CUSTOMER_LINK_INCOMPLETE',
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Call a Thor channel account route through the SDK client, which attaches
|
|
355
|
+
* the request-scoped customer/registration token and the publishable key. A
|
|
356
|
+
* missing client or an unavailable route (404/405/501) fails closed with a
|
|
357
|
+
* typed `CUSTOMER_LINK_UNAVAILABLE` error.
|
|
358
|
+
*/
|
|
359
|
+
private async fetchChannelAccountRoute<T>(
|
|
360
|
+
path: string,
|
|
361
|
+
init: { method: 'GET' | 'POST'; body?: unknown; query?: Record<string, unknown> },
|
|
362
|
+
): Promise<T> {
|
|
363
|
+
const client = (this.sdk as unknown as { client?: { fetch?: unknown } }).client;
|
|
364
|
+
if (!client || typeof client.fetch !== 'function') {
|
|
365
|
+
throw new ProviderAPIError(
|
|
366
|
+
`The channel account route ${path} is unavailable in this SDK.`,
|
|
367
|
+
'CUSTOMER_LINK_UNAVAILABLE',
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
return await (
|
|
372
|
+
client.fetch as (p: string, i: typeof init) => Promise<T>
|
|
373
|
+
)(path, init);
|
|
374
|
+
} catch (error: any) {
|
|
375
|
+
const status = error?.status || error?.statusCode;
|
|
376
|
+
if (status === 404 || status === 405 || status === 501) {
|
|
377
|
+
throw new ProviderAPIError(
|
|
378
|
+
`The channel account route ${path} is unavailable (status ${status}).`,
|
|
379
|
+
'CUSTOMER_LINK_UNAVAILABLE',
|
|
380
|
+
status,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
throw error;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
274
387
|
|
|
275
388
|
async logout(): Promise<void> {
|
|
276
389
|
this.logger.debug('logout');
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter facet truthfulness — E2 `storefront-catalog-search-filters` task 2.3.
|
|
3
|
+
*
|
|
4
|
+
* `getAvailableFilters` must never fabricate a numeric range or a per-option
|
|
5
|
+
* facet count. Medusa's Store `/store/products` returns matching products and a
|
|
6
|
+
* total count, not filterable values or their ranges, so no price range is read
|
|
7
|
+
* here and the control is omitted rather than defaulted to `0..10000`. A custom
|
|
8
|
+
* attribute range/count is emitted only when the backend actually supplies the
|
|
9
|
+
* finite metadata; a missing bound or count is omitted, never replaced by a
|
|
10
|
+
* `0`/`100` default or a synthesized `0`.
|
|
11
|
+
*
|
|
12
|
+
* The HTTP boundary is intercepted with msw against a controlled fixture, so
|
|
13
|
+
* the exact emitted filter shapes are asserted.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it } from 'vitest'
|
|
16
|
+
import { http, HttpResponse } from 'msw'
|
|
17
|
+
import { server } from '../stelorder/client.test-helpers'
|
|
18
|
+
import { MedusaProvider } from './index'
|
|
19
|
+
|
|
20
|
+
const BASE = 'https://medusa.example.com'
|
|
21
|
+
const PUB_KEY = 'pk_test_abc123'
|
|
22
|
+
const CURRENCY = 'usd'
|
|
23
|
+
|
|
24
|
+
const CATEGORIES = [
|
|
25
|
+
{
|
|
26
|
+
id: 'pcat_x',
|
|
27
|
+
name: 'Category X',
|
|
28
|
+
handle: 'x',
|
|
29
|
+
description: null,
|
|
30
|
+
parent_category_id: null,
|
|
31
|
+
product_count: 3,
|
|
32
|
+
metadata: null,
|
|
33
|
+
},
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
const COLLECTIONS = [
|
|
37
|
+
{ id: 'pcol_c1', handle: 'c1', title: 'Collection One', metadata: null },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
/** A `multi-select` attribute whose values carry real backend counts. */
|
|
41
|
+
const MULTI_WITH_COUNTS = {
|
|
42
|
+
attribute_id: 'attr_ram',
|
|
43
|
+
attribute: {
|
|
44
|
+
name: 'RAM',
|
|
45
|
+
handle: 'ram',
|
|
46
|
+
type: 'multi-select',
|
|
47
|
+
is_filterable: true,
|
|
48
|
+
is_active: true,
|
|
49
|
+
ranking: 40,
|
|
50
|
+
values: [
|
|
51
|
+
{ id: 'val_8', value: '8GB', count: 15 },
|
|
52
|
+
{ id: 'val_16', value: '16GB', count: 23 },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A `multi-select` attribute whose values carry no counts. */
|
|
58
|
+
const MULTI_WITHOUT_COUNTS = {
|
|
59
|
+
attribute_id: 'attr_ram',
|
|
60
|
+
attribute: {
|
|
61
|
+
...MULTI_WITH_COUNTS.attribute,
|
|
62
|
+
values: [
|
|
63
|
+
{ id: 'val_8', value: '8GB' },
|
|
64
|
+
{ id: 'val_16', value: '16GB' },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A `number` attribute with a real backend range. */
|
|
70
|
+
const RANGE_WITH_BOUNDS = {
|
|
71
|
+
attribute_id: 'attr_price',
|
|
72
|
+
attribute: {
|
|
73
|
+
name: 'Price',
|
|
74
|
+
handle: 'price-attr',
|
|
75
|
+
type: 'number',
|
|
76
|
+
is_filterable: true,
|
|
77
|
+
is_active: true,
|
|
78
|
+
ranking: 30,
|
|
79
|
+
metadata: { min: 5, max: 250 },
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A `number` attribute with metadata but no real bounds. */
|
|
84
|
+
const RANGE_WITHOUT_BOUNDS = {
|
|
85
|
+
attribute_id: 'attr_price',
|
|
86
|
+
attribute: {
|
|
87
|
+
...RANGE_WITH_BOUNDS.attribute,
|
|
88
|
+
metadata: {},
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function makeProvider() {
|
|
93
|
+
return new MedusaProvider({
|
|
94
|
+
baseUrl: BASE,
|
|
95
|
+
publishableKey: PUB_KEY,
|
|
96
|
+
currencyCode: CURRENCY,
|
|
97
|
+
debug: false,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface Fixture {
|
|
102
|
+
/** Every `/store/custom-attributes/category/:id` request URL. */
|
|
103
|
+
customAttributeUrls: string[]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function installFixture(
|
|
107
|
+
attributes: Array<Record<string, unknown>> = [],
|
|
108
|
+
): Fixture {
|
|
109
|
+
const fixture: Fixture = { customAttributeUrls: [] }
|
|
110
|
+
server.use(
|
|
111
|
+
// Thor plugin endpoints are absent; the static capability fallback applies
|
|
112
|
+
// (advancedSearch/customAttributes true) and channel scoping stays unscoped.
|
|
113
|
+
http.get(`${BASE}/store/thor/capabilities`, () =>
|
|
114
|
+
HttpResponse.json({}, { status: 404 }),
|
|
115
|
+
),
|
|
116
|
+
http.get(`${BASE}/store/thor/categories`, () =>
|
|
117
|
+
HttpResponse.json({}, { status: 404 }),
|
|
118
|
+
),
|
|
119
|
+
http.get(`${BASE}/store/thor/collections`, () =>
|
|
120
|
+
HttpResponse.json({}, { status: 404 }),
|
|
121
|
+
),
|
|
122
|
+
// The optional advanced endpoint is absent: the facet-support probe must
|
|
123
|
+
// treat this as "not demonstrated" rather than reading a capability flag.
|
|
124
|
+
http.get(`${BASE}/store/search`, () =>
|
|
125
|
+
HttpResponse.json(
|
|
126
|
+
{ error: 'Failed to search products', message: 'search unavailable' },
|
|
127
|
+
{ status: 500 },
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
http.get(`${BASE}/store/product-categories`, () =>
|
|
131
|
+
HttpResponse.json({
|
|
132
|
+
product_categories: CATEGORIES,
|
|
133
|
+
count: CATEGORIES.length,
|
|
134
|
+
offset: 0,
|
|
135
|
+
limit: 50,
|
|
136
|
+
}),
|
|
137
|
+
),
|
|
138
|
+
http.get(`${BASE}/store/collections`, () =>
|
|
139
|
+
HttpResponse.json({
|
|
140
|
+
collections: COLLECTIONS,
|
|
141
|
+
count: COLLECTIONS.length,
|
|
142
|
+
offset: 0,
|
|
143
|
+
limit: 100,
|
|
144
|
+
}),
|
|
145
|
+
),
|
|
146
|
+
http.get(
|
|
147
|
+
`${BASE}/store/custom-attributes/category/:categoryId`,
|
|
148
|
+
({ request }) => {
|
|
149
|
+
fixture.customAttributeUrls.push(request.url)
|
|
150
|
+
return HttpResponse.json({ attributes })
|
|
151
|
+
},
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
return fixture
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
describe('MedusaProvider.getAvailableFilters — facet truthfulness', () => {
|
|
158
|
+
it('emits no price range when the backend supplies no price metadata (never 0..10000)', async () => {
|
|
159
|
+
installFixture()
|
|
160
|
+
const filters = await makeProvider().getAvailableFilters({
|
|
161
|
+
useAdvancedSearch: true,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// The call succeeded against the real backend: basic filters are present.
|
|
165
|
+
expect(filters.map((f) => f.id)).toContain('categories')
|
|
166
|
+
// No hardcoded price filter and no synthetic range anywhere.
|
|
167
|
+
expect(filters.find((f) => f.id === 'price')).toBeUndefined()
|
|
168
|
+
expect(
|
|
169
|
+
filters.some((f) => f.type === 'range' && f.min === 0 && f.max === 10000),
|
|
170
|
+
).toBe(false)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('does not offer the stock toggle when the optional endpoint is absent (a capability boolean is not proof)', async () => {
|
|
174
|
+
installFixture()
|
|
175
|
+
const filters = await makeProvider().getAvailableFilters({
|
|
176
|
+
useAdvancedSearch: true,
|
|
177
|
+
})
|
|
178
|
+
// `advancedSearch: true` is advertised by the backend, but the real
|
|
179
|
+
// `/store/search` endpoint returns 500, so `inStock` is held and no toggle
|
|
180
|
+
// is offered (E2 task 2.4).
|
|
181
|
+
expect(filters.find((f) => f.id === 'isAvailable')).toBeUndefined()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('offers the stock toggle only when the optional endpoint demonstrates inStock', async () => {
|
|
185
|
+
installFixture()
|
|
186
|
+
server.use(
|
|
187
|
+
http.get(`${BASE}/store/search`, () =>
|
|
188
|
+
HttpResponse.json({ hits: [], applied_filters: { isAvailable: true } }),
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
const filters = await makeProvider().getAvailableFilters({
|
|
192
|
+
useAdvancedSearch: true,
|
|
193
|
+
})
|
|
194
|
+
expect(filters.find((f) => f.id === 'isAvailable')).toMatchObject({
|
|
195
|
+
type: 'boolean',
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('returns basic category/collection options with no synthesized counts', async () => {
|
|
200
|
+
installFixture()
|
|
201
|
+
const filters = await makeProvider().getAvailableFilters({})
|
|
202
|
+
|
|
203
|
+
const categories = filters.find((f) => f.id === 'categories')
|
|
204
|
+
expect(categories?.options).toEqual([
|
|
205
|
+
{ value: 'x', label: 'Category X', id: 'pcat_x' },
|
|
206
|
+
])
|
|
207
|
+
expect(categories?.options?.[0]).not.toHaveProperty('count')
|
|
208
|
+
|
|
209
|
+
const collections = filters.find((f) => f.id === 'collections')
|
|
210
|
+
expect(collections?.options?.[0]).not.toHaveProperty('count')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('omits attribute counts when the backend supplies none', async () => {
|
|
214
|
+
installFixture([MULTI_WITHOUT_COUNTS])
|
|
215
|
+
const filters = await makeProvider().getAvailableFilters({
|
|
216
|
+
categoryId: 'pcat_x',
|
|
217
|
+
includeBasicFilters: false,
|
|
218
|
+
includeFacetCounts: true,
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
const ram = filters.find((f) => f.id === 'ram')
|
|
222
|
+
expect(ram?.options).toHaveLength(2)
|
|
223
|
+
expect(ram?.options?.[0]).not.toHaveProperty('count')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('emits attribute counts only from real backend-supplied numbers', async () => {
|
|
227
|
+
installFixture([MULTI_WITH_COUNTS])
|
|
228
|
+
const filters = await makeProvider().getAvailableFilters({
|
|
229
|
+
categoryId: 'pcat_x',
|
|
230
|
+
includeBasicFilters: false,
|
|
231
|
+
includeFacetCounts: true,
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const ram = filters.find((f) => f.id === 'ram')
|
|
235
|
+
expect(ram?.options).toEqual([
|
|
236
|
+
{ value: '8gb', label: '8GB', id: 'val_8', count: 15 },
|
|
237
|
+
{ value: '16gb', label: '16GB', id: 'val_16', count: 23 },
|
|
238
|
+
])
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('omits attribute counts when the caller did not request them', async () => {
|
|
242
|
+
installFixture([MULTI_WITH_COUNTS])
|
|
243
|
+
const filters = await makeProvider().getAvailableFilters({
|
|
244
|
+
categoryId: 'pcat_x',
|
|
245
|
+
includeBasicFilters: false,
|
|
246
|
+
includeFacetCounts: false,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
const ram = filters.find((f) => f.id === 'ram')
|
|
250
|
+
expect(ram?.options?.[0]).not.toHaveProperty('count')
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('emits an attribute range only from real backend bounds', async () => {
|
|
254
|
+
installFixture([RANGE_WITH_BOUNDS])
|
|
255
|
+
const filters = await makeProvider().getAvailableFilters({
|
|
256
|
+
categoryId: 'pcat_x',
|
|
257
|
+
includeBasicFilters: false,
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
expect(filters.find((f) => f.id === 'price-attr')).toMatchObject({
|
|
261
|
+
type: 'range',
|
|
262
|
+
min: 5,
|
|
263
|
+
max: 250,
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('does not fabricate a 0/100 range when metadata bounds are missing', async () => {
|
|
268
|
+
installFixture([RANGE_WITHOUT_BOUNDS])
|
|
269
|
+
const filters = await makeProvider().getAvailableFilters({
|
|
270
|
+
categoryId: 'pcat_x',
|
|
271
|
+
includeBasicFilters: false,
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
// No fabricated range control at all.
|
|
275
|
+
expect(filters.find((f) => f.id === 'price-attr')).toBeUndefined()
|
|
276
|
+
expect(
|
|
277
|
+
filters.some((f) => f.type === 'range' && f.min === 0 && f.max === 100),
|
|
278
|
+
).toBe(false)
|
|
279
|
+
})
|
|
280
|
+
})
|