@thorprovider/adapters 4.2.0 → 4.3.0
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/CHANGELOG.md +6 -0
- package/PROVIDER_IMPLEMENTATION_ANALYSIS.md +659 -0
- package/README.md +19 -0
- package/dist/index.d.mts +249 -2
- package/dist/index.d.ts +249 -2
- package/dist/index.js +1450 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1448 -2
- package/dist/index.mjs.map +1 -1
- package/docs/RETRY_SOLUTION_SUMMARY.md +26 -11
- package/docs/authentication.md +115 -62
- package/docs/capabilities-system.md +34 -2
- package/docs/category-customization.md +37 -29
- package/docs/fetch-reliability-solution.md +127 -278
- package/docs/getting-started.md +43 -29
- package/docs/profile-management.md +68 -38
- package/docs/providers-overview.md +14 -0
- package/docs/services-overview.md +20 -2
- package/package.json +10 -6
- package/quick-reference.md +34 -0
- package/src/factory/index.ts +4 -0
- package/src/index.ts +3 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/medusa/admin.test.ts +404 -0
- package/src/providers/medusa/admin.ts +442 -0
- package/src/providers/medusa/auth.test.ts +137 -0
- package/src/providers/medusa/auth.ts +96 -1
- package/src/providers/medusa/index.ts +21 -1
- package/src/providers/shopify/auth.ts +6 -0
- package/src/providers/stelorder/client.test-helpers.ts +3 -0
- package/src/providers/stelorder/client.test.ts +58 -0
- package/src/providers/stelorder/client.ts +326 -0
- package/src/providers/stelorder/index.test.ts +48 -0
- package/src/providers/stelorder/index.ts +685 -0
- package/src/providers/stelorder/live-smoke.test.ts +54 -0
- package/src/providers/stelorder/transforms.test.ts +103 -0
- package/src/providers/stelorder/transforms.ts +631 -0
- package/src/resilience/README.md +70 -48
- package/src/types/capabilities.types.ts +51 -0
- package/vitest.config.ts +13 -0
- package/vitest.setup.ts +6 -0
- package/.turbo/turbo-build.log +0 -18
- package/dist/.tsbuildinfo +0 -1
- package/src/core/errors.js +0 -161
- package/src/core/fetch-utils.js +0 -240
- package/src/core/logger.js +0 -63
- package/src/metadata/channel-scope-validation.js +0 -136
- package/src/metadata/channel-scope.js +0 -174
- package/src/metadata/index.js +0 -31
- package/src/providers/medusa/auth.js +0 -601
- package/src/providers/medusa/index.js +0 -2485
- package/src/providers/medusa/payment.js +0 -265
- package/src/providers/medusa/transforms.js +0 -505
- package/src/types/capabilities.types.js +0 -141
- package/src/types/provider.js +0 -9
- package/src/utils/payment-utils.js +0 -152
|
@@ -49,6 +49,7 @@ import { validatePaymentId } from '../../utils/payment-utils';
|
|
|
49
49
|
import { transformProduct, transformCart, transformCollection, transformCategory, transformOrder, transformCustomer, transformStockLocation, transformInventoryLevel, transformAddress, transformStorefrontConfig } from './transforms';
|
|
50
50
|
import { MedusaAuthProvider } from './auth';
|
|
51
51
|
import { getPaymentMethods } from './payment';
|
|
52
|
+
import { MedusaAdminClient } from './admin';
|
|
52
53
|
import {
|
|
53
54
|
buildChannelScopeMetadata,
|
|
54
55
|
filterByChannel,
|
|
@@ -163,6 +164,19 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
163
164
|
* Only created when adminConfig.apiKey is provided.
|
|
164
165
|
*/
|
|
165
166
|
private sdkAdmin?: Medusa;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Admin client for Thor multi-tenant endpoints (`/admin/thor/`).
|
|
170
|
+
* Available when `adminConfig.apiKey` is provided.
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* ```typescript
|
|
174
|
+
* const { customers } = await commerce.admin.getChannelCustomers('sc_01JXXXXX');
|
|
175
|
+
* const { api_keys } = await commerce.admin.getChannelApiKeys('sc_01JXXXXX');
|
|
176
|
+
* await commerce.admin.updateStorefrontConfig('sc_01JXXXXX', { theme_accent_color: '#EF4444', ... });
|
|
177
|
+
* ```
|
|
178
|
+
*/
|
|
179
|
+
public readonly admin?: MedusaAdminClient;
|
|
166
180
|
/** Cached distinct product status values — rarely changes, cached for process lifetime */
|
|
167
181
|
private _cachedProductStatuses: string[] | null = null;
|
|
168
182
|
|
|
@@ -253,12 +267,18 @@ export class MedusaProvider implements CommerceProvider {
|
|
|
253
267
|
debug: config.debug ?? false,
|
|
254
268
|
// No publishableKey or auth config: this instance is admin-only, no JWT needed
|
|
255
269
|
});
|
|
270
|
+
// Admin client for Thor multi-tenant custom endpoints (/admin/thor/)
|
|
271
|
+
(this as any).admin = new MedusaAdminClient({
|
|
272
|
+
baseUrl: config.baseUrl,
|
|
273
|
+
apiKey: config.adminConfig.apiKey,
|
|
274
|
+
debug: config.debug,
|
|
275
|
+
});
|
|
256
276
|
this.logger.info('[Admin Auth] Dedicated admin SDK instance created with API key');
|
|
257
277
|
}
|
|
258
278
|
|
|
259
279
|
// Initialize auth provider if config is provided
|
|
260
280
|
if (config.auth) {
|
|
261
|
-
this.auth = new MedusaAuthProvider(this.sdk, config.auth, config.debug);
|
|
281
|
+
this.auth = new MedusaAuthProvider(this.sdk, config.auth, config.debug, config.baseUrl, config.publishableKey);
|
|
262
282
|
this.logger.info('Auth provider initialized');
|
|
263
283
|
}
|
|
264
284
|
|
|
@@ -399,4 +399,10 @@ export class ShopifyAuthProvider implements AuthProvider {
|
|
|
399
399
|
async deleteCustomerAccount(): Promise<void> {
|
|
400
400
|
throw new ProviderAPIError('Shopify customer self-service deletion requires Admin API and is not supported.', 'shopify', 501);
|
|
401
401
|
}
|
|
402
|
+
|
|
403
|
+
async linkCustomerToChannel(): Promise<{ linked: boolean; reason?: string }> {
|
|
404
|
+
// Shopify does not have the concept of Thor sales channels.
|
|
405
|
+
// Return a no-op response so multi-tenant flows don't break when running against Shopify.
|
|
406
|
+
return { linked: false, reason: 'not_applicable' };
|
|
407
|
+
}
|
|
402
408
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { http, HttpResponse } from 'msw'
|
|
3
|
+
import { server } from './client.test-helpers'
|
|
4
|
+
import { StelOrderClient } from './client'
|
|
5
|
+
|
|
6
|
+
describe('StelOrderClient', () => {
|
|
7
|
+
it('normalizes base url and sends APIKEY header', async () => {
|
|
8
|
+
server.use(
|
|
9
|
+
http.get('https://stel.example.com/app/products', ({ request }) => {
|
|
10
|
+
expect(request.headers.get('APIKEY')).toBe('secret')
|
|
11
|
+
expect(request.headers.get('accept')).toBe('application/json')
|
|
12
|
+
return HttpResponse.json([{ id: 1, name: 'P1' }])
|
|
13
|
+
}),
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
const client = new StelOrderClient({
|
|
17
|
+
baseUrl: 'https://stel.example.com/api/openapi.json',
|
|
18
|
+
apiKey: 'secret',
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const products = await client.list<{ id: number; name: string }>('products')
|
|
22
|
+
expect(products).toEqual([{ id: 1, name: 'P1' }])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('supports create update and delete', async () => {
|
|
26
|
+
server.use(
|
|
27
|
+
http.post('https://stel.example.com/app/clients', async ({ request }) => {
|
|
28
|
+
expect(await request.json()).toEqual({ email: 'a@b.com' })
|
|
29
|
+
return HttpResponse.json({ id: 1, email: 'a@b.com' })
|
|
30
|
+
}),
|
|
31
|
+
http.put('https://stel.example.com/app/clients/1', async ({ request }) => {
|
|
32
|
+
expect(await request.json()).toEqual({ email: 'c@d.com' })
|
|
33
|
+
return HttpResponse.json({ id: 1, email: 'c@d.com' })
|
|
34
|
+
}),
|
|
35
|
+
http.delete('https://stel.example.com/app/clients/1', () => HttpResponse.text('', { status: 204 })),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const client = new StelOrderClient({ baseUrl: 'https://stel.example.com', apiKey: 'secret' })
|
|
39
|
+
await expect(client.create('clients', { email: 'a@b.com' })).resolves.toEqual({ id: 1, email: 'a@b.com' })
|
|
40
|
+
await expect(client.update('clients', 1, { email: 'c@d.com' })).resolves.toEqual({ id: 1, email: 'c@d.com' })
|
|
41
|
+
await expect(client.remove('clients', 1)).resolves.toBeUndefined()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('throws provider api error on non-ok responses', async () => {
|
|
45
|
+
server.use(
|
|
46
|
+
http.get('https://stel.example.com/app/products/1', () =>
|
|
47
|
+
HttpResponse.json({ message: 'Not found' }, { status: 404 }),
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const client = new StelOrderClient({ baseUrl: 'https://stel.example.com', apiKey: 'secret' })
|
|
52
|
+
|
|
53
|
+
await expect(client.getOne('products', 1)).rejects.toMatchObject({
|
|
54
|
+
name: 'ProviderAPIError',
|
|
55
|
+
status: 404,
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import type { ProviderConfig } from '../../types/provider';
|
|
2
|
+
import { ConfigurationError, ProviderAPIError, extractErrorMessage } from '../../core/errors';
|
|
3
|
+
import { fetchWithRetry, type RetryConfig } from '../../core/fetch-utils';
|
|
4
|
+
import { createLogger, type Logger } from '../../core/logger';
|
|
5
|
+
import { normalizeStelCollection, normalizeStelEntity } from './transforms';
|
|
6
|
+
|
|
7
|
+
type FetchImplementation = (url: string, init?: RequestInit) => Promise<Response>;
|
|
8
|
+
type HttpMethod = 'DELETE' | 'GET' | 'POST' | 'PUT';
|
|
9
|
+
|
|
10
|
+
export type StelQueryValue =
|
|
11
|
+
| string
|
|
12
|
+
| number
|
|
13
|
+
| boolean
|
|
14
|
+
| null
|
|
15
|
+
| undefined
|
|
16
|
+
| Array<string | number | boolean>;
|
|
17
|
+
|
|
18
|
+
export type StelQueryParams = Record<string, StelQueryValue>;
|
|
19
|
+
|
|
20
|
+
export interface StelOrderProviderConfig extends ProviderConfig {
|
|
21
|
+
baseUrl: string;
|
|
22
|
+
apiKey?: string;
|
|
23
|
+
publishableKey?: string;
|
|
24
|
+
currencyCode?: string;
|
|
25
|
+
companyName?: string;
|
|
26
|
+
storefrontId?: string;
|
|
27
|
+
retry?: RetryConfig;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type RequiredRetryConfig = Required<RetryConfig>;
|
|
31
|
+
|
|
32
|
+
const DEFAULT_RETRY_CONFIG: RequiredRetryConfig = {
|
|
33
|
+
maxRetries: 2,
|
|
34
|
+
initialDelayMs: 300,
|
|
35
|
+
backoffMultiplier: 2,
|
|
36
|
+
maxDelayMs: 5000,
|
|
37
|
+
timeoutMs: 10000,
|
|
38
|
+
retryableStatusCodes: [408, 429, 500, 502, 503, 504],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function sleep(ms: number): Promise<void> {
|
|
42
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function calculateDelay(attempt: number, retryConfig: RequiredRetryConfig): number {
|
|
46
|
+
const delay = retryConfig.initialDelayMs * Math.pow(retryConfig.backoffMultiplier, attempt);
|
|
47
|
+
return Math.min(delay, retryConfig.maxDelayMs);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isRetryableError(error: unknown, retryConfig: RequiredRetryConfig): boolean {
|
|
51
|
+
if (error instanceof ProviderAPIError) {
|
|
52
|
+
return typeof error.status === 'number' && retryConfig.retryableStatusCodes.includes(error.status);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!(error instanceof Error)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const message = error.message.toLowerCase();
|
|
60
|
+
return (
|
|
61
|
+
error.name === 'AbortError' ||
|
|
62
|
+
error.name === 'TimeoutError' ||
|
|
63
|
+
message.includes('fetch failed') ||
|
|
64
|
+
message.includes('timeout') ||
|
|
65
|
+
message.includes('econnrefused') ||
|
|
66
|
+
message.includes('etimedout')
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createRetryingFetch(
|
|
71
|
+
fetchImplementation: FetchImplementation,
|
|
72
|
+
retryConfig: RequiredRetryConfig,
|
|
73
|
+
logger: Logger,
|
|
74
|
+
): FetchImplementation {
|
|
75
|
+
return async (url: string, init?: RequestInit): Promise<Response> => {
|
|
76
|
+
let lastError: unknown = new Error('Unknown STEL Order request error');
|
|
77
|
+
|
|
78
|
+
for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt += 1) {
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
const timeoutId = setTimeout(() => controller.abort(), retryConfig.timeoutMs);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const response = await fetchImplementation(url, {
|
|
84
|
+
...init,
|
|
85
|
+
signal: controller.signal,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
clearTimeout(timeoutId);
|
|
89
|
+
|
|
90
|
+
if (!response.ok && retryConfig.retryableStatusCodes.includes(response.status)) {
|
|
91
|
+
throw new ProviderAPIError(
|
|
92
|
+
`STEL Order API error: ${response.status} ${response.statusText}`,
|
|
93
|
+
'stelorder',
|
|
94
|
+
response.status,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return response;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
clearTimeout(timeoutId);
|
|
101
|
+
lastError = error;
|
|
102
|
+
|
|
103
|
+
if (attempt >= retryConfig.maxRetries || !isRetryableError(error, retryConfig)) {
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const delay = calculateDelay(attempt, retryConfig);
|
|
108
|
+
logger.warn('STEL request failed, retrying.', {
|
|
109
|
+
url,
|
|
110
|
+
attempt: attempt + 1,
|
|
111
|
+
maxAttempts: retryConfig.maxRetries + 1,
|
|
112
|
+
nextRetryIn: delay,
|
|
113
|
+
error: extractErrorMessage(error),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await sleep(delay);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
throw lastError;
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function parseErrorResponse(response: Response): Promise<string> {
|
|
125
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
if (contentType.includes('application/json')) {
|
|
129
|
+
const body = (await response.json()) as unknown;
|
|
130
|
+
const message = extractErrorMessage(body);
|
|
131
|
+
if (message && message !== 'An unknown error occurred') {
|
|
132
|
+
return message;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// Ignore body parsing issues and fall back to text/status.
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const text = await response.text();
|
|
141
|
+
if (text.trim()) {
|
|
142
|
+
return text.trim();
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// Ignore body parsing issues and fall back to status.
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return `STEL Order API error: ${response.status} ${response.statusText}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function normalizeStelApiBaseUrl(baseUrl: string): string {
|
|
152
|
+
const trimmed = baseUrl.trim();
|
|
153
|
+
|
|
154
|
+
if (!trimmed) {
|
|
155
|
+
throw new ConfigurationError('Stel Order baseUrl is required.');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const normalized = trimmed
|
|
159
|
+
.replace(/\/api\/openapi\.json$/i, '')
|
|
160
|
+
.replace(/\/+$/, '');
|
|
161
|
+
|
|
162
|
+
return normalized.endsWith('/app') ? normalized : `${normalized}/app`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
interface StelRequestOptions {
|
|
166
|
+
method: HttpMethod;
|
|
167
|
+
path: string;
|
|
168
|
+
query?: StelQueryParams;
|
|
169
|
+
body?: unknown;
|
|
170
|
+
headers?: HeadersInit;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export class StelOrderClient {
|
|
174
|
+
readonly apiBaseUrl: string;
|
|
175
|
+
|
|
176
|
+
private readonly apiKey: string;
|
|
177
|
+
private readonly logger: Logger;
|
|
178
|
+
private readonly retryConfig: RequiredRetryConfig;
|
|
179
|
+
private readonly fetchImplementation?: FetchImplementation;
|
|
180
|
+
|
|
181
|
+
constructor(config: StelOrderProviderConfig) {
|
|
182
|
+
const apiKey = config.apiKey ?? config.publishableKey;
|
|
183
|
+
|
|
184
|
+
if (!apiKey) {
|
|
185
|
+
throw new ConfigurationError(
|
|
186
|
+
'Stel Order API key is required. Pass `apiKey` or `publishableKey` to createProvider().',
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
this.apiBaseUrl = normalizeStelApiBaseUrl(config.baseUrl);
|
|
191
|
+
this.apiKey = apiKey;
|
|
192
|
+
this.logger = createLogger('StelOrderClient', config.debug);
|
|
193
|
+
this.retryConfig = {
|
|
194
|
+
...DEFAULT_RETRY_CONFIG,
|
|
195
|
+
...config.retry,
|
|
196
|
+
};
|
|
197
|
+
this.fetchImplementation = config.customFetch
|
|
198
|
+
? createRetryingFetch(config.customFetch, this.retryConfig, this.logger)
|
|
199
|
+
: undefined;
|
|
200
|
+
|
|
201
|
+
this.logger.info('Initializing Stel Order client', {
|
|
202
|
+
apiBaseUrl: this.apiBaseUrl,
|
|
203
|
+
retryConfig: this.retryConfig,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async list<T>(resource: string, query?: StelQueryParams): Promise<T[]> {
|
|
208
|
+
const payload = await this.request<T | T[]>({
|
|
209
|
+
method: 'GET',
|
|
210
|
+
path: resource,
|
|
211
|
+
query,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
return normalizeStelCollection(payload);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async getOne<T>(resource: string, id: string | number, query?: StelQueryParams): Promise<T | undefined> {
|
|
218
|
+
const payload = await this.request<T | T[]>({
|
|
219
|
+
method: 'GET',
|
|
220
|
+
path: `${resource}/${id}`,
|
|
221
|
+
query,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return normalizeStelEntity(payload);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async create<TResponse, TBody = unknown>(resource: string, body: TBody): Promise<TResponse> {
|
|
228
|
+
return this.request<TResponse>({
|
|
229
|
+
method: 'POST',
|
|
230
|
+
path: resource,
|
|
231
|
+
body,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async update<TResponse, TBody = unknown>(resource: string, id: string | number, body: TBody): Promise<TResponse> {
|
|
236
|
+
return this.request<TResponse>({
|
|
237
|
+
method: 'PUT',
|
|
238
|
+
path: `${resource}/${id}`,
|
|
239
|
+
body,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async remove(resource: string, id: string | number): Promise<void> {
|
|
244
|
+
await this.request<void>({
|
|
245
|
+
method: 'DELETE',
|
|
246
|
+
path: `${resource}/${id}`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private buildUrl(path: string, query?: StelQueryParams): string {
|
|
251
|
+
const normalizedPath = path.replace(/^\/+/, '');
|
|
252
|
+
const url = new URL(`${this.apiBaseUrl}/${normalizedPath}`);
|
|
253
|
+
|
|
254
|
+
if (!query) {
|
|
255
|
+
return url.toString();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (const [key, value] of Object.entries(query)) {
|
|
259
|
+
if (value === undefined || value === null || value === '') {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (Array.isArray(value)) {
|
|
264
|
+
url.searchParams.set(key, value.map(String).join(','));
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
url.searchParams.set(key, String(value));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return url.toString();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private buildHeaders(headers?: HeadersInit, hasBody: boolean = false): Headers {
|
|
275
|
+
const normalizedHeaders = new Headers(headers);
|
|
276
|
+
|
|
277
|
+
normalizedHeaders.set('Accept', 'application/json');
|
|
278
|
+
normalizedHeaders.set('APIKEY', this.apiKey);
|
|
279
|
+
|
|
280
|
+
if (hasBody && !normalizedHeaders.has('Content-Type')) {
|
|
281
|
+
normalizedHeaders.set('Content-Type', 'application/json');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return normalizedHeaders;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private async executeFetch(url: string, init: RequestInit): Promise<Response> {
|
|
288
|
+
if (this.fetchImplementation) {
|
|
289
|
+
return this.fetchImplementation(url, init);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return fetchWithRetry(url, init, this.retryConfig);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private async request<TResponse>(options: StelRequestOptions): Promise<TResponse> {
|
|
296
|
+
const url = this.buildUrl(options.path, options.query);
|
|
297
|
+
const hasBody = options.body !== undefined;
|
|
298
|
+
|
|
299
|
+
this.logger.debug('STEL request', {
|
|
300
|
+
method: options.method,
|
|
301
|
+
url,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const response = await this.executeFetch(url, {
|
|
305
|
+
method: options.method,
|
|
306
|
+
headers: this.buildHeaders(options.headers, hasBody),
|
|
307
|
+
body: hasBody ? JSON.stringify(options.body) : undefined,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
if (!response.ok) {
|
|
311
|
+
const message = await parseErrorResponse(response);
|
|
312
|
+
throw new ProviderAPIError(message, 'stelorder', response.status);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (response.status === 204) {
|
|
316
|
+
return undefined as TResponse;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
320
|
+
if (contentType.includes('application/json')) {
|
|
321
|
+
return (await response.json()) as TResponse;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return (await response.text()) as TResponse;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { http, HttpResponse } from 'msw'
|
|
3
|
+
import { server } from './client.test-helpers'
|
|
4
|
+
import { StelOrderProvider } from './index'
|
|
5
|
+
|
|
6
|
+
describe('StelOrderProvider', () => {
|
|
7
|
+
it('returns storefront context and supports read methods', async () => {
|
|
8
|
+
server.use(
|
|
9
|
+
http.get('https://stel.example.com/app/products', () =>
|
|
10
|
+
HttpResponse.json([{ id: 10, name: 'Test Product', 'full-reference': 'TP-10', 'sales-price': 10 }]),
|
|
11
|
+
),
|
|
12
|
+
http.get('https://stel.example.com/app/productCategories', () =>
|
|
13
|
+
HttpResponse.json([{ id: 2, name: 'Hardware', slug: 'hardware' }]),
|
|
14
|
+
),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const provider = new StelOrderProvider({
|
|
18
|
+
baseUrl: 'https://stel.example.com',
|
|
19
|
+
apiKey: 'secret',
|
|
20
|
+
companyName: 'Test Co',
|
|
21
|
+
storefrontId: 'stel-1',
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const context = await provider.getStorefrontContext()
|
|
25
|
+
expect(context.platformType).toBe('stelorder')
|
|
26
|
+
expect(context.name).toBe('Test Co')
|
|
27
|
+
|
|
28
|
+
await expect(provider.getProducts()).resolves.toHaveLength(1)
|
|
29
|
+
await expect(provider.getCategories()).resolves.toHaveLength(1)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('throws clear errors for unsupported workflows', async () => {
|
|
33
|
+
const provider = new StelOrderProvider({
|
|
34
|
+
baseUrl: 'https://stel.example.com',
|
|
35
|
+
apiKey: 'secret',
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
await expect(provider.createCart()).rejects.toMatchObject({
|
|
39
|
+
name: 'ProviderAPIError',
|
|
40
|
+
status: 501,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
await expect(provider.completeCheckout('cart-1')).rejects.toMatchObject({
|
|
44
|
+
name: 'ProviderAPIError',
|
|
45
|
+
status: 501,
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
})
|