@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.
Files changed (37) hide show
  1. package/AGENTS.md +1 -1
  2. package/dist/index.d.mts +376 -16
  3. package/dist/index.d.ts +376 -16
  4. package/dist/index.js +1687 -528
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +1680 -530
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +3 -2
  9. package/src/core/errors.ts +31 -0
  10. package/src/factory/index.ts +16 -6
  11. package/src/factory/provider-types.test.ts +32 -0
  12. package/src/factory/provider-types.ts +176 -0
  13. package/src/index.ts +6 -4
  14. package/src/providers/index.ts +1 -1
  15. package/src/providers/medusa/auth.test.ts +148 -25
  16. package/src/providers/medusa/auth.ts +129 -16
  17. package/src/providers/medusa/available-filters.test.ts +280 -0
  18. package/src/providers/medusa/catalog-facet-support.test.ts +715 -0
  19. package/src/providers/medusa/catalog-query.test.ts +422 -0
  20. package/src/providers/medusa/catalog-sort.test.ts +175 -0
  21. package/src/providers/medusa/customer-orders.test.ts +142 -0
  22. package/src/providers/medusa/index.test.ts +480 -0
  23. package/src/providers/medusa/index.ts +1592 -538
  24. package/src/providers/medusa/live-smoke.test.ts +68 -0
  25. package/src/providers/medusa/transforms.test.ts +222 -0
  26. package/src/providers/medusa/transforms.ts +152 -66
  27. package/src/providers/mock/catalog-query.test.ts +491 -0
  28. package/src/providers/mock/index.test.ts +66 -0
  29. package/src/providers/mock/index.ts +43 -1
  30. package/src/providers/mock/mock.ts +513 -3
  31. package/src/types/completion.ts +57 -0
  32. package/src/types/index.ts +7 -0
  33. package/.turbo/turbo-build.log +0 -18
  34. package/.turbo/turbo-test.log +0 -372
  35. package/.turbo/turbo-type-check.log +0 -0
  36. package/dist/.tsbuildinfo +0 -1
  37. 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: Fetch customer data
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
- // The SDK auto-stores the registration JWT token for subsequent requests.
208
- await this.sdk.auth.register('customer', 'emailpass', {
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: Create customer profile
214
- // The registration token is automatically included in the header by the SDK.
215
- const { customer } = await this.sdk.store.customer.create({
216
- email: data.email,
217
- first_name: data.first_name,
218
- last_name: data.last_name,
219
- phone: data.phone,
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 3: Login to obtain the actual session/token
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 4: Store actual auth token based on auth method
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: transformCustomer(customer, customer.email),
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
+ })