@thorprovider/adapters 5.0.11 → 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/dist/index.d.mts +29 -6
- package/dist/index.d.ts +29 -6
- package/dist/index.js +294 -32
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +294 -32
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/providers/medusa/auth.test.ts +148 -25
- package/src/providers/medusa/auth.ts +129 -16
- package/src/providers/medusa/customer-orders.test.ts +142 -0
- package/src/providers/medusa/index.ts +108 -26
- package/src/providers/medusa/transforms.test.ts +45 -0
- package/src/providers/medusa/transforms.ts +7 -0
- package/src/providers/mock/catalog-query.test.ts +153 -17
- package/src/providers/mock/index.test.ts +19 -0
- package/src/providers/mock/mock.ts +189 -1
- package/.turbo/turbo-build.log +0 -18
- package/.turbo/turbo-test.log +0 -897
- package/.turbo/turbo-type-check.log +0 -0
- package/dist/.tsbuildinfo +0 -1
- package/publish.log +0 -93
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thorprovider/adapters",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.12",
|
|
4
4
|
"description": "Framework-agnostic commerce adapters for Thor Commerce ecosystem - Multi-platform support with unified API",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"registry": "https://registry.npmjs.org",
|
|
@@ -45,4 +45,4 @@
|
|
|
45
45
|
"directory": "packages/adapters"
|
|
46
46
|
},
|
|
47
47
|
"license": "MIT"
|
|
48
|
-
}
|
|
48
|
+
}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Tests for MedusaAuthProvider
|
|
3
|
+
*
|
|
4
|
+
* Covers the channel-linked registration/login workflow
|
|
5
|
+
* (change: storefront-live-auth-orders-acceptance, task 2.4):
|
|
6
|
+
* - the registration token is persisted in the request-scoped MUTABLE custom
|
|
7
|
+
* storage before the customer-linking call, and that call carries it;
|
|
8
|
+
* - an auth identity without a linked customer is not treated as complete;
|
|
9
|
+
* - the channel-linking route being unavailable fails closed with a typed error;
|
|
10
|
+
* - login links the customer's channel idempotently.
|
|
3
11
|
*/
|
|
4
12
|
|
|
5
13
|
import { describe, expect, it, vi } from 'vitest'
|
|
@@ -17,6 +25,9 @@ function makeSdkStub() {
|
|
|
17
25
|
resetPassword: vi.fn(),
|
|
18
26
|
updateProvider: vi.fn(),
|
|
19
27
|
},
|
|
28
|
+
client: {
|
|
29
|
+
fetch: vi.fn(),
|
|
30
|
+
},
|
|
20
31
|
store: {
|
|
21
32
|
customer: {
|
|
22
33
|
retrieve: vi.fn(),
|
|
@@ -31,20 +42,49 @@ function makeSdkStub() {
|
|
|
31
42
|
} as unknown as Medusa
|
|
32
43
|
}
|
|
33
44
|
|
|
34
|
-
|
|
45
|
+
/** Access the stubbed SDK client fetch as a vitest mock. */
|
|
46
|
+
function mockFetch(sdk: Medusa): ReturnType<typeof vi.fn> {
|
|
47
|
+
return sdk.client.fetch as unknown as ReturnType<typeof vi.fn>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A mutable request-scoped storage that records the order of its operations. */
|
|
51
|
+
function makeRecordingStorage(
|
|
52
|
+
token: string | null,
|
|
53
|
+
events: string[] = [],
|
|
54
|
+
): {
|
|
55
|
+
storage: {
|
|
56
|
+
getItem: (key: string) => Promise<string | null>
|
|
57
|
+
setItem: (key: string, value: string) => Promise<void>
|
|
58
|
+
removeItem: () => Promise<void>
|
|
59
|
+
}
|
|
60
|
+
peek: () => string | null
|
|
61
|
+
} {
|
|
35
62
|
let stored: string | null = token
|
|
36
63
|
return {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
64
|
+
storage: {
|
|
65
|
+
getItem: () => Promise.resolve(stored),
|
|
66
|
+
setItem: (_key: string, value: string) => {
|
|
67
|
+
stored = value
|
|
68
|
+
events.push(`set:${value}`)
|
|
69
|
+
return Promise.resolve()
|
|
70
|
+
},
|
|
71
|
+
removeItem: () => {
|
|
72
|
+
stored = null
|
|
73
|
+
events.push('remove')
|
|
74
|
+
return Promise.resolve()
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
peek: () => stored,
|
|
40
78
|
}
|
|
41
79
|
}
|
|
42
80
|
|
|
43
|
-
function makeAuthConfig(token: string | null = 'jwt-bearer-token') {
|
|
81
|
+
function makeAuthConfig(token: string | null = 'jwt-bearer-token', customStorage?: unknown) {
|
|
44
82
|
return {
|
|
45
83
|
method: 'jwt' as const,
|
|
46
84
|
storage: 'memory' as const,
|
|
47
|
-
customStorage:
|
|
85
|
+
customStorage:
|
|
86
|
+
(customStorage as ReturnType<typeof makeRecordingStorage>['storage']) ??
|
|
87
|
+
makeRecordingStorage(token).storage,
|
|
48
88
|
}
|
|
49
89
|
}
|
|
50
90
|
|
|
@@ -56,7 +96,10 @@ describe('MedusaAuthProvider', () => {
|
|
|
56
96
|
it('stores jwt token and returns customer on login', async () => {
|
|
57
97
|
const sdk = makeSdkStub()
|
|
58
98
|
sdk.auth.login = vi.fn().mockResolvedValue('jwt-bearer-token')
|
|
59
|
-
sdk
|
|
99
|
+
mockFetch(sdk).mockResolvedValue({ linked: true })
|
|
100
|
+
sdk.store.customer.retrieve = vi
|
|
101
|
+
.fn()
|
|
102
|
+
.mockResolvedValue({ customer: { id: 'cus_1', email: 'test@example.com' } })
|
|
60
103
|
|
|
61
104
|
const provider = new MedusaAuthProvider(sdk, makeAuthConfig('jwt-bearer-token'), false)
|
|
62
105
|
|
|
@@ -70,13 +113,45 @@ describe('MedusaAuthProvider', () => {
|
|
|
70
113
|
})
|
|
71
114
|
})
|
|
72
115
|
|
|
116
|
+
it('links the customer channel idempotently on login', async () => {
|
|
117
|
+
const sdk = makeSdkStub()
|
|
118
|
+
sdk.auth.login = vi.fn().mockResolvedValue('jwt-bearer-token')
|
|
119
|
+
mockFetch(sdk).mockResolvedValue({ linked: false, reason: 'already_linked' })
|
|
120
|
+
sdk.store.customer.retrieve = vi
|
|
121
|
+
.fn()
|
|
122
|
+
.mockResolvedValue({ customer: { id: 'cus_1', email: 'test@example.com' } })
|
|
123
|
+
|
|
124
|
+
const provider = new MedusaAuthProvider(sdk, makeAuthConfig('jwt-bearer-token'), false)
|
|
125
|
+
const result = await provider.login({ email: 'test@example.com', password: 'secret' })
|
|
126
|
+
|
|
127
|
+
expect(sdk.client.fetch).toHaveBeenCalledWith(
|
|
128
|
+
'/store/thor/customers/me/channels',
|
|
129
|
+
expect.objectContaining({ method: 'POST' }),
|
|
130
|
+
)
|
|
131
|
+
expect(result.customer?.id).toBe('cus_1')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('fails closed with a typed error when the channel link route is unavailable on login', async () => {
|
|
135
|
+
const sdk = makeSdkStub()
|
|
136
|
+
sdk.auth.login = vi.fn().mockResolvedValue('jwt-bearer-token')
|
|
137
|
+
mockFetch(sdk).mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }))
|
|
138
|
+
|
|
139
|
+
const provider = new MedusaAuthProvider(sdk, makeAuthConfig('jwt-bearer-token'), false)
|
|
140
|
+
|
|
141
|
+
await expect(
|
|
142
|
+
provider.login({ email: 'test@example.com', password: 'secret' }),
|
|
143
|
+
).rejects.toMatchObject({ provider: 'CUSTOMER_LINK_UNAVAILABLE' })
|
|
144
|
+
})
|
|
145
|
+
|
|
73
146
|
it('throws ProviderAPIError with login code on auth failure', async () => {
|
|
74
147
|
const sdk = makeSdkStub()
|
|
75
148
|
sdk.auth.login = vi.fn().mockRejectedValue(new Error('bad credentials'))
|
|
76
149
|
|
|
77
150
|
const provider = new MedusaAuthProvider(sdk, makeAuthConfig(), false)
|
|
78
151
|
|
|
79
|
-
await expect(
|
|
152
|
+
await expect(
|
|
153
|
+
provider.login({ email: 'test@example.com', password: 'secret' }),
|
|
154
|
+
).rejects.toMatchObject({
|
|
80
155
|
name: 'ProviderAPIError',
|
|
81
156
|
code: 'PROVIDER_API_ERROR',
|
|
82
157
|
provider: 'LOGIN_FAILED',
|
|
@@ -86,7 +161,9 @@ describe('MedusaAuthProvider', () => {
|
|
|
86
161
|
|
|
87
162
|
it('returns requiresAction when login requires verification (v2.16.0)', async () => {
|
|
88
163
|
const sdk = makeSdkStub()
|
|
89
|
-
sdk.auth.login = vi
|
|
164
|
+
sdk.auth.login = vi
|
|
165
|
+
.fn()
|
|
166
|
+
.mockResolvedValue({ verification_required: true, token: 'verify-token' })
|
|
90
167
|
|
|
91
168
|
const provider = new MedusaAuthProvider(sdk, makeAuthConfig(), false)
|
|
92
169
|
const result = await provider.login({ email: 'test@example.com', password: 'secret' })
|
|
@@ -95,19 +172,22 @@ describe('MedusaAuthProvider', () => {
|
|
|
95
172
|
expect(result.token).toBe('verify-token')
|
|
96
173
|
})
|
|
97
174
|
|
|
98
|
-
it('
|
|
175
|
+
it('persists the registration token before the channel-linking call and returns the linked customer', async () => {
|
|
99
176
|
const sdk = makeSdkStub()
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
sdk.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
177
|
+
const events: string[] = []
|
|
178
|
+
const { storage } = makeRecordingStorage(null, events)
|
|
179
|
+
sdk.auth.register = vi.fn().mockResolvedValue('registration-token')
|
|
180
|
+
sdk.auth.login = vi.fn().mockResolvedValue('session-token')
|
|
181
|
+
mockFetch(sdk).mockImplementation(async (path: string) => {
|
|
182
|
+
events.push(`fetch:${path}`)
|
|
183
|
+
if (path === '/store/thor/customers') return { customer: { id: 'cus_1' } }
|
|
184
|
+
throw new Error(`unexpected ${path}`)
|
|
107
185
|
})
|
|
186
|
+
sdk.store.customer.retrieve = vi
|
|
187
|
+
.fn()
|
|
188
|
+
.mockResolvedValue({ customer: { id: 'cus_1', email: 'test@example.com' } })
|
|
108
189
|
|
|
109
|
-
const provider = new MedusaAuthProvider(sdk, makeAuthConfig(
|
|
110
|
-
|
|
190
|
+
const provider = new MedusaAuthProvider(sdk, makeAuthConfig(null, storage), false)
|
|
111
191
|
const result = await provider.register({
|
|
112
192
|
email: 'test@example.com',
|
|
113
193
|
password: 'secret',
|
|
@@ -119,12 +199,55 @@ describe('MedusaAuthProvider', () => {
|
|
|
119
199
|
email: 'test@example.com',
|
|
120
200
|
password: 'secret',
|
|
121
201
|
})
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
202
|
+
// The registration token was written to the request-scoped mutable storage
|
|
203
|
+
// BEFORE the linking call carried it.
|
|
204
|
+
expect(events.indexOf('set:registration-token')).toBeGreaterThanOrEqual(0)
|
|
205
|
+
expect(events.indexOf('set:registration-token')).toBeLessThan(
|
|
206
|
+
events.indexOf('fetch:/store/thor/customers'),
|
|
207
|
+
)
|
|
208
|
+
// The linking call goes through the channel-linking route, not native create.
|
|
209
|
+
expect(sdk.client.fetch).toHaveBeenCalledWith(
|
|
210
|
+
'/store/thor/customers',
|
|
211
|
+
expect.objectContaining({ method: 'POST' }),
|
|
212
|
+
)
|
|
213
|
+
expect(sdk.store.customer.create).not.toHaveBeenCalled()
|
|
214
|
+
expect(result.token).toBe('session-token')
|
|
215
|
+
expect(result.customer?.id).toBe('cus_1')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('does not treat an auth identity without a linked customer as complete', async () => {
|
|
219
|
+
const sdk = makeSdkStub()
|
|
220
|
+
sdk.auth.register = vi.fn().mockResolvedValue('registration-token')
|
|
221
|
+
// The linking route returns no customer id -> incomplete.
|
|
222
|
+
mockFetch(sdk).mockResolvedValue({ customer: {} })
|
|
223
|
+
|
|
224
|
+
const provider = new MedusaAuthProvider(sdk, makeAuthConfig(null), false)
|
|
225
|
+
|
|
226
|
+
await expect(
|
|
227
|
+
provider.register({
|
|
228
|
+
email: 'test@example.com',
|
|
229
|
+
password: 'secret',
|
|
230
|
+
first_name: 'Test',
|
|
231
|
+
last_name: 'User',
|
|
232
|
+
}),
|
|
233
|
+
).rejects.toMatchObject({ provider: 'CUSTOMER_LINK_INCOMPLETE' })
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('fails closed with a typed unavailable error when the linking route is missing', async () => {
|
|
237
|
+
const sdk = makeSdkStub()
|
|
238
|
+
sdk.auth.register = vi.fn().mockResolvedValue('registration-token')
|
|
239
|
+
mockFetch(sdk).mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }))
|
|
240
|
+
|
|
241
|
+
const provider = new MedusaAuthProvider(sdk, makeAuthConfig(null), false)
|
|
242
|
+
|
|
243
|
+
await expect(
|
|
244
|
+
provider.register({
|
|
245
|
+
email: 'test@example.com',
|
|
246
|
+
password: 'secret',
|
|
247
|
+
first_name: 'Test',
|
|
248
|
+
last_name: 'User',
|
|
249
|
+
}),
|
|
250
|
+
).rejects.toMatchObject({ provider: 'CUSTOMER_LINK_UNAVAILABLE' })
|
|
128
251
|
})
|
|
129
252
|
|
|
130
253
|
it('requires email when resetting password', async () => {
|
|
@@ -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,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Channel-scoped customer orders tests (MedusaProvider).
|
|
3
|
+
*
|
|
4
|
+
* Covers the account channel-API selection
|
|
5
|
+
* (change: storefront-live-auth-orders-acceptance, tasks 2.1–2.2, 2.5):
|
|
6
|
+
* - the paginated call goes to `GET /store/thor/orders` (customer + publishable
|
|
7
|
+
* key sales channel) carrying the request's token, and no client-side
|
|
8
|
+
* `filterByChannel` runs;
|
|
9
|
+
* - `getOrders` keeps its legacy `Order[]` return via the same channel route;
|
|
10
|
+
* - a foreign-channel order (a multi-channel key silently scoped to a first id)
|
|
11
|
+
* fails closed with a typed error (AC-AUTH-4.5).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from 'vitest'
|
|
15
|
+
import { http, HttpResponse } from 'msw'
|
|
16
|
+
import { server } from '../stelorder/client.test-helpers'
|
|
17
|
+
import { MedusaProvider } from './index'
|
|
18
|
+
|
|
19
|
+
const BASE = 'https://medusa.example.com'
|
|
20
|
+
const PUB_KEY = 'pk_test_channel'
|
|
21
|
+
const STORE_CHANNEL = 'sc_store'
|
|
22
|
+
const TOKEN = 'request-scoped-customer-token'
|
|
23
|
+
|
|
24
|
+
/** A minimal Medusa order the transform understands. */
|
|
25
|
+
function medusaOrder(id: string, salesChannelId: string, displayId: number) {
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
display_id: displayId,
|
|
29
|
+
status: 'pending',
|
|
30
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
31
|
+
updated_at: '2026-01-01T00:00:00.000Z',
|
|
32
|
+
email: 'buyer@example.com',
|
|
33
|
+
currency_code: 'usd',
|
|
34
|
+
total: 1000,
|
|
35
|
+
subtotal: 1000,
|
|
36
|
+
sales_channel_id: salesChannelId,
|
|
37
|
+
items: [],
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A request-scoped custom storage holding exactly the request token. */
|
|
42
|
+
function makeProvider() {
|
|
43
|
+
return new MedusaProvider({
|
|
44
|
+
baseUrl: BASE,
|
|
45
|
+
publishableKey: PUB_KEY,
|
|
46
|
+
currencyCode: 'usd',
|
|
47
|
+
debug: false,
|
|
48
|
+
auth: {
|
|
49
|
+
method: 'jwt',
|
|
50
|
+
storage: 'memory',
|
|
51
|
+
customStorage: {
|
|
52
|
+
getItem: () => TOKEN,
|
|
53
|
+
setItem: () => {},
|
|
54
|
+
removeItem: () => {},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('MedusaProvider — channel-scoped customer orders', () => {
|
|
61
|
+
it('calls GET /store/thor/orders with the request token and paginated window', async () => {
|
|
62
|
+
const captured: { url: string; auth: string | null }[] = []
|
|
63
|
+
server.use(
|
|
64
|
+
http.get(`${BASE}/store/thor/orders`, ({ request }) => {
|
|
65
|
+
captured.push({
|
|
66
|
+
url: request.url,
|
|
67
|
+
auth: request.headers.get('authorization'),
|
|
68
|
+
})
|
|
69
|
+
return HttpResponse.json({
|
|
70
|
+
orders: [medusaOrder('order_1', STORE_CHANNEL, 1)],
|
|
71
|
+
count: 12,
|
|
72
|
+
limit: 5,
|
|
73
|
+
offset: 5,
|
|
74
|
+
})
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const provider = makeProvider()
|
|
79
|
+
const page = await provider.customerOrdersPage({
|
|
80
|
+
page: 2,
|
|
81
|
+
pageSize: 5,
|
|
82
|
+
salesChannelId: STORE_CHANNEL,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
expect(captured).toHaveLength(1)
|
|
86
|
+
const params = new URL(captured[0].url).searchParams
|
|
87
|
+
expect(new URL(captured[0].url).pathname).toBe('/store/thor/orders')
|
|
88
|
+
expect(params.get('limit')).toBe('5')
|
|
89
|
+
expect(params.get('offset')).toBe('5')
|
|
90
|
+
// The SDK attaches the request-scoped token as a bearer header.
|
|
91
|
+
expect(captured[0].auth).toBe(`Bearer ${TOKEN}`)
|
|
92
|
+
// The backend's real count is passed through, not a page length.
|
|
93
|
+
expect(page.count).toBe(12)
|
|
94
|
+
expect(page.limit).toBe(5)
|
|
95
|
+
expect(page.offset).toBe(5)
|
|
96
|
+
expect(page.orders).toHaveLength(1)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('keeps the legacy getOrders surface array-returning through the channel route', async () => {
|
|
100
|
+
let calledPath = ''
|
|
101
|
+
server.use(
|
|
102
|
+
http.get(`${BASE}/store/thor/orders`, ({ request }) => {
|
|
103
|
+
calledPath = new URL(request.url).pathname
|
|
104
|
+
return HttpResponse.json({
|
|
105
|
+
orders: [
|
|
106
|
+
medusaOrder('order_1', STORE_CHANNEL, 1),
|
|
107
|
+
medusaOrder('order_2', STORE_CHANNEL, 2),
|
|
108
|
+
],
|
|
109
|
+
count: 2,
|
|
110
|
+
limit: 100,
|
|
111
|
+
offset: 0,
|
|
112
|
+
})
|
|
113
|
+
}),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
const provider = makeProvider()
|
|
117
|
+
const orders = await provider.getOrders({ salesChannelId: STORE_CHANNEL })
|
|
118
|
+
|
|
119
|
+
expect(calledPath).toBe('/store/thor/orders')
|
|
120
|
+
expect(Array.isArray(orders)).toBe(true)
|
|
121
|
+
expect(orders).toHaveLength(2)
|
|
122
|
+
expect(orders.map((o) => o.id)).toEqual(['order_1', 'order_2'])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('fails closed when the backend returns a foreign-channel order', async () => {
|
|
126
|
+
server.use(
|
|
127
|
+
http.get(`${BASE}/store/thor/orders`, () =>
|
|
128
|
+
HttpResponse.json({
|
|
129
|
+
orders: [medusaOrder('order_foreign', 'sc_other', 9)],
|
|
130
|
+
count: 1,
|
|
131
|
+
limit: 10,
|
|
132
|
+
offset: 0,
|
|
133
|
+
}),
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const provider = makeProvider()
|
|
138
|
+
await expect(
|
|
139
|
+
provider.customerOrdersPage({ page: 1, pageSize: 10, salesChannelId: STORE_CHANNEL }),
|
|
140
|
+
).rejects.toMatchObject({ name: 'ProviderAPIError' })
|
|
141
|
+
})
|
|
142
|
+
})
|