@marianmeres/ecsuite 1.3.2 → 2.0.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/AGENTS.md CHANGED
@@ -6,7 +6,7 @@ Machine-readable documentation for AI coding assistants.
6
6
 
7
7
  ```yaml
8
8
  name: "@marianmeres/ecsuite"
9
- version: "1.1.4"
9
+ version: "1.3.2"
10
10
  type: "library"
11
11
  language: "typescript"
12
12
  runtime: "deno"
@@ -28,12 +28,13 @@ E-commerce frontend UI state management library providing:
28
28
 
29
29
  ```
30
30
  ECSuite (orchestrator)
31
- ├── CartManager [localStorage, optimistic updates]
32
- ├── WishlistManager [localStorage, optimistic updates]
33
- ├── OrderManager [server-only, read + create (returns model_id)]
31
+ ├── CartManager [localStorage, optimistic updates, per-domain mutation queue]
32
+ ├── WishlistManager [localStorage, optimistic updates, per-domain mutation queue]
33
+ ├── OrderManager [server-only, read + create list of {model_id, data}]
34
34
  ├── CustomerManager [server-only, read + update + fetchBySession]
35
- ├── PaymentManager [server-only, read + initiate + capture]
36
- └── ProductManager [in-memory cache with TTL]
35
+ ├── PaymentManager [server-only, read + initiate + capture (throw NOT_IMPL)]
36
+ └── ProductManager [in-memory TTL cache; extends BaseDomainManager;
37
+ in-flight dedup; emits domain:error]
37
38
  ```
38
39
 
39
40
  ## Directory Structure
@@ -166,7 +167,29 @@ const suite = createECSuite({
166
167
  storage: { type: "local" },
167
168
  productCacheTtl: 300000,
168
169
  autoInitialize: true,
170
+ autoResetOnIdentityChange: true, // default: reset on customerId transition
169
171
  });
172
+
173
+ // Avoid the auto-init race: callers should await the suite's readiness
174
+ // before issuing mutations.
175
+ await suite.ready;
176
+ ```
177
+
178
+ ### Identity Switch
179
+
180
+ ```typescript
181
+ // Atomic: merge context, reset all domains, re-initialize.
182
+ await suite.switchIdentity({ customerId: "another" });
183
+
184
+ // Or via setContext when autoResetOnIdentityChange is enabled.
185
+ suite.setContext({ customerId: "another" });
186
+ await suite.ready;
187
+ ```
188
+
189
+ ### Teardown
190
+
191
+ ```typescript
192
+ suite.destroy(); // unsubscribes all internal pubsub listeners
170
193
  ```
171
194
 
172
195
  ### Selective Initialization
@@ -280,20 +303,32 @@ deno publish # Publish to JSR
280
303
 
281
304
  ## Important Implementation Details
282
305
 
283
- 1. **Optimistic Updates**: `_withOptimisticUpdate()` in BaseDomainManager captures previous state before mutation, rolls back on server error.
306
+ 1. **Optimistic Updates**: `withOptimisticUpdate()` in BaseDomainManager captures previous state before mutation, rolls back to it (including `null`) on server error. Mutations are **serialized per domain** through an internal mutation queue so concurrent callers can't race their rollback snapshots.
284
307
 
285
- 2. **Persistence**: Cart and Wishlist use `@marianmeres/store` with `createStoragePersistor()` for localStorage/sessionStorage.
308
+ 2. **Persistence**: Cart and Wishlist use `@marianmeres/store` with `createStoragePersistor()` for localStorage/sessionStorage. (Cross-tab `storage` event sync is NOT yet implemented.)
286
309
 
287
- 3. **ProductManager**: Does NOT extend BaseDomainManager. Uses simple Map cache with TTL instead of state machine.
310
+ 3. **ProductManager**: Extends BaseDomainManager with `data: null` (cache lives in a private Map). Exposes `subscribe`, emits `domain:error` (without changing state — a single failed product fetch shouldn't blanket the whole domain in error). Uses an in-flight Map to dedup concurrent `getById` callers (prevents stampede on TTL expiry).
288
311
 
289
312
  4. **Event System**: Shared PubSub instance passed through ECSuite constructor. Events typed with discriminated union.
290
313
 
291
314
  5. **Context**: DomainContext (customerId, sessionId, + arbitrary properties via index signature) passed to all adapter methods for server-side identification.
292
315
 
293
- 6. **OrderCreateResult**: `OrderAdapter.create()` returns `{ model_id, data }` so consumers always get the server-assigned model ID.
316
+ 6. **OrderListData**: Stores `OrderCreateResult[]` (`{ model_id, data }`). `fetchAll`/`fetchOne`/`create` all return this envelope so orders are uniquely identifiable. Use `getOrderById(modelId)` / `getOrderDataById(modelId)`.
294
317
 
295
- 7. **Payment Write Ops**: `PaymentAdapter.initiate?()` and `capture?()` are optional methods. `PaymentManager` null-checks before calling, returns null when unavailable.
318
+ 7. **Payment Write Ops**: `PaymentAdapter.initiate?()` and `capture?()` are optional adapter methods. `PaymentManager` **throws** `NOT_IMPLEMENTED` (and emits `domain:error`) when called without an implementation — callers must catch or feature-detect (`adapter.initiate !== undefined`).
296
319
 
297
- 8. **Guest Checkout**: `CustomerAdapter.fetchBySession?()` is optional. `CustomerManager` uses it when `customerId` is absent in context, falls back to `fetch()` when unavailable.
320
+ 8. **Guest Checkout**: `CustomerAdapter.fetchBySession?()` is optional. When `customerId` is absent in context AND `fetchBySession` isn't implemented, `CustomerManager` warns and stays in `ready` with `data: null` — it does NOT silently call `fetch()` anymore (real adapters typically need a `customerId`).
298
321
 
299
322
  9. **Operation Hooks**: `ECSuite.onBeforeSync()` and `onAfterSync()` are convenience wrappers over the existing event system (no changes to BaseDomainManager).
323
+
324
+ 10. **Identity Switches**: `setContext({ customerId })` auto-resets all domains and re-initializes when the id transitions (default; opt out via `autoResetOnIdentityChange: false`). Awaitable via `suite.ready` or use `suite.switchIdentity()` directly.
325
+
326
+ 11. **Auto-init race**: Constructor with `autoInitialize: true` (default) starts initialize() but cannot await it; consumers should `await suite.ready` before issuing mutations.
327
+
328
+ 12. **Cart Quantity Validation**: `addItem` and `updateItemQuantity` throw `TypeError`/`RangeError` for `NaN`, `Infinity`, fractional, or negative values at the call site (never persisted optimistically).
329
+
330
+ ## Known limitations (not yet fixed)
331
+
332
+ - **Payment grouping by orderId**: `PaymentManager.getPayments()` returns a flat list. There is no `getPaymentsForOrder(orderId)` helper because `PaymentData` does not carry the order id; consumers fetching for multiple orders must keep their own index.
333
+ - **Pagination**: `OrderAdapter.fetchAll` and `PaymentAdapter.fetchForOrder` have no `limit/offset/cursor` params.
334
+ - **Cross-tab sync**: localStorage edits in one tab don't propagate to other tabs' stores.
package/API.md CHANGED
@@ -59,6 +59,14 @@ interface ECSuiteConfig {
59
59
  productCacheTtl?: number;
60
60
  /** Auto-initialize on creation (default: true) */
61
61
  autoInitialize?: boolean;
62
+ /** Domains to initialize (default: all) */
63
+ initializeDomains?: InitializableDomainName[];
64
+ /**
65
+ * Reset and re-initialize all domains automatically when `setContext()`
66
+ * changes `customerId`. Default: true. Set false to manage identity
67
+ * transitions yourself via `switchIdentity()`.
68
+ */
69
+ autoResetOnIdentityChange?: boolean;
62
70
  }
63
71
  ```
64
72
 
@@ -66,33 +74,48 @@ interface ECSuiteConfig {
66
74
 
67
75
  #### Properties
68
76
 
69
- | Property | Type | Description |
70
- | ---------- | ----------------- | ----------------------- |
71
- | `cart` | `CartManager` | Cart domain manager |
72
- | `wishlist` | `WishlistManager` | Wishlist domain manager |
73
- | `order` | `OrderManager` | Order domain manager |
74
- | `customer` | `CustomerManager` | Customer domain manager |
75
- | `payment` | `PaymentManager` | Payment domain manager |
76
- | `product` | `ProductManager` | Product domain manager |
77
+ | Property | Type | Description |
78
+ | ---------- | ----------------- | -------------------------------------------------------------------- |
79
+ | `cart` | `CartManager` | Cart domain manager |
80
+ | `wishlist` | `WishlistManager` | Wishlist domain manager |
81
+ | `order` | `OrderManager` | Order domain manager |
82
+ | `customer` | `CustomerManager` | Customer domain manager |
83
+ | `payment` | `PaymentManager` | Payment domain manager |
84
+ | `product` | `ProductManager` | Product domain manager |
85
+ | `ready` | `Promise<void>` | Resolves when the most recent (auto or manual) `initialize()` settles |
77
86
 
78
87
  #### Methods
79
88
 
80
- ##### initialize()
89
+ ##### initialize(domains?)
81
90
 
82
- Initialize all domains. Called automatically if `autoInitialize` is true.
91
+ Initialize the chosen domains (or all by default). Called automatically if
92
+ `autoInitialize` is true. The most recently initialized set is remembered
93
+ and re-used for identity-switch re-initialization.
83
94
 
84
95
  ```typescript
85
- async initialize(): Promise<void>
96
+ async initialize(domains?: InitializableDomainName[]): Promise<void>
86
97
  ```
87
98
 
88
99
  ##### setContext(context)
89
100
 
90
- Update context across all domains.
101
+ Update context across all domains. If `customerId` transitions and
102
+ `autoResetOnIdentityChange` is enabled (default), this also resets all
103
+ domains and re-initializes them — `suite.ready` is updated to the new
104
+ in-flight promise.
91
105
 
92
106
  ```typescript
93
107
  setContext(context: DomainContext): void
94
108
  ```
95
109
 
110
+ ##### switchIdentity(context)
111
+
112
+ Atomic identity switch: merge context, reset all domains, re-initialize.
113
+ Returns a promise that settles when the re-init completes.
114
+
115
+ ```typescript
116
+ async switchIdentity(context: DomainContext): Promise<void>
117
+ ```
118
+
96
119
  ##### getContext()
97
120
 
98
121
  Get the current context.
@@ -133,6 +156,16 @@ Reset all domains to initial state.
133
156
  reset(): void
134
157
  ```
135
158
 
159
+ ##### destroy()
160
+
161
+ Tear down the suite: unsubscribe all listeners on the internal pubsub and
162
+ clear the product cache. Persisted storage is intentionally NOT cleared;
163
+ call `reset()` first if you also want to wipe in-memory state.
164
+
165
+ ```typescript
166
+ destroy(): void
167
+ ```
168
+
136
169
  ---
137
170
 
138
171
  ## Domain Managers
@@ -381,10 +414,11 @@ async fetchAll(): Promise<void>
381
414
 
382
415
  ##### fetchOne(orderId)
383
416
 
384
- Fetch single order by ID.
417
+ Fetch single order by ID. Updates an existing entry in the local list (matched
418
+ by `model_id`) or appends a new one.
385
419
 
386
420
  ```typescript
387
- async fetchOne(orderId: UUID): Promise<OrderData | null>
421
+ async fetchOne(orderId: UUID): Promise<OrderCreateResult | null>
388
422
  ```
389
423
 
390
424
  ##### create(orderData)
@@ -392,7 +426,7 @@ async fetchOne(orderId: UUID): Promise<OrderData | null>
392
426
  Create a new order.
393
427
 
394
428
  ```typescript
395
- async create(orderData: OrderCreatePayload): Promise<OrderData | null>
429
+ async create(orderData: OrderCreatePayload): Promise<OrderCreateResult | null>
396
430
  ```
397
431
 
398
432
  **Emits:** `order:created`
@@ -407,18 +441,34 @@ getOrderCount(): number
407
441
 
408
442
  ##### getOrders()
409
443
 
410
- Get all orders.
444
+ Get all order envelopes.
445
+
446
+ ```typescript
447
+ getOrders(): OrderCreateResult[]
448
+ ```
449
+
450
+ ##### getOrderById(modelId)
451
+
452
+ Look up an order envelope by its server-assigned `model_id`.
411
453
 
412
454
  ```typescript
413
- getOrders(): OrderData[]
455
+ getOrderById(modelId: UUID): OrderCreateResult | undefined
456
+ ```
457
+
458
+ ##### getOrderDataById(modelId)
459
+
460
+ Same as `getOrderById` but returns the bare `OrderData` payload.
461
+
462
+ ```typescript
463
+ getOrderDataById(modelId: UUID): OrderData | undefined
414
464
  ```
415
465
 
416
466
  ##### getOrderByIndex(index)
417
467
 
418
- Get order by index.
468
+ Get order envelope by index.
419
469
 
420
470
  ```typescript
421
- getOrderByIndex(index: number): OrderData | undefined
471
+ getOrderByIndex(index: number): OrderCreateResult | undefined
422
472
  ```
423
473
 
424
474
  ---
@@ -521,12 +571,38 @@ async fetchForOrder(orderId: UUID): Promise<PaymentData[]>
521
571
 
522
572
  ##### fetchOne(paymentId)
523
573
 
524
- Fetch single payment by ID.
574
+ Fetch single payment by ID. Updates the existing entry (matched by
575
+ `provider_reference`) or appends a new one.
525
576
 
526
577
  ```typescript
527
578
  async fetchOne(paymentId: UUID): Promise<PaymentData | null>
528
579
  ```
529
580
 
581
+ ##### initiate(orderId, config)
582
+
583
+ Initiate a payment for an order. **Throws** if `adapter.initiate` is not
584
+ implemented (also emits `domain:error` with `code: "NOT_IMPLEMENTED"`).
585
+
586
+ ```typescript
587
+ async initiate(
588
+ orderId: UUID,
589
+ config: PaymentInitConfig,
590
+ ): Promise<PaymentIntent | null>
591
+ ```
592
+
593
+ **Emits:** `payment:initiated`
594
+
595
+ ##### capture(paymentId)
596
+
597
+ Capture a previously initiated payment. **Throws** if `adapter.capture` is
598
+ not implemented (also emits `domain:error` with `code: "NOT_IMPLEMENTED"`).
599
+
600
+ ```typescript
601
+ async capture(paymentId: UUID): Promise<PaymentData | null>
602
+ ```
603
+
604
+ **Emits:** `payment:captured`
605
+
530
606
  ##### getPaymentCount()
531
607
 
532
608
  Get number of fetched payments.
@@ -563,7 +639,12 @@ clearCache(): void
563
639
 
564
640
  ### ProductManager
565
641
 
566
- Manages product data with in-memory caching. Unlike other managers, uses a simple cache layer instead of state machine.
642
+ Manages product data with in-memory TTL caching. Extends `BaseDomainManager`
643
+ for unified observability (`subscribe`, `domain:error`, `domain:state:changed`)
644
+ but skips per-product state-machine transitions — fetching a single product
645
+ never blankets the whole domain in `"syncing"` or `"error"`. Concurrent
646
+ `getById()` callers for the same id share a single in-flight request
647
+ (stampede dedup).
567
648
 
568
649
  #### Constructor Options
569
650
 
@@ -645,7 +726,6 @@ interface CartAdapter {
645
726
  updateItem(productId: UUID, quantity: number, ctx: DomainContext): Promise<CartData>;
646
727
  removeItem(productId: UUID, ctx: DomainContext): Promise<CartData>;
647
728
  clear(ctx: DomainContext): Promise<CartData>;
648
- sync(cart: CartData, ctx: DomainContext): Promise<CartData>;
649
729
  }
650
730
  ```
651
731
 
@@ -657,16 +737,19 @@ interface WishlistAdapter {
657
737
  addItem(productId: UUID, ctx: DomainContext): Promise<WishlistData>;
658
738
  removeItem(productId: UUID, ctx: DomainContext): Promise<WishlistData>;
659
739
  clear(ctx: DomainContext): Promise<WishlistData>;
660
- sync(wishlist: WishlistData, ctx: DomainContext): Promise<WishlistData>;
661
740
  }
662
741
  ```
663
742
 
664
743
  ### OrderAdapter
665
744
 
745
+ > Returns `OrderCreateResult` envelopes (`{ model_id, data }`) so the
746
+ > manager can identify orders by `model_id` (bare `OrderData` has only an
747
+ > open index signature).
748
+
666
749
  ```typescript
667
750
  interface OrderAdapter {
668
- fetchAll(ctx: DomainContext): Promise<OrderData[]>;
669
- fetchOne(orderId: UUID, ctx: DomainContext): Promise<OrderData>;
751
+ fetchAll(ctx: DomainContext): Promise<OrderCreateResult[]>;
752
+ fetchOne(orderId: UUID, ctx: DomainContext): Promise<OrderCreateResult>;
670
753
  create(order: OrderCreatePayload, ctx: DomainContext): Promise<OrderData>;
671
754
  }
672
755
  ```
package/README.md CHANGED
@@ -41,6 +41,10 @@ const suite = createECSuite({
41
41
  },
42
42
  });
43
43
 
44
+ // `autoInitialize` is true by default; await `suite.ready` so consumer
45
+ // mutations don't race the in-flight initial fetches.
46
+ await suite.ready;
47
+
44
48
  // Subscribe to cart state (Svelte-compatible)
45
49
  suite.cart.subscribe((state) => {
46
50
  console.log(state.state, state.data);
@@ -57,6 +61,23 @@ suite.on("domain:error", (event) => {
57
61
  await suite.cart.addItem({ product_id: "prod-1", quantity: 2 });
58
62
  ```
59
63
 
64
+ ### Identity switches (login / logout)
65
+
66
+ When the user signs in or out, use `switchIdentity()` (or just call
67
+ `setContext()` with a different `customerId` — auto-reset is on by default):
68
+
69
+ ```typescript
70
+ await suite.switchIdentity({ customerId: "another-user" });
71
+ // All domains reset, re-initialized for the new identity, and `suite.ready`
72
+ // resolves once the new fetches settle.
73
+ ```
74
+
75
+ ### Teardown
76
+
77
+ ```typescript
78
+ suite.destroy(); // unsubscribes every internal event listener
79
+ ```
80
+
60
81
  ## Domains
61
82
 
62
83
  | Domain | Persistence | Operations |
@@ -145,6 +166,55 @@ suite.once("order:created", (event) => {
145
166
 
146
167
  For complete API documentation, see [API.md](API.md).
147
168
 
169
+ ## Migration to next major
170
+
171
+ This release tightens correctness in several places. Breaking changes:
172
+
173
+ - **`OrderAdapter` returns `OrderCreateResult`** for both `fetchAll` and
174
+ `fetchOne` (`{ model_id, data }`) so orders are uniquely identifiable.
175
+ `OrderListData.orders` is now `OrderCreateResult[]`. Use the new
176
+ `orders.getOrderById(modelId)` / `getOrderDataById(modelId)` helpers, or
177
+ read `result.data.<field>` on returned envelopes.
178
+ - **`CartAdapter.sync()` and `WishlistAdapter.sync()` removed** — they were
179
+ never called by the manager.
180
+ - **`PaymentManager.initiate()` / `capture()` throw `NOT_IMPLEMENTED`** when
181
+ the adapter doesn't implement the optional method (previously returned
182
+ `null` silently). `domain:error` is also emitted.
183
+ - **`CustomerManager.update()` throws `NOT_IMPLEMENTED`** when no adapter is
184
+ configured (previously silent no-op).
185
+ - **`CustomerManager` no longer falls through to `fetch()`** when both
186
+ `customerId` is missing AND `adapter.fetchBySession` is undefined; it
187
+ now warns and stays in `ready` with `data: null`. Pass `customerId` in
188
+ context, or implement `fetchBySession`.
189
+ - **`CartManager.addItem` / `updateItemQuantity`** validate the quantity
190
+ (must be a finite, non-negative integer); invalid values throw at the
191
+ call site instead of being persisted optimistically.
192
+ - **`ProductManager` now extends `BaseDomainManager`** — exposes `subscribe`,
193
+ emits `domain:error`, and gains an `initialize()` no-op. `setAdapter` /
194
+ `getAdapter` / `setContext` / `getContext` keep the same signatures.
195
+ - **`InitializableDomainName`** now includes `"product"` for parity with
196
+ the other domains.
197
+
198
+ New additions:
199
+
200
+ - `suite.ready: Promise<void>` — resolves when the most recent (auto or
201
+ manual) `initialize()` settles.
202
+ - `suite.switchIdentity(context)` — atomic identity switch (merge context,
203
+ reset domains, re-initialize). Returns a promise.
204
+ - `suite.destroy()` — unsubscribes all internal pubsub listeners.
205
+ - `ECSuiteConfig.autoResetOnIdentityChange` (default `true`) — opt out of
206
+ the auto-reset path on `setContext()` if you manage identity transitions
207
+ yourself.
208
+ - `OrderManager.getOrderById(modelId)` / `getOrderDataById(modelId)` lookup
209
+ helpers.
210
+ - Per-domain mutation queue (`withOptimisticUpdate` is serialized per
211
+ manager) — concurrent `cart.addItem(...)` calls no longer race their
212
+ rollback snapshots.
213
+ - Cache stampede dedup in `ProductManager.getById` — concurrent callers
214
+ for the same id share a single in-flight request.
215
+ - Mock adapters now dispatch `forceError.code` (any name from `HTTP_ERROR`)
216
+ so tests can simulate `NotFound`, `Conflict`, etc., not just `BadRequest`.
217
+
148
218
  ## License
149
219
 
150
220
  MIT
@@ -2,6 +2,7 @@
2
2
  * Mock cart adapter for testing.
3
3
  */
4
4
  import type { CartData } from "@marianmeres/collection-types";
5
+ import { HTTP_ERROR } from "@marianmeres/http-utils";
5
6
  import type { CartAdapter } from "../../types/adapter.js";
6
7
  /** Mock cart adapter options */
7
8
  export interface MockCartAdapterOptions {
@@ -11,8 +12,9 @@ export interface MockCartAdapterOptions {
11
12
  delay?: number;
12
13
  /** Force errors for testing */
13
14
  forceError?: {
14
- operation?: "fetch" | "addItem" | "updateItem" | "removeItem" | "clear" | "sync";
15
- code?: string;
15
+ operation?: "fetch" | "addItem" | "updateItem" | "removeItem" | "clear";
16
+ /** HTTP error class name from `HTTP_ERROR` (default: "BadRequest") */
17
+ code?: keyof typeof HTTP_ERROR;
16
18
  message?: string;
17
19
  };
18
20
  }
@@ -11,7 +11,9 @@ export function createMockCartAdapter(options = {}) {
11
11
  const wait = () => new Promise((r) => setTimeout(r, delay));
12
12
  const maybeThrow = (operation) => {
13
13
  if (options.forceError?.operation === operation) {
14
- throw new HTTP_ERROR.BadRequest(options.forceError.message ?? `Mock error for ${operation}`);
14
+ const code = options.forceError.code ?? "BadRequest";
15
+ const Ctor = HTTP_ERROR[code] ?? HTTP_ERROR.BadRequest;
16
+ throw new Ctor(options.forceError.message ?? `Mock error for ${operation}`);
15
17
  }
16
18
  };
17
19
  return {
@@ -58,11 +60,5 @@ export function createMockCartAdapter(options = {}) {
58
60
  cart = { items: [] };
59
61
  return structuredClone(cart);
60
62
  },
61
- async sync(newCart, _ctx) {
62
- await wait();
63
- maybeThrow("sync");
64
- cart = structuredClone(newCart);
65
- return structuredClone(cart);
66
- },
67
63
  };
68
64
  }
@@ -2,6 +2,7 @@
2
2
  * Mock customer adapter for testing.
3
3
  */
4
4
  import type { CustomerData } from "@marianmeres/collection-types";
5
+ import { HTTP_ERROR } from "@marianmeres/http-utils";
5
6
  import type { CustomerAdapter } from "../../types/adapter.js";
6
7
  /** Mock customer adapter options */
7
8
  export interface MockCustomerAdapterOptions {
@@ -14,7 +15,8 @@ export interface MockCustomerAdapterOptions {
14
15
  /** Force errors for testing */
15
16
  forceError?: {
16
17
  operation?: "fetch" | "fetchBySession" | "update";
17
- code?: string;
18
+ /** HTTP error class name from `HTTP_ERROR` (default: "BadRequest") */
19
+ code?: keyof typeof HTTP_ERROR;
18
20
  message?: string;
19
21
  };
20
22
  }
@@ -14,8 +14,9 @@ export function createMockCustomerAdapter(options = {}) {
14
14
  const wait = () => new Promise((r) => setTimeout(r, delay));
15
15
  const maybeThrow = (operation) => {
16
16
  if (options.forceError?.operation === operation) {
17
- throw new HTTP_ERROR.BadRequest(options.forceError.message ??
18
- `Mock error for ${operation}`);
17
+ const code = options.forceError.code ?? "BadRequest";
18
+ const Ctor = HTTP_ERROR[code] ?? HTTP_ERROR.BadRequest;
19
+ throw new Ctor(options.forceError.message ?? `Mock error for ${operation}`);
19
20
  }
20
21
  };
21
22
  const hasFetchBySession = "guestData" in options ||
@@ -2,6 +2,7 @@
2
2
  * Mock order adapter for testing.
3
3
  */
4
4
  import type { OrderData } from "@marianmeres/collection-types";
5
+ import { HTTP_ERROR } from "@marianmeres/http-utils";
5
6
  import type { OrderAdapter } from "../../types/adapter.js";
6
7
  /** Mock order adapter options */
7
8
  export interface MockOrderAdapterOptions {
@@ -12,7 +13,8 @@ export interface MockOrderAdapterOptions {
12
13
  /** Force errors for testing */
13
14
  forceError?: {
14
15
  operation?: "fetchAll" | "fetchOne" | "create";
15
- code?: string;
16
+ /** HTTP error class name from `HTTP_ERROR` (default: "BadRequest") */
17
+ code?: keyof typeof HTTP_ERROR;
16
18
  message?: string;
17
19
  };
18
20
  }
@@ -5,17 +5,19 @@ import { HTTP_ERROR } from "@marianmeres/http-utils";
5
5
  /** Create a mock order adapter for testing */
6
6
  export function createMockOrderAdapter(options = {}) {
7
7
  const delay = options.delay ?? 50;
8
- let orders = options.initialData
8
+ const orders = options.initialData
9
9
  ? options.initialData.map((o, i) => ({
10
- ...structuredClone(o),
11
10
  model_id: `order-${i + 1}`,
11
+ data: structuredClone(o),
12
12
  }))
13
13
  : [];
14
14
  let orderIdCounter = orders.length;
15
15
  const wait = () => new Promise((r) => setTimeout(r, delay));
16
16
  const maybeThrow = (operation) => {
17
17
  if (options.forceError?.operation === operation) {
18
- throw new HTTP_ERROR.BadRequest(options.forceError.message ?? `Mock error for ${operation}`);
18
+ const code = options.forceError.code ?? "BadRequest";
19
+ const Ctor = HTTP_ERROR[code] ?? HTTP_ERROR.BadRequest;
20
+ throw new Ctor(options.forceError.message ?? `Mock error for ${operation}`);
19
21
  }
20
22
  };
21
23
  return {
@@ -41,8 +43,8 @@ export function createMockOrderAdapter(options = {}) {
41
43
  ...structuredClone(orderData),
42
44
  status: "pending",
43
45
  };
44
- orders.push({ ...data, model_id });
45
- return { model_id, data: structuredClone(data) };
46
+ orders.push({ model_id, data: structuredClone(data) });
47
+ return { model_id, data };
46
48
  },
47
49
  };
48
50
  }
@@ -2,6 +2,7 @@
2
2
  * Mock payment adapter for testing.
3
3
  */
4
4
  import type { PaymentData, UUID } from "@marianmeres/collection-types";
5
+ import { HTTP_ERROR } from "@marianmeres/http-utils";
5
6
  import type { PaymentAdapter } from "../../types/adapter.js";
6
7
  /** Mock payment adapter options */
7
8
  export interface MockPaymentAdapterOptions {
@@ -12,7 +13,8 @@ export interface MockPaymentAdapterOptions {
12
13
  /** Force errors for testing */
13
14
  forceError?: {
14
15
  operation?: "fetchForOrder" | "fetchOne" | "initiate" | "capture";
15
- code?: string;
16
+ /** HTTP error class name from `HTTP_ERROR` (default: "BadRequest") */
17
+ code?: keyof typeof HTTP_ERROR;
16
18
  message?: string;
17
19
  };
18
20
  }
@@ -11,11 +11,19 @@ export function createMockPaymentAdapter(options = {}) {
11
11
  const wait = () => new Promise((r) => setTimeout(r, delay));
12
12
  const maybeThrow = (operation) => {
13
13
  if (options.forceError?.operation === operation) {
14
- throw new HTTP_ERROR.BadRequest(options.forceError.message ??
15
- `Mock error for ${operation}`);
14
+ const code = options.forceError.code ?? "BadRequest";
15
+ const Ctor = HTTP_ERROR[code] ?? HTTP_ERROR.BadRequest;
16
+ throw new Ctor(options.forceError.message ?? `Mock error for ${operation}`);
16
17
  }
17
18
  };
18
- const getAllPayments = () => Object.values(payments).flat();
19
+ const findPaymentByRef = (ref) => {
20
+ for (const [orderId, list] of Object.entries(payments)) {
21
+ const payment = list.find((p) => p.provider_reference === ref);
22
+ if (payment)
23
+ return { payment, orderId: orderId };
24
+ }
25
+ return null;
26
+ };
19
27
  return {
20
28
  async fetchForOrder(orderId, _ctx) {
21
29
  await wait();
@@ -29,17 +37,26 @@ export function createMockPaymentAdapter(options = {}) {
29
37
  async fetchOne(paymentId, _ctx) {
30
38
  await wait();
31
39
  maybeThrow("fetchOne");
32
- const allPayments = getAllPayments();
33
- const payment = allPayments.find((p) => p.provider_reference === paymentId);
34
- if (!payment) {
40
+ const found = findPaymentByRef(paymentId);
41
+ if (!found) {
35
42
  throw new HTTP_ERROR.NotFound(`Payment ${paymentId} not found`);
36
43
  }
37
- return structuredClone(payment);
44
+ return structuredClone(found.payment);
38
45
  },
39
46
  async initiate(orderId, config, _ctx) {
40
47
  await wait();
41
48
  maybeThrow("initiate");
42
49
  const id = `pi_${Math.random().toString(36).slice(2)}`;
50
+ // Persist the initiated (pending) payment so capture() can find it.
51
+ if (!payments[orderId])
52
+ payments[orderId] = [];
53
+ payments[orderId].push({
54
+ provider: config.provider,
55
+ status: "pending",
56
+ amount: config.amount,
57
+ currency: config.currency,
58
+ provider_reference: id,
59
+ });
43
60
  return {
44
61
  id,
45
62
  redirect_url: `https://mock-payment.test/pay/${id}`,
@@ -52,19 +69,16 @@ export function createMockPaymentAdapter(options = {}) {
52
69
  async capture(paymentId, _ctx) {
53
70
  await wait();
54
71
  maybeThrow("capture");
55
- const payment = {
56
- provider: "mock",
57
- status: "completed",
58
- amount: 0,
59
- currency: "EUR",
60
- provider_reference: paymentId,
61
- };
62
- // Store captured payment
63
- const key = Object.keys(payments)[0] ?? "mock-order";
64
- if (!payments[key])
65
- payments[key] = [];
66
- payments[key].push(payment);
67
- return structuredClone(payment);
72
+ // Look up the (pending) payment by reference and complete it.
73
+ // Preserves the original amount/currency/provider that initiate()
74
+ // recorded — bypassing this would force the test to assert against
75
+ // hardcoded zeros.
76
+ const found = findPaymentByRef(paymentId);
77
+ if (!found) {
78
+ throw new HTTP_ERROR.NotFound(`Payment ${paymentId} not found`);
79
+ }
80
+ found.payment.status = "completed";
81
+ return structuredClone(found.payment);
68
82
  },
69
83
  };
70
84
  }