@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.
@@ -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
+ }
@@ -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
- ## 📦 Export
24
+ ## 📦 Exports
14
25
 
15
26
  ```typescript
16
- import { CircuitBreaker, backendCircuitBreaker } from '@thorprovider/adapters/resilience';
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/resilience';
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/resilience';
138
+ import { backendCircuitBreaker } from '@thorprovider/adapters';
121
139
 
122
140
  // Use in adapters
123
141
  async function getProducts() {
124
142
  return backendCircuitBreaker.execute(
125
- () => fetch('/api/products'),
143
+ () => commerce.getProducts(),
126
144
  [] // Empty array fallback
127
145
  );
128
146
  }
129
147
  ```
130
148
 
131
- **Configuration:**
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: L2 Adapter with Graceful Degradation
158
+ ### Pattern 1: Adapter with Graceful Degradation
141
159
 
142
160
  ```typescript
143
- // packages/adapters/src/medusa/product-adapter.ts
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
- () => medusaClient.products.list(options),
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: L5 Server Component with Fallback UI
176
+ ### Pattern 2: Server Component with Fallback UI
160
177
 
161
178
  ```typescript
162
- // packages/core/app/products/page.tsx
163
- import { backendCircuitBreaker } from '@thorprovider/adapters/resilience';
164
- import { getProducts } from '@thorprovider/adapters/medusa';
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
- [] // Fallback to empty array
185
+ () => commerce.getProducts(),
186
+ []
170
187
  );
171
-
188
+
172
189
  if (products.length === 0) {
173
- return <EmptyState message="Products temporarily unavailable" />;
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(data: CheckoutData) {
201
+ async function completeCheckout(cartId: string) {
185
202
  try {
186
203
  return await backendCircuitBreaker.execute(
187
- () => medusaClient.carts.complete(data)
188
- // NO fallback - we want this to fail if backend is down
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 { CircuitBreaker } from '@thorprovider/adapters/resilience';
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 = jest.fn().mockRejectedValue(new Error('fail'));
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 = jest.fn().mockRejectedValue(new Error('fail'));
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
- - [ ] **Retry Pattern**: Exponential backoff for transient failures
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 - Circuit Breaker](https://martinfowler.com/bliki/CircuitBreaker.html)
292
- - [Microsoft - Resilience Patterns](https://docs.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker)
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**: March 24, 2026
319
+ **Last Updated**: 2026-05-03
298
320
  **Status**: ✅ Active
299
321
  **Layer**: L2 (Adapters)