@thorprovider/adapters 4.2.1 → 4.2.2
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/.turbo/turbo-build.log +18 -0
- package/.turbo/turbo-test.log +1 -0
- package/.turbo/turbo-type-check.log +0 -0
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +6 -0
- package/PROVIDER_IMPLEMENTATION_ANALYSIS.md +659 -0
- package/README.md +3 -3
- package/dist/.tsbuildinfo +1 -0
- package/dist/index.d.mts +51 -1
- package/dist/index.d.ts +51 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6 -0
- package/dist/index.mjs.map +1 -1
- package/docs/RETRY_SOLUTION_SUMMARY.md +26 -11
- package/docs/authentication.md +89 -62
- package/docs/capabilities-system.md +70 -38
- 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/services-overview.md +4 -2
- package/docs/update-documentation.md +10 -8
- package/package.json +3 -2
- package/publish.log +93 -0
- package/src/index.ts +4 -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 +95 -0
- package/src/providers/medusa/index.test.ts +392 -0
- package/src/providers/medusa/live-smoke.test.ts +412 -0
- package/src/resilience/README.md +70 -48
- package/vitest.live.config.ts +18 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Medusa Provider — Live Smoke Tests
|
|
3
|
+
*
|
|
4
|
+
* Exercises the adapter against a real Medusa backend (dev.erpthor.com).
|
|
5
|
+
* Guarded by MEDUSA_SMOKE_TEST=1 environment variable.
|
|
6
|
+
*
|
|
7
|
+
* Environment variables (all required when MEDUSA_SMOKE_TEST=1):
|
|
8
|
+
* MEDUSA_BASE_URL - Backend URL (e.g. https://dev.erpthor.com)
|
|
9
|
+
* MEDUSA_PUBLISHABLE_KEY - Publishable API key
|
|
10
|
+
* MEDUSA_SECRET_KEY - Secret admin API key (sk_...) for SDK Basic auth
|
|
11
|
+
* MEDUSA_SALES_CHANNEL_ID - Sales channel ID for scoped queries
|
|
12
|
+
* MEDUSA_ADMIN_EMAIL - Admin email for JWT auth (required for Thor admin endpoints)
|
|
13
|
+
* MEDUSA_ADMIN_PASSWORD - Admin password
|
|
14
|
+
*
|
|
15
|
+
* Optional:
|
|
16
|
+
* MEDUSA_TEST_CUSTOMER_EMAIL - Existing customer email for auth tests
|
|
17
|
+
* MEDUSA_TEST_CUSTOMER_PASSWORD - Existing customer password
|
|
18
|
+
*
|
|
19
|
+
* Known issue: MedusaAdminClient uses x-medusa-access-token header which Medusa v2
|
|
20
|
+
* does not support. The backend requires Authorization: Bearer <JWT>. Group D tests
|
|
21
|
+
* work around this by obtaining a JWT via login and testing Thor admin endpoints directly.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, expect, it, beforeAll } from 'vitest'
|
|
25
|
+
import { MedusaProvider } from './index'
|
|
26
|
+
import { MedusaAdminClient } from './admin'
|
|
27
|
+
|
|
28
|
+
const runLive = process.env.MEDUSA_SMOKE_TEST === '1';
|
|
29
|
+
|
|
30
|
+
function requiredEnv(name: string): string {
|
|
31
|
+
const value = process.env[name];
|
|
32
|
+
if (!value) throw new Error(`Missing required env var: ${name}`);
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function optionalEnv(name: string): string | undefined {
|
|
37
|
+
return process.env[name] || undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!runLive) {
|
|
41
|
+
describe('Medusa live smoke', () => {
|
|
42
|
+
it.skip('disabled unless MEDUSA_SMOKE_TEST=1', () => {});
|
|
43
|
+
});
|
|
44
|
+
} else {
|
|
45
|
+
const baseUrl = requiredEnv('MEDUSA_BASE_URL');
|
|
46
|
+
const publishableKey = requiredEnv('MEDUSA_PUBLISHABLE_KEY');
|
|
47
|
+
const secretKey = requiredEnv('MEDUSA_SECRET_KEY');
|
|
48
|
+
const salesChannelId = requiredEnv('MEDUSA_SALES_CHANNEL_ID');
|
|
49
|
+
const adminEmail = requiredEnv('MEDUSA_ADMIN_EMAIL');
|
|
50
|
+
const adminPassword = requiredEnv('MEDUSA_ADMIN_PASSWORD');
|
|
51
|
+
|
|
52
|
+
// --------------------------------------------------------------------------
|
|
53
|
+
// Group A: Thor Plugin Store Endpoints (custom /store/thor/*)
|
|
54
|
+
// --------------------------------------------------------------------------
|
|
55
|
+
describe('Group A: Thor Store Plugin Endpoints', () => {
|
|
56
|
+
let provider: MedusaProvider;
|
|
57
|
+
|
|
58
|
+
beforeAll(() => {
|
|
59
|
+
provider = new MedusaProvider({
|
|
60
|
+
baseUrl,
|
|
61
|
+
publishableKey,
|
|
62
|
+
currencyCode: 'USD',
|
|
63
|
+
adminConfig: { apiKey: secretKey },
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('GET /store/thor/capabilities', async () => {
|
|
68
|
+
// capabilities is fetched in constructor, wait a tick then access
|
|
69
|
+
await new Promise((r) => setTimeout(r, 2000))
|
|
70
|
+
const caps = provider.capabilities
|
|
71
|
+
expect(caps).toBeDefined()
|
|
72
|
+
expect(caps).toHaveProperty('advancedSearch')
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('GET /store/thor/categories (channel-scoped)', async () => {
|
|
76
|
+
const categories = await provider.getCategories({ limit: 5 });
|
|
77
|
+
expect(categories).toBeInstanceOf(Array);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('GET /store/thor/config (storefront config)', async () => {
|
|
81
|
+
const config = await provider.getStorefrontConfig();
|
|
82
|
+
// May return null if no config set for this channel — should not throw
|
|
83
|
+
expect(config === null || typeof config === 'object').toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('GET /store/thor/site-config (designer config)', async () => {
|
|
87
|
+
const config = await provider.getDesignerConfig();
|
|
88
|
+
expect(config === null || typeof config === 'object').toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('GET /store/search (advanced search)', async () => {
|
|
92
|
+
// Search depends on MeiliSearch being running on the backend — may 500 if not available
|
|
93
|
+
try {
|
|
94
|
+
const result = await provider.searchProductsAdvanced({ query: 'test', first: 3 })
|
|
95
|
+
expect(result).toHaveProperty('products')
|
|
96
|
+
expect(result.products).toBeInstanceOf(Array)
|
|
97
|
+
} catch (e: any) {
|
|
98
|
+
// MeiliSearch not running is acceptable in dev
|
|
99
|
+
console.log('Search endpoint unavailable (MeiliSearch down):', e.message?.slice(0, 80));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// --------------------------------------------------------------------------
|
|
105
|
+
// Group B: Standard Medusa Store Endpoints (via SDK)
|
|
106
|
+
// --------------------------------------------------------------------------
|
|
107
|
+
describe('Group B: Medusa Store Standard Endpoints', () => {
|
|
108
|
+
let provider: MedusaProvider;
|
|
109
|
+
|
|
110
|
+
beforeAll(() => {
|
|
111
|
+
provider = new MedusaProvider({
|
|
112
|
+
baseUrl,
|
|
113
|
+
publishableKey,
|
|
114
|
+
currencyCode: 'USD',
|
|
115
|
+
adminConfig: { apiKey: secretKey },
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('GET /store/products (list)', async () => {
|
|
120
|
+
const products = await provider.getProducts({ first: 3 });
|
|
121
|
+
expect(products).toBeInstanceOf(Array);
|
|
122
|
+
expect(products.length).toBeGreaterThan(0);
|
|
123
|
+
expect(products[0]).toHaveProperty('id');
|
|
124
|
+
expect(products[0]).toHaveProperty('title');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('GET /store/products (by handle)', async () => {
|
|
128
|
+
const products = await provider.getProducts({ first: 1 });
|
|
129
|
+
expect(products.length).toBeGreaterThan(0);
|
|
130
|
+
const handle = products[0].handle;
|
|
131
|
+
expect(handle).toBeTruthy();
|
|
132
|
+
|
|
133
|
+
const product = await provider.getProduct(handle!);
|
|
134
|
+
expect(product).toBeDefined();
|
|
135
|
+
expect(product!.handle).toBe(handle);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('GET /store/collections', async () => {
|
|
139
|
+
const collections = await provider.getCollections({});
|
|
140
|
+
expect(collections).toBeInstanceOf(Array);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('GET /store/product-categories', async () => {
|
|
144
|
+
const categories = await provider.getCategories({ limit: 10 });
|
|
145
|
+
expect(categories).toBeInstanceOf(Array);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('GET /store/regions', async () => {
|
|
149
|
+
const regions = await provider.getRegions();
|
|
150
|
+
expect(regions).toBeInstanceOf(Array);
|
|
151
|
+
expect(regions.length).toBeGreaterThan(0);
|
|
152
|
+
expect(regions[0]).toHaveProperty('id');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('GET /store/regions/:id (countries)', async () => {
|
|
156
|
+
const regions = await provider.getRegions();
|
|
157
|
+
expect(regions.length).toBeGreaterThan(0);
|
|
158
|
+
|
|
159
|
+
const countries = await provider.getRegionCountries(regions[0].id);
|
|
160
|
+
expect(countries).toBeInstanceOf(Array);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('Cart lifecycle', () => {
|
|
164
|
+
let cartId: string;
|
|
165
|
+
|
|
166
|
+
it('POST /store/carts (create)', async () => {
|
|
167
|
+
const cart = await provider.createCart({})
|
|
168
|
+
expect(cart).toHaveProperty('id')
|
|
169
|
+
expect(cart.id).toBeTruthy()
|
|
170
|
+
cartId = cart.id as string
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('GET /store/carts/:id (retrieve)', async () => {
|
|
174
|
+
expect(cartId).toBeTruthy()
|
|
175
|
+
const cart = await provider.getCart(cartId as string)
|
|
176
|
+
expect(cart).toHaveProperty('id')
|
|
177
|
+
expect(cart!.id).toBe(cartId)
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('POST /store/carts/:id/line-items (add item)', async () => {
|
|
181
|
+
expect(cartId).toBeTruthy()
|
|
182
|
+
const products = await provider.getProducts({ first: 1 })
|
|
183
|
+
expect(products.length).toBeGreaterThan(0)
|
|
184
|
+
const variant = products[0].variants?.[0]
|
|
185
|
+
expect(variant).toBeDefined()
|
|
186
|
+
|
|
187
|
+
const cart = await provider.addToCart(cartId as string, [{
|
|
188
|
+
merchandiseId: variant!.id,
|
|
189
|
+
quantity: 1,
|
|
190
|
+
}])
|
|
191
|
+
expect(cart.lines.length).toBeGreaterThan(0)
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('POST /store/carts/:id/line-items/:lineId (update quantity)', async () => {
|
|
195
|
+
expect(cartId).toBeTruthy()
|
|
196
|
+
const cart = await provider.getCart(cartId as string)
|
|
197
|
+
expect(cart!.lines.length).toBeGreaterThan(0)
|
|
198
|
+
const lineId = cart!.lines[0].id as string
|
|
199
|
+
|
|
200
|
+
const updated = await provider.updateCart(cartId as string, [{
|
|
201
|
+
id: lineId,
|
|
202
|
+
merchandiseId: cart!.lines[0].merchandise.id,
|
|
203
|
+
quantity: 2,
|
|
204
|
+
}])
|
|
205
|
+
const line = updated.lines.find((l) => l.id === lineId)
|
|
206
|
+
expect(line?.quantity).toBe(2)
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('DELETE /store/carts/:id/line-items/:lineId (remove item)', async () => {
|
|
210
|
+
expect(cartId).toBeTruthy()
|
|
211
|
+
const cart = await provider.getCart(cartId as string)
|
|
212
|
+
expect(cart!.lines.length).toBeGreaterThan(0)
|
|
213
|
+
const lineId = cart!.lines[0].id as string
|
|
214
|
+
|
|
215
|
+
const updated = await provider.removeFromCart(cartId as string, [lineId])
|
|
216
|
+
expect(updated.lines.find((l) => l.id === lineId)).toBeUndefined()
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('GET /store/payment-providers', async () => {
|
|
221
|
+
const regions = await provider.getRegions()
|
|
222
|
+
expect(regions.length).toBeGreaterThan(0)
|
|
223
|
+
|
|
224
|
+
const methods = await provider.getPaymentMethods({ regionId: regions[0].id })
|
|
225
|
+
expect(methods).toBeInstanceOf(Array)
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// --------------------------------------------------------------------------
|
|
230
|
+
// Group C: Customer Authentication (login, profile, addresses)
|
|
231
|
+
// --------------------------------------------------------------------------
|
|
232
|
+
describe('Group C: Customer Authentication', () => {
|
|
233
|
+
const testEmail = optionalEnv('MEDUSA_TEST_CUSTOMER_EMAIL')
|
|
234
|
+
const testPassword = optionalEnv('MEDUSA_TEST_CUSTOMER_PASSWORD')
|
|
235
|
+
|
|
236
|
+
it('login with test customer', async () => {
|
|
237
|
+
if (!testEmail || !testPassword) {
|
|
238
|
+
console.log('Skipping: MEDUSA_TEST_CUSTOMER_EMAIL/PASSWORD not set')
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const provider = new MedusaProvider({
|
|
243
|
+
baseUrl,
|
|
244
|
+
publishableKey,
|
|
245
|
+
currencyCode: 'USD',
|
|
246
|
+
auth: {
|
|
247
|
+
method: 'jwt',
|
|
248
|
+
storage: 'memory',
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const authProvider = provider.auth
|
|
253
|
+
expect(authProvider).toBeDefined()
|
|
254
|
+
|
|
255
|
+
const result = await authProvider!.login({ email: testEmail, password: testPassword })
|
|
256
|
+
expect(result.token || result.customer).toBeTruthy()
|
|
257
|
+
expect(result.customer).toBeDefined()
|
|
258
|
+
expect(result.customer!.email).toBe(testEmail)
|
|
259
|
+
|
|
260
|
+
// getCurrentCustomer
|
|
261
|
+
const customer = await authProvider!.getCurrentCustomer()
|
|
262
|
+
expect(customer).toBeDefined()
|
|
263
|
+
expect(customer!.email).toBe(testEmail)
|
|
264
|
+
|
|
265
|
+
// getAddresses
|
|
266
|
+
const addresses = await authProvider!.getAddresses()
|
|
267
|
+
expect(addresses).toBeInstanceOf(Array)
|
|
268
|
+
|
|
269
|
+
// Logout
|
|
270
|
+
await authProvider!.logout()
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// --------------------------------------------------------------------------
|
|
275
|
+
// Group D: Admin Thor Plugin Endpoints (direct HTTP with JWT)
|
|
276
|
+
//
|
|
277
|
+
// NOTE: MedusaAdminClient uses x-medusa-access-token which is NOT supported
|
|
278
|
+
// by Medusa v2. These tests use direct fetch with Bearer JWT to verify the
|
|
279
|
+
// endpoints exist and respond correctly. This documents a real adapter bug.
|
|
280
|
+
// --------------------------------------------------------------------------
|
|
281
|
+
describe('Group D: Admin Thor Plugin Endpoints (direct JWT)', () => {
|
|
282
|
+
let jwt: string
|
|
283
|
+
|
|
284
|
+
beforeAll(async () => {
|
|
285
|
+
// Obtain admin JWT
|
|
286
|
+
const res = await fetch(`${baseUrl}/auth/user/emailpass`, {
|
|
287
|
+
method: 'POST',
|
|
288
|
+
headers: { 'Content-Type': 'application/json' },
|
|
289
|
+
body: JSON.stringify({ email: adminEmail, password: adminPassword }),
|
|
290
|
+
})
|
|
291
|
+
const data = await res.json() as { token?: string }
|
|
292
|
+
expect(data.token).toBeTruthy()
|
|
293
|
+
jwt = data.token as string
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
async function adminFetch(path: string): Promise<any> {
|
|
297
|
+
const res = await fetch(`${baseUrl}${path}`, {
|
|
298
|
+
headers: { Authorization: `Bearer ${jwt}` },
|
|
299
|
+
})
|
|
300
|
+
if (!res.ok) {
|
|
301
|
+
const text = await res.text()
|
|
302
|
+
throw new Error(`${res.status} ${path}: ${text}`)
|
|
303
|
+
}
|
|
304
|
+
return res.json()
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
it('GET /admin/thor/sales-channels/:id/customers', async () => {
|
|
308
|
+
const result = await adminFetch(`/admin/thor/sales-channels/${salesChannelId}/customers?limit=5`)
|
|
309
|
+
expect(result).toHaveProperty('customers')
|
|
310
|
+
expect(result.customers).toBeInstanceOf(Array)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('GET /admin/thor/sales-channels/:id/api-keys', async () => {
|
|
314
|
+
const result = await adminFetch(`/admin/thor/sales-channels/${salesChannelId}/api-keys`)
|
|
315
|
+
expect(result).toHaveProperty('api_keys')
|
|
316
|
+
expect(result.api_keys).toBeInstanceOf(Array)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('GET /admin/thor/sales-channels/:id/categories', async () => {
|
|
320
|
+
const result = await adminFetch(`/admin/thor/sales-channels/${salesChannelId}/categories?limit=10`)
|
|
321
|
+
expect(result).toHaveProperty('categories')
|
|
322
|
+
expect(result.categories).toBeInstanceOf(Array)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('GET /admin/thor/storefront-config/:id (may 404)', async () => {
|
|
326
|
+
const res = await fetch(`${baseUrl}/admin/thor/storefront-config/${salesChannelId}`, {
|
|
327
|
+
headers: { Authorization: `Bearer ${jwt}` },
|
|
328
|
+
})
|
|
329
|
+
// 200 or 404 are both acceptable
|
|
330
|
+
expect([200, 404]).toContain(res.status)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('GET /admin/thor/site-config/:id (may 404)', async () => {
|
|
334
|
+
const res = await fetch(`${baseUrl}/admin/thor/site-config/${salesChannelId}`, {
|
|
335
|
+
headers: { Authorization: `Bearer ${jwt}` },
|
|
336
|
+
})
|
|
337
|
+
expect([200, 404]).toContain(res.status)
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('GET /admin/thor/site-config/:id/history (may 404)', async () => {
|
|
341
|
+
const res = await fetch(`${baseUrl}/admin/thor/site-config/${salesChannelId}/history`, {
|
|
342
|
+
headers: { Authorization: `Bearer ${jwt}` },
|
|
343
|
+
})
|
|
344
|
+
expect([200, 404]).toContain(res.status)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('BUG: MedusaAdminClient x-medusa-access-token returns 401', async () => {
|
|
348
|
+
// This documents the adapter bug: MedusaAdminClient sends x-medusa-access-token
|
|
349
|
+
// but Medusa v2 only accepts Authorization: Bearer for admin routes.
|
|
350
|
+
const res = await fetch(`${baseUrl}/admin/thor/sales-channels/${salesChannelId}/customers?limit=1`, {
|
|
351
|
+
headers: { 'x-medusa-access-token': jwt },
|
|
352
|
+
})
|
|
353
|
+
expect(res.status).toBe(401)
|
|
354
|
+
})
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// --------------------------------------------------------------------------
|
|
358
|
+
// Group E: Admin Standard Medusa Endpoints (via SDK with Basic auth)
|
|
359
|
+
// --------------------------------------------------------------------------
|
|
360
|
+
describe('Group E: Admin Standard Medusa Endpoints', () => {
|
|
361
|
+
let provider: MedusaProvider;
|
|
362
|
+
|
|
363
|
+
beforeAll(() => {
|
|
364
|
+
provider = new MedusaProvider({
|
|
365
|
+
baseUrl,
|
|
366
|
+
publishableKey,
|
|
367
|
+
currencyCode: 'USD',
|
|
368
|
+
adminConfig: { apiKey: secretKey },
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('GET /admin/products (list)', async () => {
|
|
373
|
+
const result = await provider.getAdminProducts({ limit: 3 })
|
|
374
|
+
expect(result).toHaveProperty('products')
|
|
375
|
+
expect(result.products).toBeInstanceOf(Array)
|
|
376
|
+
expect(result.products.length).toBeGreaterThan(0)
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('GET /admin/products/:id (retrieve)', async () => {
|
|
380
|
+
const list = await provider.getAdminProducts({ limit: 1 })
|
|
381
|
+
expect(list.products.length).toBeGreaterThan(0)
|
|
382
|
+
const id = list.products[0].id
|
|
383
|
+
|
|
384
|
+
const product = await provider.getAdminProduct(id)
|
|
385
|
+
expect(product).toBeDefined()
|
|
386
|
+
expect(product!.id).toBe(id)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('GET /admin/orders (list)', async () => {
|
|
390
|
+
const result = await provider.getAdminOrders({ limit: 3 })
|
|
391
|
+
expect(result).toHaveProperty('orders')
|
|
392
|
+
expect(result.orders).toBeInstanceOf(Array)
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('GET /admin/customers (list)', async () => {
|
|
396
|
+
const result = await provider.getAdminCustomers({ limit: 3 })
|
|
397
|
+
expect(result).toHaveProperty('customers')
|
|
398
|
+
expect(result.customers).toBeInstanceOf(Array)
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('GET /admin/regions (list)', async () => {
|
|
402
|
+
const result = await provider.getAdminRegions()
|
|
403
|
+
expect(result).toBeInstanceOf(Array)
|
|
404
|
+
expect(result.length).toBeGreaterThan(0)
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('GET /admin/stock-locations (list)', async () => {
|
|
408
|
+
const result = await provider.getStockLocations()
|
|
409
|
+
expect(result).toBeInstanceOf(Array)
|
|
410
|
+
})
|
|
411
|
+
});
|
|
412
|
+
}
|
package/src/resilience/README.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Resilience Patterns
|
|
3
|
+
purpose: Documents the Circuit Breaker and retry patterns for robust backend integration
|
|
4
|
+
type: reference
|
|
5
|
+
tags: [resilience, circuit-breaker, retry, fetch, patterns]
|
|
6
|
+
related:
|
|
7
|
+
- ../../docs/fetch-reliability-solution.md
|
|
8
|
+
- ../../docs/RETRY_SOLUTION_SUMMARY.md
|
|
9
|
+
updated: 2026-05-03
|
|
10
|
+
---
|
|
11
|
+
|
|
1
12
|
# Resilience Patterns
|
|
2
13
|
|
|
3
14
|
**Framework-agnostic resilience patterns for robust backend integration.**
|
|
@@ -10,10 +21,17 @@ This module provides enterprise-grade resilience patterns for handling backend f
|
|
|
10
21
|
|
|
11
22
|
---
|
|
12
23
|
|
|
13
|
-
## 📦
|
|
24
|
+
## 📦 Exports
|
|
14
25
|
|
|
15
26
|
```typescript
|
|
16
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
CircuitBreaker,
|
|
29
|
+
backendCircuitBreaker,
|
|
30
|
+
CircuitBreakerOpenError,
|
|
31
|
+
type CircuitState,
|
|
32
|
+
type CircuitBreakerConfig,
|
|
33
|
+
type CircuitBreakerStats,
|
|
34
|
+
} from '@thorprovider/adapters';
|
|
17
35
|
```
|
|
18
36
|
|
|
19
37
|
---
|
|
@@ -53,7 +71,7 @@ The Circuit Breaker pattern prevents cascading failures when the backend is unav
|
|
|
53
71
|
### Basic Usage
|
|
54
72
|
|
|
55
73
|
```typescript
|
|
56
|
-
import { CircuitBreaker } from '@thorprovider/adapters
|
|
74
|
+
import { CircuitBreaker } from '@thorprovider/adapters';
|
|
57
75
|
|
|
58
76
|
const breaker = new CircuitBreaker({
|
|
59
77
|
failureThreshold: 5, // Open after 5 failures
|
|
@@ -63,7 +81,7 @@ const breaker = new CircuitBreaker({
|
|
|
63
81
|
|
|
64
82
|
// With fallback
|
|
65
83
|
const data = await breaker.execute(
|
|
66
|
-
() => fetch('/api/data'),
|
|
84
|
+
() => fetch('/api/data').then(r => r.json()),
|
|
67
85
|
{ default: 'value' } // Fallback if circuit is open
|
|
68
86
|
);
|
|
69
87
|
|
|
@@ -81,11 +99,11 @@ try {
|
|
|
81
99
|
interface CircuitBreakerConfig {
|
|
82
100
|
/** Number of failures before opening circuit */
|
|
83
101
|
failureThreshold: number;
|
|
84
|
-
|
|
102
|
+
|
|
85
103
|
/** Time in ms to wait before attempting recovery */
|
|
86
104
|
resetTimeout: number;
|
|
87
|
-
|
|
88
|
-
/** Name for logging
|
|
105
|
+
|
|
106
|
+
/** Name for logging/monitoring */
|
|
89
107
|
name?: string;
|
|
90
108
|
}
|
|
91
109
|
```
|
|
@@ -103,7 +121,7 @@ console.log({
|
|
|
103
121
|
failureCount: stats.failureCount,
|
|
104
122
|
totalRequests: stats.totalRequests,
|
|
105
123
|
totalFailures: stats.totalFailures,
|
|
106
|
-
totalSuccesses: stats.totalSuccesses
|
|
124
|
+
totalSuccesses: stats.totalSuccesses,
|
|
107
125
|
});
|
|
108
126
|
|
|
109
127
|
// Manual reset (force recovery)
|
|
@@ -117,35 +135,34 @@ breaker.manualReset();
|
|
|
117
135
|
A pre-configured global instance for backend API calls:
|
|
118
136
|
|
|
119
137
|
```typescript
|
|
120
|
-
import { backendCircuitBreaker } from '@thorprovider/adapters
|
|
138
|
+
import { backendCircuitBreaker } from '@thorprovider/adapters';
|
|
121
139
|
|
|
122
140
|
// Use in adapters
|
|
123
141
|
async function getProducts() {
|
|
124
142
|
return backendCircuitBreaker.execute(
|
|
125
|
-
() =>
|
|
143
|
+
() => commerce.getProducts(),
|
|
126
144
|
[] // Empty array fallback
|
|
127
145
|
);
|
|
128
146
|
}
|
|
129
147
|
```
|
|
130
148
|
|
|
131
|
-
**
|
|
149
|
+
**Default configuration:**
|
|
132
150
|
- `failureThreshold`: 5 failures
|
|
133
151
|
- `resetTimeout`: 60,000ms (1 minute)
|
|
134
|
-
- `name`: 'backend-api'
|
|
152
|
+
- `name`: `'backend-api'`
|
|
135
153
|
|
|
136
154
|
---
|
|
137
155
|
|
|
138
156
|
## 🎨 Usage Patterns
|
|
139
157
|
|
|
140
|
-
### Pattern 1:
|
|
158
|
+
### Pattern 1: Adapter with Graceful Degradation
|
|
141
159
|
|
|
142
160
|
```typescript
|
|
143
|
-
|
|
144
|
-
import { backendCircuitBreaker } from '@thorprovider/adapters/resilience';
|
|
161
|
+
import { backendCircuitBreaker } from '@thorprovider/adapters';
|
|
145
162
|
|
|
146
163
|
export async function getProducts(options: GetProductsOptions) {
|
|
147
164
|
return backendCircuitBreaker.execute(
|
|
148
|
-
() =>
|
|
165
|
+
() => commerce.getProducts(options),
|
|
149
166
|
[] // Return empty array if backend is down
|
|
150
167
|
);
|
|
151
168
|
}
|
|
@@ -156,23 +173,23 @@ export async function getProducts(options: GetProductsOptions) {
|
|
|
156
173
|
- ✅ User sees "No products" instead of error page
|
|
157
174
|
- ✅ Backend gets time to recover
|
|
158
175
|
|
|
159
|
-
### Pattern 2:
|
|
176
|
+
### Pattern 2: Server Component with Fallback UI
|
|
160
177
|
|
|
161
178
|
```typescript
|
|
162
|
-
//
|
|
163
|
-
import { backendCircuitBreaker } from '@thorprovider/adapters
|
|
164
|
-
import {
|
|
179
|
+
// app/products/page.tsx
|
|
180
|
+
import { backendCircuitBreaker } from '@thorprovider/adapters';
|
|
181
|
+
import { commerce } from '@/lib/commerce';
|
|
165
182
|
|
|
166
183
|
export default async function ProductsPage() {
|
|
167
184
|
const products = await backendCircuitBreaker.execute(
|
|
168
|
-
() => getProducts(),
|
|
169
|
-
[]
|
|
185
|
+
() => commerce.getProducts(),
|
|
186
|
+
[]
|
|
170
187
|
);
|
|
171
|
-
|
|
188
|
+
|
|
172
189
|
if (products.length === 0) {
|
|
173
|
-
return <
|
|
190
|
+
return <p>Products temporarily unavailable</p>;
|
|
174
191
|
}
|
|
175
|
-
|
|
192
|
+
|
|
176
193
|
return <ProductsGrid products={products} />;
|
|
177
194
|
}
|
|
178
195
|
```
|
|
@@ -181,14 +198,13 @@ export default async function ProductsPage() {
|
|
|
181
198
|
|
|
182
199
|
```typescript
|
|
183
200
|
// Critical operations should fail explicitly
|
|
184
|
-
async function completeCheckout(
|
|
201
|
+
async function completeCheckout(cartId: string) {
|
|
185
202
|
try {
|
|
186
203
|
return await backendCircuitBreaker.execute(
|
|
187
|
-
() =>
|
|
188
|
-
// NO fallback
|
|
204
|
+
() => commerce.completeCheckout(cartId)
|
|
205
|
+
// NO fallback — we want this to fail if backend is down
|
|
189
206
|
);
|
|
190
207
|
} catch (error) {
|
|
191
|
-
// Show error message to user
|
|
192
208
|
throw new Error('Checkout unavailable. Please try again later.');
|
|
193
209
|
}
|
|
194
210
|
}
|
|
@@ -198,10 +214,9 @@ async function completeCheckout(data: CheckoutData) {
|
|
|
198
214
|
|
|
199
215
|
## 🧪 Testing
|
|
200
216
|
|
|
201
|
-
### Unit Test Example
|
|
202
|
-
|
|
203
217
|
```typescript
|
|
204
|
-
import {
|
|
218
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
219
|
+
import { CircuitBreaker } from '@thorprovider/adapters';
|
|
205
220
|
|
|
206
221
|
describe('CircuitBreaker', () => {
|
|
207
222
|
it('opens after threshold failures', async () => {
|
|
@@ -209,28 +224,28 @@ describe('CircuitBreaker', () => {
|
|
|
209
224
|
failureThreshold: 3,
|
|
210
225
|
resetTimeout: 1000,
|
|
211
226
|
});
|
|
212
|
-
|
|
213
|
-
const mockFn =
|
|
214
|
-
|
|
227
|
+
|
|
228
|
+
const mockFn = vi.fn().mockRejectedValue(new Error('fail'));
|
|
229
|
+
|
|
215
230
|
// 3 failures
|
|
216
231
|
await expect(breaker.execute(mockFn)).rejects.toThrow();
|
|
217
232
|
await expect(breaker.execute(mockFn)).rejects.toThrow();
|
|
218
233
|
await expect(breaker.execute(mockFn)).rejects.toThrow();
|
|
219
|
-
|
|
234
|
+
|
|
220
235
|
expect(breaker.state).toBe('OPEN');
|
|
221
236
|
});
|
|
222
|
-
|
|
237
|
+
|
|
223
238
|
it('uses fallback when circuit is open', async () => {
|
|
224
239
|
const breaker = new CircuitBreaker({
|
|
225
240
|
failureThreshold: 1,
|
|
226
241
|
resetTimeout: 1000,
|
|
227
242
|
});
|
|
228
|
-
|
|
229
|
-
const mockFn =
|
|
230
|
-
|
|
243
|
+
|
|
244
|
+
const mockFn = vi.fn().mockRejectedValue(new Error('fail'));
|
|
245
|
+
|
|
231
246
|
// Open circuit
|
|
232
247
|
await breaker.execute(mockFn, 'fallback').catch(() => {});
|
|
233
|
-
|
|
248
|
+
|
|
234
249
|
// Should return fallback immediately
|
|
235
250
|
const result = await breaker.execute(mockFn, 'fallback');
|
|
236
251
|
expect(result).toBe('fallback');
|
|
@@ -239,6 +254,12 @@ describe('CircuitBreaker', () => {
|
|
|
239
254
|
});
|
|
240
255
|
```
|
|
241
256
|
|
|
257
|
+
Run tests:
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
yarn workspace @thorprovider/adapters test
|
|
261
|
+
```
|
|
262
|
+
|
|
242
263
|
---
|
|
243
264
|
|
|
244
265
|
## 📐 Architecture Decisions
|
|
@@ -271,29 +292,30 @@ describe('CircuitBreaker', () => {
|
|
|
271
292
|
|
|
272
293
|
- Don't use for client-side only operations
|
|
273
294
|
- Don't ignore OPEN state errors silently
|
|
274
|
-
- Don't set failureThreshold too low (causes flapping)
|
|
275
|
-
- Don't set resetTimeout too high (slow recovery)
|
|
295
|
+
- Don't set `failureThreshold` too low (causes flapping)
|
|
296
|
+
- Don't set `resetTimeout` too high (slow recovery)
|
|
276
297
|
|
|
277
298
|
---
|
|
278
299
|
|
|
279
300
|
## 🔮 Future Enhancements
|
|
280
301
|
|
|
281
|
-
- [ ] **
|
|
282
|
-
- [ ] **Bulkhead Pattern**: Isolate resource pools
|
|
302
|
+
- [ ] **Bulkhead Pattern**: Isolate resource pools per service
|
|
283
303
|
- [ ] **Rate Limiting**: Prevent API abuse
|
|
284
304
|
- [ ] **Metrics Export**: Prometheus/Datadog integration
|
|
285
305
|
- [ ] **Health Check Integration**: Auto-open circuit on health check fail
|
|
286
306
|
|
|
307
|
+
> ✅ **Retry Pattern** with exponential backoff is already implemented in `src/core/fetch-utils.ts`.
|
|
308
|
+
|
|
287
309
|
---
|
|
288
310
|
|
|
289
311
|
## 📚 References
|
|
290
312
|
|
|
291
|
-
- [Martin Fowler
|
|
292
|
-
- [Microsoft
|
|
313
|
+
- [Martin Fowler — Circuit Breaker](https://martinfowler.com/bliki/CircuitBreaker.html)
|
|
314
|
+
- [Microsoft — Resilience Patterns](https://docs.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker)
|
|
293
315
|
- [Netflix Hystrix](https://github.com/Netflix/Hystrix/wiki)
|
|
294
316
|
|
|
295
317
|
---
|
|
296
318
|
|
|
297
|
-
**Last Updated**:
|
|
319
|
+
**Last Updated**: 2026-05-03
|
|
298
320
|
**Status**: ✅ Active
|
|
299
321
|
**Layer**: L2 (Adapters)
|