@acmekit/acmekit 2.13.84 → 2.13.86

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.
@@ -382,6 +382,74 @@ integrationTestRunner<IPostModuleService>({
382
382
 
383
383
  ---
384
384
 
385
+ ## Unit Test Template (No Framework Bootstrap)
386
+
387
+ For providers, utilities, and standalone classes. Uses plain Jest with `jest.mock`.
388
+
389
+ **CRITICAL — jest.mock hoisting:** `jest.mock()` factories are hoisted above `const`/`let` by SWC. Never reference file-level variables inside a factory. Create mocks INSIDE the factory and access via `require()`.
390
+
391
+ ```typescript
392
+ // Provider unit test pattern
393
+ jest.mock("external-sdk", () => {
394
+ const mocks = {
395
+ doThing: jest.fn(),
396
+ }
397
+ const MockClient = jest.fn().mockImplementation(() => ({
398
+ doThing: mocks.doThing,
399
+ }))
400
+ return { Client: MockClient, __mocks: mocks }
401
+ })
402
+
403
+ const { __mocks: sdkMocks } = require("external-sdk")
404
+
405
+ import MyProvider from "../my-provider"
406
+
407
+ describe("MyProvider", () => {
408
+ let provider: MyProvider
409
+ const mockContainer = {} as any
410
+ const defaultOptions = { apiKey: "test-key" }
411
+
412
+ beforeEach(() => {
413
+ jest.clearAllMocks()
414
+ provider = new MyProvider(mockContainer, defaultOptions)
415
+ })
416
+
417
+ describe("static identifier", () => {
418
+ it("should have correct identifier", () => {
419
+ expect(MyProvider.identifier).toBe("my-provider")
420
+ })
421
+ })
422
+
423
+ describe("validateOptions", () => {
424
+ it("should accept valid options", () => {
425
+ expect(() =>
426
+ MyProvider.validateOptions({ apiKey: "key" })
427
+ ).not.toThrow()
428
+ })
429
+
430
+ it("should reject missing required option", () => {
431
+ expect(() => MyProvider.validateOptions({})).toThrow()
432
+ })
433
+ })
434
+
435
+ describe("doSomething", () => {
436
+ it("should delegate to SDK", async () => {
437
+ sdkMocks.doThing.mockResolvedValue({ success: true })
438
+ const result = await provider.doSomething({ input: "test" })
439
+ expect(result.success).toBe(true)
440
+ })
441
+ })
442
+ })
443
+ ```
444
+
445
+ **Timer mocking:** If code under test uses `setTimeout` or `sleep()`, use `jest.useFakeTimers()` + `jest.advanceTimersByTimeAsync()` or mock the sleep method.
446
+
447
+ **SWC regex:** Complex regex literals may fail. Use `new RegExp("...")` instead.
448
+
449
+ **Error paths:** Read the implementation to check whether errors are thrown or returned. Don't assume from the return type.
450
+
451
+ ---
452
+
385
453
  ## Asserting Domain Events (module mode)
386
454
 
387
455
  ```typescript
@@ -486,3 +554,7 @@ it("should emit post.created event", async () => {
486
554
  - Direct workflow execution: `workflow(getContainer()).run({ input })`
487
555
  - `waitSubscribersExecution` promise BEFORE triggering event
488
556
  - NEVER use JSDoc blocks or type casts in test files
557
+ - **Always `beforeEach(() => jest.clearAllMocks())`** in unit tests — mock state leaks between describes
558
+ - **Never reference file-level `const`/`let` inside `jest.mock()` factories** — TDZ error
559
+ - **Mock timers or sleep** when code under test has delays — prevents timeouts
560
+ - **Use `new RegExp()` over complex regex literals** — SWC parser has edge cases
@@ -22,6 +22,10 @@ paths:
22
22
 
23
23
  **`.rejects.toThrow()` DOES work for service errors** (e.g., `service.retrievePost("bad-id")`). Services throw real `Error` instances. Only workflow errors are serialized.
24
24
 
25
+ **jest.mock factories are hoisted.** `jest.mock()` runs BEFORE `const`/`let` declarations. Never reference file-level variables inside a `jest.mock()` factory — see "Unit Tests" section below.
26
+
27
+ **Always add `jest.clearAllMocks()` in `beforeEach`.** Mock state leaks between test blocks. Without explicit cleanup, assertions on mock call counts fail from prior-test contamination.
28
+
25
29
  **Inline auth setup.** There is no `createAdminUser` helper. Resolve `Modules.USER`, `Modules.AUTH`, `Modules.API_KEY` from the container and create credentials directly in `beforeEach`.
26
30
 
27
31
  ---
@@ -248,11 +252,10 @@ integrationTestRunner({
248
252
  ### HTTP test lifecycle
249
253
 
250
254
  - `beforeAll`: boots full Express app (resolves plugins, runs migrations, starts HTTP server)
251
- - `beforeEach`: truncates all tables, re-runs module loaders, runs `createDefaultsWorkflow`
252
- - `afterEach`: **automatically calls `waitWorkflowExecutions()`** then `dbUtils.teardown()`
255
+ - `beforeEach`: **automatically calls `waitWorkflowExecutions()`** from the previous test, then truncates all tables, re-runs module loaders, runs `createDefaultsWorkflow` (first test skips reset — state is already fresh after setup)
253
256
  - `afterAll`: drops DB, shuts down Express
254
257
 
255
- **IMPORTANT:** The runner calls `waitWorkflowExecutions()` automatically in `afterEach`. You do NOT need to call it in your tests unless you need workflow results BETWEEN two API calls in the same test.
258
+ **IMPORTANT:** The runner calls `waitWorkflowExecutions()` automatically during the reset phase (before each subsequent test). You do NOT need to call it in your tests unless you need workflow results BETWEEN two API calls in the same test.
256
259
 
257
260
  ---
258
261
 
@@ -719,6 +722,188 @@ getContainer().resolve("blogModuleService") // ❌
719
722
 
720
723
  ---
721
724
 
725
+ ## Unit Tests (No Framework Bootstrap)
726
+
727
+ For providers, utility functions, and standalone classes that don't need the database or AcmeKit container. Uses plain Jest — no `integrationTestRunner`.
728
+
729
+ **File naming:** `src/**/__tests__/<name>.unit.spec.ts` — matches `TEST_TYPE=unit` in `jest.config.js`.
730
+
731
+ ### jest.mock Hoisting (Temporal Dead Zone)
732
+
733
+ `jest.mock()` factories are **hoisted above all `const`/`let` declarations** by SWC/Babel. Referencing a file-level `const` inside a `jest.mock()` factory causes `ReferenceError: Cannot access before initialization`.
734
+
735
+ ```typescript
736
+ // WRONG — TDZ error: mockSign is not yet initialized when factory runs
737
+ const mockSign = jest.fn()
738
+ jest.mock("tronweb", () => ({
739
+ TronWeb: jest.fn().mockImplementation(() => ({ trx: { sign: mockSign } })),
740
+ }))
741
+
742
+ // RIGHT — create mocks INSIDE the factory, expose via module return
743
+ jest.mock("tronweb", () => {
744
+ const mocks = {
745
+ sign: jest.fn(),
746
+ isAddress: jest.fn().mockReturnValue(true),
747
+ }
748
+ const MockTronWeb = jest.fn().mockImplementation(() => ({
749
+ trx: { sign: mocks.sign },
750
+ }))
751
+ MockTronWeb.isAddress = mocks.isAddress
752
+ return { TronWeb: MockTronWeb, __mocks: mocks }
753
+ })
754
+
755
+ // Access mocks after jest.mock via require()
756
+ const { __mocks: tronMocks } = require("tronweb")
757
+ ```
758
+
759
+ **Rule:** All mock state must live INSIDE the `jest.mock()` factory or be accessed via `require()` after the mock is set up. Never reference file-level `const`/`let` from inside a `jest.mock()` factory.
760
+
761
+ ### Provider Unit Test Pattern
762
+
763
+ Providers have a specific structure: constructor receives `(container, options)`, static `identifier`, optional static `validateOptions`. Test each part:
764
+
765
+ ```typescript
766
+ jest.mock("external-sdk", () => {
767
+ const mocks = {
768
+ doThing: jest.fn(),
769
+ }
770
+ const MockClient = jest.fn().mockImplementation(() => ({
771
+ doThing: mocks.doThing,
772
+ }))
773
+ return { Client: MockClient, __mocks: mocks }
774
+ })
775
+
776
+ const { __mocks: sdkMocks } = require("external-sdk")
777
+
778
+ import MyProvider from "../my-provider"
779
+
780
+ describe("MyProvider", () => {
781
+ let provider: MyProvider
782
+
783
+ const mockContainer = {} as any
784
+ const defaultOptions = { apiKey: "test-key" }
785
+
786
+ beforeEach(() => {
787
+ jest.clearAllMocks()
788
+ provider = new MyProvider(mockContainer, defaultOptions)
789
+ })
790
+
791
+ describe("static identifier", () => {
792
+ it("should have correct identifier", () => {
793
+ expect(MyProvider.identifier).toBe("my-provider")
794
+ })
795
+ })
796
+
797
+ describe("validateOptions", () => {
798
+ it("should accept valid options", () => {
799
+ expect(() =>
800
+ MyProvider.validateOptions({ apiKey: "key" })
801
+ ).not.toThrow()
802
+ })
803
+
804
+ it("should reject missing required option", () => {
805
+ expect(() => MyProvider.validateOptions({})).toThrow()
806
+ })
807
+ })
808
+
809
+ describe("doSomething", () => {
810
+ it("should delegate to SDK", async () => {
811
+ sdkMocks.doThing.mockResolvedValue({ success: true })
812
+ const result = await provider.doSomething({ input: "test" })
813
+ expect(result.success).toBe(true)
814
+ expect(sdkMocks.doThing).toHaveBeenCalledWith(
815
+ expect.objectContaining({ input: "test" })
816
+ )
817
+ })
818
+ })
819
+ })
820
+ ```
821
+
822
+ ### Mock Cleanup Between Tests
823
+
824
+ Mock state leaks between `describe` and `it` blocks. **Always add cleanup:**
825
+
826
+ ```typescript
827
+ // Recommended: file-level cleanup
828
+ beforeEach(() => {
829
+ jest.clearAllMocks()
830
+ })
831
+
832
+ // Alternative: per-describe when different describes need different setups
833
+ describe("feature A", () => {
834
+ beforeEach(() => {
835
+ jest.clearAllMocks()
836
+ mockFn.mockResolvedValue("A result")
837
+ })
838
+ })
839
+ ```
840
+
841
+ Without `jest.clearAllMocks()`, a mock called in one test still shows those calls in the next test:
842
+
843
+ ```typescript
844
+ expect(mockSign).not.toHaveBeenCalled() // FAILS — called by prior test
845
+ ```
846
+
847
+ ### Testing Code with Timers
848
+
849
+ When code under test calls `setTimeout`, `setInterval`, or a `sleep()` function, tests time out or run slowly.
850
+
851
+ ```typescript
852
+ // Option 1: Fake timers (for setTimeout/setInterval)
853
+ beforeEach(() => {
854
+ jest.useFakeTimers()
855
+ })
856
+ afterEach(() => {
857
+ jest.useRealTimers()
858
+ })
859
+ it("should retry after delay", async () => {
860
+ const promise = provider.retryOperation()
861
+ await jest.advanceTimersByTimeAsync(3000)
862
+ const result = await promise
863
+ expect(result).toBeDefined()
864
+ })
865
+
866
+ // Option 2: Mock the sleep method (for custom sleep/delay functions)
867
+ it("should complete without waiting", async () => {
868
+ jest.spyOn(provider as any, "sleep_").mockResolvedValue(undefined)
869
+ const result = await provider.longRunningOperation()
870
+ expect(result).toBeDefined()
871
+ })
872
+ ```
873
+
874
+ ### SWC Regex Limitation
875
+
876
+ SWC's regex parser fails on certain complex regex literals. If you get a `Syntax Error` from SWC on a line with a regex:
877
+
878
+ ```typescript
879
+ // WRONG — SWC may fail to parse this
880
+ const pattern = /^(\*|[0-9]+)(\/[0-9]+)?$|^\*\/[0-9]+$/
881
+
882
+ // RIGHT — use RegExp constructor
883
+ const pattern = new RegExp("^(\\*|[0-9]+)(\\/[0-9]+)?$|^\\*\\/[0-9]+$")
884
+ ```
885
+
886
+ ### Verifying Error Paths
887
+
888
+ When testing error cases, **read the implementation** to determine whether the method throws or returns an error object. Don't assume from the return type alone.
889
+
890
+ ```typescript
891
+ // If the method catches errors and returns { success: false }:
892
+ const result = await provider.process(badInput)
893
+ expect(result.success).toBe(false)
894
+
895
+ // If the method throws (no internal try/catch on that path):
896
+ await expect(provider.process(badInput)).rejects.toThrow("invalid")
897
+
898
+ // If validation runs BEFORE a try/catch block:
899
+ // validateInput() throws → not caught by the try/catch in process()
900
+ await expect(provider.process(badInput)).rejects.toThrow("validation")
901
+ ```
902
+
903
+ **Tip:** Look for `try/catch` blocks in the implementation. Code that runs BEFORE or OUTSIDE a `try/catch` throws directly. Code INSIDE a `try/catch` may return an error result instead.
904
+
905
+ ---
906
+
722
907
  ## Anti-Patterns — NEVER Do These
723
908
 
724
909
  ```typescript
@@ -848,4 +1033,39 @@ import { ContainerRegistrationKeys } from "@acmekit/framework/utils" // ❌ if
848
1033
 
849
1034
  // WRONG — type casts in tests
850
1035
  const filtered = (operations as Array<{ status: string }>).filter(...) // ❌
1036
+
1037
+ // WRONG — referencing file-level const/let inside jest.mock factory (TDZ)
1038
+ const mockFn = jest.fn()
1039
+ jest.mock("lib", () => ({ thing: mockFn })) // ❌ ReferenceError
1040
+ // RIGHT — create mocks inside factory, access via require()
1041
+ jest.mock("lib", () => {
1042
+ const mocks = { thing: jest.fn() }
1043
+ return { ...mocks, __mocks: mocks }
1044
+ })
1045
+ const { __mocks } = require("lib")
1046
+
1047
+ // WRONG — no mock cleanup between tests
1048
+ describe("A", () => { it("calls mock", () => { mockFn() }) })
1049
+ describe("B", () => { it("mock is clean", () => {
1050
+ expect(mockFn).not.toHaveBeenCalled() // ❌ fails — leaked from A
1051
+ }) })
1052
+ // RIGHT — add beforeEach(() => jest.clearAllMocks())
1053
+
1054
+ // WRONG — real timers in unit tests cause timeouts
1055
+ it("should process", async () => {
1056
+ await relay.process(data) // ❌ hangs — code calls sleep(3000) internally
1057
+ })
1058
+ // RIGHT — mock timers or sleep method
1059
+ jest.useFakeTimers()
1060
+ // or: jest.spyOn(relay as any, "sleep_").mockResolvedValue(undefined)
1061
+
1062
+ // WRONG — complex regex literal that SWC can't parse
1063
+ const re = /^(\*|[0-9]+)(\/[0-9]+)?$/ // ❌ SWC Syntax Error
1064
+ // RIGHT
1065
+ const re = new RegExp("^(\\*|[0-9]+)(\\/[0-9]+)?$")
1066
+
1067
+ // WRONG — assuming method returns error without reading implementation
1068
+ const result = await provider.process(bad)
1069
+ expect(result.success).toBe(false) // ❌ actually throws
1070
+ // RIGHT — read implementation first to check if it throws or returns
851
1071
  ```
@@ -238,6 +238,47 @@ integrationTestRunner({
238
238
  })
239
239
  ```
240
240
 
241
+ #### Unit Test (No Framework Bootstrap)
242
+
243
+ For providers, utilities, and standalone classes. **CRITICAL:** `jest.mock()` factories are hoisted above `const`/`let`. Create mocks INSIDE the factory, access via `require()`.
244
+
245
+ ```typescript
246
+ jest.mock("external-sdk", () => {
247
+ const mocks = { doThing: jest.fn() }
248
+ const MockClient = jest.fn().mockImplementation(() => ({
249
+ doThing: mocks.doThing,
250
+ }))
251
+ return { Client: MockClient, __mocks: mocks }
252
+ })
253
+
254
+ const { __mocks: sdkMocks } = require("external-sdk")
255
+
256
+ import MyProvider from "../my-provider"
257
+
258
+ describe("MyProvider", () => {
259
+ const mockContainer = {} as any
260
+
261
+ beforeEach(() => {
262
+ jest.clearAllMocks()
263
+ })
264
+
265
+ it("should have correct identifier", () => {
266
+ expect(MyProvider.identifier).toBe("my-provider")
267
+ })
268
+
269
+ it("should delegate to SDK", async () => {
270
+ sdkMocks.doThing.mockResolvedValue({ success: true })
271
+ const provider = new MyProvider(mockContainer, { apiKey: "key" })
272
+ const result = await provider.doSomething({ input: "test" })
273
+ expect(result.success).toBe(true)
274
+ })
275
+ })
276
+ ```
277
+
278
+ **Timer mocking:** Use `jest.useFakeTimers()` + `jest.advanceTimersByTimeAsync()` or `jest.spyOn(instance, "sleep_").mockResolvedValue(undefined)`.
279
+
280
+ **SWC regex:** Use `new RegExp("...")` instead of complex regex literals.
281
+
241
282
  #### Module Integration Test
242
283
 
243
284
  ```typescript
@@ -280,3 +321,8 @@ pnpm test:integration:http # HTTP integration tests
280
321
  - Call `waitSubscribersExecution` BEFORE triggering the event
281
322
  - Import jobs/subscribers directly and call with container
282
323
  - Use realistic test data, not "test" or "foo"
324
+ - **Always `beforeEach(() => jest.clearAllMocks())`** in unit tests — mock state leaks between describes
325
+ - **Never reference file-level `const`/`let` inside `jest.mock()` factories** — TDZ error (SWC/Babel hoisting)
326
+ - **Mock timers or sleep** when code under test has delays — prevents test timeouts
327
+ - **Use `new RegExp()` over complex regex literals** — SWC parser fails on some patterns
328
+ - **Read implementation to verify error paths** — check if errors are thrown vs returned
@@ -258,6 +258,15 @@ integrationTestRunner({
258
258
 
259
259
  **Fixtures (container-only):** `container`, `acmekitApp`, `MikroOrmWrapper`, `dbConfig`.
260
260
 
261
+ **When plugin depends on other plugins:** Use `additionalPlugins` to load real peer plugins with their modules, migrations, and services. Only fall back to `skipDependencyValidation: true` + `injectedDependencies` mocks when peer plugins can't be installed.
262
+
263
+ **When plugin has providers needing options:** Use `pluginModuleOptions` keyed by module name:
264
+ ```typescript
265
+ pluginModuleOptions: {
266
+ myModule: { providers: [{ resolve: "./src/providers/my-provider", id: "my-id", options: { apiKey: "key" } }] },
267
+ }
268
+ ```
269
+
261
270
  ---
262
271
 
263
272
  ## Subscriber Test Template (direct handler invocation)
@@ -392,6 +401,74 @@ integrationTestRunner<IGreetingModuleService>({
392
401
 
393
402
  ---
394
403
 
404
+ ## Unit Test Template (No Framework Bootstrap)
405
+
406
+ For providers, utilities, and standalone classes. Uses plain Jest with `jest.mock`.
407
+
408
+ **CRITICAL — jest.mock hoisting:** `jest.mock()` factories are hoisted above `const`/`let` by SWC. Never reference file-level variables inside a factory. Create mocks INSIDE the factory and access via `require()`.
409
+
410
+ ```typescript
411
+ // Provider unit test pattern
412
+ jest.mock("external-sdk", () => {
413
+ const mocks = {
414
+ doThing: jest.fn(),
415
+ }
416
+ const MockClient = jest.fn().mockImplementation(() => ({
417
+ doThing: mocks.doThing,
418
+ }))
419
+ return { Client: MockClient, __mocks: mocks }
420
+ })
421
+
422
+ const { __mocks: sdkMocks } = require("external-sdk")
423
+
424
+ import MyProvider from "../my-provider"
425
+
426
+ describe("MyProvider", () => {
427
+ let provider: MyProvider
428
+ const mockContainer = {} as any
429
+ const defaultOptions = { apiKey: "test-key" }
430
+
431
+ beforeEach(() => {
432
+ jest.clearAllMocks()
433
+ provider = new MyProvider(mockContainer, defaultOptions)
434
+ })
435
+
436
+ describe("static identifier", () => {
437
+ it("should have correct identifier", () => {
438
+ expect(MyProvider.identifier).toBe("my-provider")
439
+ })
440
+ })
441
+
442
+ describe("validateOptions", () => {
443
+ it("should accept valid options", () => {
444
+ expect(() =>
445
+ MyProvider.validateOptions({ apiKey: "key" })
446
+ ).not.toThrow()
447
+ })
448
+
449
+ it("should reject missing required option", () => {
450
+ expect(() => MyProvider.validateOptions({})).toThrow()
451
+ })
452
+ })
453
+
454
+ describe("doSomething", () => {
455
+ it("should delegate to SDK", async () => {
456
+ sdkMocks.doThing.mockResolvedValue({ success: true })
457
+ const result = await provider.doSomething({ input: "test" })
458
+ expect(result.success).toBe(true)
459
+ })
460
+ })
461
+ })
462
+ ```
463
+
464
+ **Timer mocking:** If code under test uses `setTimeout` or `sleep()`, use `jest.useFakeTimers()` + `jest.advanceTimersByTimeAsync()` or mock the sleep method.
465
+
466
+ **SWC regex:** Complex regex literals may fail. Use `new RegExp("...")` instead.
467
+
468
+ **Error paths:** Read the implementation to check whether errors are thrown or returned. Don't assume from the return type.
469
+
470
+ ---
471
+
395
472
  ## Asserting Domain Events (container-only mode)
396
473
 
397
474
  Plugin mode (without HTTP) injects `MockEventBusService`. Spy on the **prototype**, not an instance.
@@ -502,3 +579,7 @@ it("should emit greeting.created event", async () => {
502
579
  - Spy on `MockEventBusService.prototype` — not an instance
503
580
  - `jest.restoreAllMocks()` in `afterEach` when spying
504
581
  - NEVER use JSDoc blocks or type casts in test files
582
+ - **Always `beforeEach(() => jest.clearAllMocks())`** in unit tests — mock state leaks between describes
583
+ - **Never reference file-level `const`/`let` inside `jest.mock()` factories** — TDZ error
584
+ - **Mock timers or sleep** when code under test has delays — prevents timeouts
585
+ - **Use `new RegExp()` over complex regex literals** — SWC parser has edge cases
@@ -22,6 +22,10 @@ paths:
22
22
 
23
23
  **`.rejects.toThrow()` DOES work for service errors** (e.g., `service.retrievePost("bad-id")`). Services throw real `Error` instances. Only workflow errors are serialized.
24
24
 
25
+ **jest.mock factories are hoisted.** `jest.mock()` runs BEFORE `const`/`let` declarations. Never reference file-level variables inside a `jest.mock()` factory — see "Unit Tests" section below.
26
+
27
+ **Always add `jest.clearAllMocks()` in `beforeEach`.** Mock state leaks between test blocks. Without explicit cleanup, assertions on mock call counts fail from prior-test contamination.
28
+
25
29
  ---
26
30
 
27
31
  ## Test Runner Selection
@@ -124,8 +128,11 @@ integrationTestRunner({
124
128
  | `pluginPath` | `string` | **(required)** | Path to plugin root — always use `process.cwd()` |
125
129
  | `pluginOptions` | `Record<string, unknown>` | `{}` | Simulates host app plugin config |
126
130
  | `http` | `boolean` | `false` | Set `true` to boot full Express server for HTTP tests |
131
+ | `additionalPlugins` | `Array<{ resolve, options? }>` | `[]` | Peer plugins to load alongside the plugin under test — real modules, migrations, services |
127
132
  | `additionalModules` | `Record<string, any>` | `{}` | Extra modules to load alongside the plugin |
128
133
  | `injectedDependencies` | `Record<string, any>` | `{}` | Mock services to register in the container |
134
+ | `skipDependencyValidation` | `boolean` | `false` | Escape hatch: skip `definePlugin({ dependencies })` validation when peer plugins can't be installed |
135
+ | `pluginModuleOptions` | `Record<string, Record<string, any>>` | `{}` | Per-module options keyed by module name (e.g., provider config) |
129
136
  | `dbName` | `string` | auto-generated | Override the computed DB name |
130
137
  | `schema` | `string` | `"public"` | Postgres schema |
131
138
  | `debug` | `boolean` | `false` | Enables DB query logging |
@@ -534,6 +541,255 @@ container.resolve("auth") // ❌
534
541
 
535
542
  ---
536
543
 
544
+ ## Unit Tests (No Framework Bootstrap)
545
+
546
+ For providers, utility functions, and standalone classes that don't need the database or AcmeKit container. Uses plain Jest — no `integrationTestRunner`.
547
+
548
+ **File naming:** `src/**/__tests__/<name>.unit.spec.ts` — matches `TEST_TYPE=unit` in `jest.config.js`.
549
+
550
+ ### jest.mock Hoisting (Temporal Dead Zone)
551
+
552
+ `jest.mock()` factories are **hoisted above all `const`/`let` declarations** by SWC/Babel. Referencing a file-level `const` inside a `jest.mock()` factory causes `ReferenceError: Cannot access before initialization`.
553
+
554
+ ```typescript
555
+ // WRONG — TDZ error: mockSign is not yet initialized when factory runs
556
+ const mockSign = jest.fn()
557
+ jest.mock("tronweb", () => ({
558
+ TronWeb: jest.fn().mockImplementation(() => ({ trx: { sign: mockSign } })),
559
+ }))
560
+
561
+ // RIGHT — create mocks INSIDE the factory, expose via module return
562
+ jest.mock("tronweb", () => {
563
+ const mocks = {
564
+ sign: jest.fn(),
565
+ isAddress: jest.fn().mockReturnValue(true),
566
+ }
567
+ const MockTronWeb = jest.fn().mockImplementation(() => ({
568
+ trx: { sign: mocks.sign },
569
+ }))
570
+ MockTronWeb.isAddress = mocks.isAddress
571
+ return { TronWeb: MockTronWeb, __mocks: mocks }
572
+ })
573
+
574
+ // Access mocks after jest.mock via require()
575
+ const { __mocks: tronMocks } = require("tronweb")
576
+ ```
577
+
578
+ **Rule:** All mock state must live INSIDE the `jest.mock()` factory or be accessed via `require()` after the mock is set up. Never reference file-level `const`/`let` from inside a `jest.mock()` factory.
579
+
580
+ ### Provider Unit Test Pattern
581
+
582
+ Providers have a specific structure: constructor receives `(container, options)`, static `identifier`, optional static `validateOptions`. Test each part:
583
+
584
+ ```typescript
585
+ jest.mock("external-sdk", () => {
586
+ const mocks = {
587
+ doThing: jest.fn(),
588
+ }
589
+ const MockClient = jest.fn().mockImplementation(() => ({
590
+ doThing: mocks.doThing,
591
+ }))
592
+ return { Client: MockClient, __mocks: mocks }
593
+ })
594
+
595
+ const { __mocks: sdkMocks } = require("external-sdk")
596
+
597
+ import MyProvider from "../my-provider"
598
+
599
+ describe("MyProvider", () => {
600
+ let provider: MyProvider
601
+
602
+ const mockContainer = {} as any
603
+ const defaultOptions = { apiKey: "test-key" }
604
+
605
+ beforeEach(() => {
606
+ jest.clearAllMocks()
607
+ provider = new MyProvider(mockContainer, defaultOptions)
608
+ })
609
+
610
+ describe("static identifier", () => {
611
+ it("should have correct identifier", () => {
612
+ expect(MyProvider.identifier).toBe("my-provider")
613
+ })
614
+ })
615
+
616
+ describe("validateOptions", () => {
617
+ it("should accept valid options", () => {
618
+ expect(() =>
619
+ MyProvider.validateOptions({ apiKey: "key" })
620
+ ).not.toThrow()
621
+ })
622
+
623
+ it("should reject missing required option", () => {
624
+ expect(() => MyProvider.validateOptions({})).toThrow()
625
+ })
626
+ })
627
+
628
+ describe("doSomething", () => {
629
+ it("should delegate to SDK", async () => {
630
+ sdkMocks.doThing.mockResolvedValue({ success: true })
631
+ const result = await provider.doSomething({ input: "test" })
632
+ expect(result.success).toBe(true)
633
+ expect(sdkMocks.doThing).toHaveBeenCalledWith(
634
+ expect.objectContaining({ input: "test" })
635
+ )
636
+ })
637
+ })
638
+ })
639
+ ```
640
+
641
+ ### Mock Cleanup Between Tests
642
+
643
+ Mock state leaks between `describe` and `it` blocks. **Always add cleanup:**
644
+
645
+ ```typescript
646
+ // Recommended: file-level cleanup
647
+ beforeEach(() => {
648
+ jest.clearAllMocks()
649
+ })
650
+
651
+ // Alternative: per-describe when different describes need different setups
652
+ describe("feature A", () => {
653
+ beforeEach(() => {
654
+ jest.clearAllMocks()
655
+ mockFn.mockResolvedValue("A result")
656
+ })
657
+ })
658
+ ```
659
+
660
+ Without `jest.clearAllMocks()`, a mock called in one test still shows those calls in the next test:
661
+
662
+ ```typescript
663
+ expect(mockSign).not.toHaveBeenCalled() // FAILS — called by prior test
664
+ ```
665
+
666
+ ### Testing Code with Timers
667
+
668
+ When code under test calls `setTimeout`, `setInterval`, or a `sleep()` function, tests time out or run slowly.
669
+
670
+ ```typescript
671
+ // Option 1: Fake timers (for setTimeout/setInterval)
672
+ beforeEach(() => {
673
+ jest.useFakeTimers()
674
+ })
675
+ afterEach(() => {
676
+ jest.useRealTimers()
677
+ })
678
+ it("should retry after delay", async () => {
679
+ const promise = provider.retryOperation()
680
+ await jest.advanceTimersByTimeAsync(3000)
681
+ const result = await promise
682
+ expect(result).toBeDefined()
683
+ })
684
+
685
+ // Option 2: Mock the sleep method (for custom sleep/delay functions)
686
+ it("should complete without waiting", async () => {
687
+ jest.spyOn(provider as any, "sleep_").mockResolvedValue(undefined)
688
+ const result = await provider.longRunningOperation()
689
+ expect(result).toBeDefined()
690
+ })
691
+ ```
692
+
693
+ ### SWC Regex Limitation
694
+
695
+ SWC's regex parser fails on certain complex regex literals. If you get a `Syntax Error` from SWC on a line with a regex:
696
+
697
+ ```typescript
698
+ // WRONG — SWC may fail to parse this
699
+ const pattern = /^(\*|[0-9]+)(\/[0-9]+)?$|^\*\/[0-9]+$/
700
+
701
+ // RIGHT — use RegExp constructor
702
+ const pattern = new RegExp("^(\\*|[0-9]+)(\\/[0-9]+)?$|^\\*\\/[0-9]+$")
703
+ ```
704
+
705
+ ### Verifying Error Paths
706
+
707
+ When testing error cases, **read the implementation** to determine whether the method throws or returns an error object. Don't assume from the return type alone.
708
+
709
+ ```typescript
710
+ // If the method catches errors and returns { success: false }:
711
+ const result = await provider.process(badInput)
712
+ expect(result.success).toBe(false)
713
+
714
+ // If the method throws (no internal try/catch on that path):
715
+ await expect(provider.process(badInput)).rejects.toThrow("invalid")
716
+
717
+ // If validation runs BEFORE a try/catch block:
718
+ // validateInput() throws → not caught by the try/catch in process()
719
+ await expect(provider.process(badInput)).rejects.toThrow("validation")
720
+ ```
721
+
722
+ **Tip:** Look for `try/catch` blocks in the implementation. Code that runs BEFORE or OUTSIDE a `try/catch` throws directly. Code INSIDE a `try/catch` may return an error result instead.
723
+
724
+ ---
725
+
726
+ ## Testing Plugins with Dependencies
727
+
728
+ When your plugin depends on other plugins (declared in `definePlugin({ dependencies })`), use `additionalPlugins` to load them alongside the plugin under test. This boots real modules, runs real migrations, and registers real services — the same as production:
729
+
730
+ ```typescript
731
+ integrationTestRunner({
732
+ mode: "plugin",
733
+ pluginPath: process.cwd(),
734
+ additionalPlugins: [
735
+ { resolve: "@acmekit/plugin-reviews" },
736
+ { resolve: "@acmekit/plugin-loyalty", options: { tier: "gold" } },
737
+ ],
738
+ testSuite: ({ container }) => {
739
+ // container has REAL services from all plugins
740
+ it("uses review service", async () => {
741
+ const reviewService = container.resolve(REVIEW_MODULE)
742
+ const reviews = await reviewService.listReviews()
743
+ expect(reviews).toBeDefined()
744
+ })
745
+ },
746
+ })
747
+ ```
748
+
749
+ **Escape hatch:** When peer plugins genuinely can't be installed (e.g., CI without optional deps), use `skipDependencyValidation` to bypass validation and mock the services manually:
750
+
751
+ ```typescript
752
+ integrationTestRunner({
753
+ mode: "plugin",
754
+ pluginPath: process.cwd(),
755
+ skipDependencyValidation: true,
756
+ injectedDependencies: {
757
+ // Mock services your plugin expects from peer plugins
758
+ peerModuleService: { list: jest.fn().mockResolvedValue([]) },
759
+ },
760
+ testSuite: ({ container }) => { ... },
761
+ })
762
+ ```
763
+
764
+ > **Prefer `additionalPlugins` over `skipDependencyValidation` + mocks.** Mocked services can silently drift from the real API — tests pass but production breaks.
765
+
766
+ ## Configuring Providers in Plugin Tests
767
+
768
+ Use `pluginModuleOptions` to pass per-module options (e.g., provider configuration) keyed by module name:
769
+
770
+ ```typescript
771
+ integrationTestRunner({
772
+ mode: "plugin",
773
+ pluginPath: process.cwd(),
774
+ pluginModuleOptions: {
775
+ tron: {
776
+ providers: [
777
+ {
778
+ resolve: "./src/providers/energy/own-pool",
779
+ id: "own-pool",
780
+ options: { apiKey: "test-key" },
781
+ },
782
+ ],
783
+ },
784
+ },
785
+ testSuite: ({ container }) => { ... },
786
+ })
787
+ ```
788
+
789
+ `pluginModuleOptions` is merged into the discovered module config AFTER `pluginOptions`, so module-specific options override plugin-level ones.
790
+
791
+ ---
792
+
537
793
  ## Anti-Patterns — NEVER Do These
538
794
 
539
795
  ```typescript
@@ -624,4 +880,39 @@ import jwt from "jsonwebtoken" // ❌
624
880
  { [CLIENT_API_KEY_HEADER]: token }
625
881
 
626
882
  // WRONG — unused imports, JSDoc blocks, type casts in tests
883
+
884
+ // WRONG — referencing file-level const/let inside jest.mock factory (TDZ)
885
+ const mockFn = jest.fn()
886
+ jest.mock("lib", () => ({ thing: mockFn })) // ❌ ReferenceError
887
+ // RIGHT — create mocks inside factory, access via require()
888
+ jest.mock("lib", () => {
889
+ const mocks = { thing: jest.fn() }
890
+ return { ...mocks, __mocks: mocks }
891
+ })
892
+ const { __mocks } = require("lib")
893
+
894
+ // WRONG — no mock cleanup between tests
895
+ describe("A", () => { it("calls mock", () => { mockFn() }) })
896
+ describe("B", () => { it("mock is clean", () => {
897
+ expect(mockFn).not.toHaveBeenCalled() // ❌ fails — leaked from A
898
+ }) })
899
+ // RIGHT — add beforeEach(() => jest.clearAllMocks())
900
+
901
+ // WRONG — real timers in unit tests cause timeouts
902
+ it("should process", async () => {
903
+ await relay.process(data) // ❌ hangs — code calls sleep(3000) internally
904
+ })
905
+ // RIGHT — mock timers or sleep method
906
+ jest.useFakeTimers()
907
+ // or: jest.spyOn(relay as any, "sleep_").mockResolvedValue(undefined)
908
+
909
+ // WRONG — complex regex literal that SWC can't parse
910
+ const re = /^(\*|[0-9]+)(\/[0-9]+)?$/ // ❌ SWC Syntax Error
911
+ // RIGHT
912
+ const re = new RegExp("^(\\*|[0-9]+)(\\/[0-9]+)?$")
913
+
914
+ // WRONG — assuming method returns error without reading implementation
915
+ const result = await provider.process(bad)
916
+ expect(result.success).toBe(false) // ❌ actually throws
917
+ // RIGHT — read implementation first to check if it throws or returns
627
918
  ```
@@ -275,6 +275,47 @@ integrationTestRunner({
275
275
  })
276
276
  ```
277
277
 
278
+ #### Unit Test (No Framework Bootstrap)
279
+
280
+ For providers, utilities, and standalone classes. **CRITICAL:** `jest.mock()` factories are hoisted above `const`/`let`. Create mocks INSIDE the factory, access via `require()`.
281
+
282
+ ```typescript
283
+ jest.mock("external-sdk", () => {
284
+ const mocks = { doThing: jest.fn() }
285
+ const MockClient = jest.fn().mockImplementation(() => ({
286
+ doThing: mocks.doThing,
287
+ }))
288
+ return { Client: MockClient, __mocks: mocks }
289
+ })
290
+
291
+ const { __mocks: sdkMocks } = require("external-sdk")
292
+
293
+ import MyProvider from "../my-provider"
294
+
295
+ describe("MyProvider", () => {
296
+ const mockContainer = {} as any
297
+
298
+ beforeEach(() => {
299
+ jest.clearAllMocks()
300
+ })
301
+
302
+ it("should have correct identifier", () => {
303
+ expect(MyProvider.identifier).toBe("my-provider")
304
+ })
305
+
306
+ it("should delegate to SDK", async () => {
307
+ sdkMocks.doThing.mockResolvedValue({ success: true })
308
+ const provider = new MyProvider(mockContainer, { apiKey: "key" })
309
+ const result = await provider.doSomething({ input: "test" })
310
+ expect(result.success).toBe(true)
311
+ })
312
+ })
313
+ ```
314
+
315
+ **Timer mocking:** Use `jest.useFakeTimers()` + `jest.advanceTimersByTimeAsync()` or `jest.spyOn(instance, "sleep_").mockResolvedValue(undefined)`.
316
+
317
+ **SWC regex:** Use `new RegExp("...")` instead of complex regex literals.
318
+
278
319
  #### Module Integration Test (isolated)
279
320
 
280
321
  ```typescript
@@ -315,9 +356,16 @@ pnpm test:integration:http # Plugin HTTP tests
315
356
  - Always use `pluginPath: process.cwd()` — never hardcode paths
316
357
  - Container-only tests: access services via `container.resolve(MODULE_CONSTANT)` — no `api` fixture
317
358
  - HTTP tests: full auth setup with `generateJwtToken` + `ApiKeyType.CLIENT` — no `createAdminUser` helper
359
+ - Plugin depends on other plugins: use `additionalPlugins: [{ resolve: "@acmekit/plugin-name" }]` to load real peers. Fall back to `skipDependencyValidation: true` + `injectedDependencies` only when peers can't be installed
360
+ - Plugin providers need options: use `pluginModuleOptions: { moduleName: { providers: [...] } }`
318
361
  - Pass body directly: `api.post(url, body, headers)` — NOT `{ body: {...} }`
319
362
  - Use `.catch((e: any) => e)` for error assertions — axios throws on 4xx/5xx
320
363
  - Use `expect.objectContaining()` with `expect.any(String)` for IDs/timestamps
321
364
  - Test subscribers by importing handler directly and calling with `{ event, container }`
322
365
  - Test jobs by importing function directly and calling with `container`
323
366
  - Use realistic test data, not "test" or "foo"
367
+ - **Always `beforeEach(() => jest.clearAllMocks())`** in unit tests — mock state leaks between describes
368
+ - **Never reference file-level `const`/`let` inside `jest.mock()` factories** — TDZ error (SWC/Babel hoisting)
369
+ - **Mock timers or sleep** when code under test has delays — prevents test timeouts
370
+ - **Use `new RegExp()` over complex regex literals** — SWC parser fails on some patterns
371
+ - **Read implementation to verify error paths** — check if errors are thrown vs returned
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acmekit/acmekit",
3
- "version": "2.13.84",
3
+ "version": "2.13.86",
4
4
  "description": "Generic application bootstrap and loaders for the AcmeKit framework",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -49,45 +49,45 @@
49
49
  "test:integration": "../../node_modules/.bin/jest --passWithNoTests --forceExit --testPathPattern=\"src/.*/integration-tests/__tests__/.*\\.ts\""
50
50
  },
51
51
  "devDependencies": {
52
- "@acmekit/framework": "2.13.84"
52
+ "@acmekit/framework": "2.13.86"
53
53
  },
54
54
  "dependencies": {
55
- "@acmekit/admin-bundler": "2.13.84",
56
- "@acmekit/analytics": "2.13.84",
57
- "@acmekit/analytics-local": "2.13.84",
58
- "@acmekit/analytics-posthog": "2.13.84",
59
- "@acmekit/api-key": "2.13.84",
60
- "@acmekit/auth": "2.13.84",
61
- "@acmekit/auth-emailpass": "2.13.84",
62
- "@acmekit/auth-github": "2.13.84",
63
- "@acmekit/auth-google": "2.13.84",
64
- "@acmekit/cache-inmemory": "2.13.84",
65
- "@acmekit/cache-redis": "2.13.84",
66
- "@acmekit/caching": "2.13.84",
67
- "@acmekit/caching-redis": "2.13.84",
68
- "@acmekit/core-flows": "2.13.84",
69
- "@acmekit/event-bus-local": "2.13.84",
70
- "@acmekit/event-bus-redis": "2.13.84",
71
- "@acmekit/file": "2.13.84",
72
- "@acmekit/file-local": "2.13.84",
73
- "@acmekit/file-s3": "2.13.84",
74
- "@acmekit/index": "2.13.84",
75
- "@acmekit/link-modules": "2.13.84",
76
- "@acmekit/locking": "2.13.84",
77
- "@acmekit/locking-postgres": "2.13.84",
78
- "@acmekit/locking-redis": "2.13.84",
79
- "@acmekit/notification": "2.13.84",
80
- "@acmekit/notification-local": "2.13.84",
81
- "@acmekit/notification-sendgrid": "2.13.84",
82
- "@acmekit/rbac": "2.13.84",
83
- "@acmekit/secrets-aws": "2.13.84",
84
- "@acmekit/secrets-local": "2.13.84",
85
- "@acmekit/settings": "2.13.84",
86
- "@acmekit/telemetry": "2.13.84",
87
- "@acmekit/translation": "2.13.84",
88
- "@acmekit/user": "2.13.84",
89
- "@acmekit/workflow-engine-inmemory": "2.13.84",
90
- "@acmekit/workflow-engine-redis": "2.13.84",
55
+ "@acmekit/admin-bundler": "2.13.86",
56
+ "@acmekit/analytics": "2.13.86",
57
+ "@acmekit/analytics-local": "2.13.86",
58
+ "@acmekit/analytics-posthog": "2.13.86",
59
+ "@acmekit/api-key": "2.13.86",
60
+ "@acmekit/auth": "2.13.86",
61
+ "@acmekit/auth-emailpass": "2.13.86",
62
+ "@acmekit/auth-github": "2.13.86",
63
+ "@acmekit/auth-google": "2.13.86",
64
+ "@acmekit/cache-inmemory": "2.13.86",
65
+ "@acmekit/cache-redis": "2.13.86",
66
+ "@acmekit/caching": "2.13.86",
67
+ "@acmekit/caching-redis": "2.13.86",
68
+ "@acmekit/core-flows": "2.13.86",
69
+ "@acmekit/event-bus-local": "2.13.86",
70
+ "@acmekit/event-bus-redis": "2.13.86",
71
+ "@acmekit/file": "2.13.86",
72
+ "@acmekit/file-local": "2.13.86",
73
+ "@acmekit/file-s3": "2.13.86",
74
+ "@acmekit/index": "2.13.86",
75
+ "@acmekit/link-modules": "2.13.86",
76
+ "@acmekit/locking": "2.13.86",
77
+ "@acmekit/locking-postgres": "2.13.86",
78
+ "@acmekit/locking-redis": "2.13.86",
79
+ "@acmekit/notification": "2.13.86",
80
+ "@acmekit/notification-local": "2.13.86",
81
+ "@acmekit/notification-sendgrid": "2.13.86",
82
+ "@acmekit/rbac": "2.13.86",
83
+ "@acmekit/secrets-aws": "2.13.86",
84
+ "@acmekit/secrets-local": "2.13.86",
85
+ "@acmekit/settings": "2.13.86",
86
+ "@acmekit/telemetry": "2.13.86",
87
+ "@acmekit/translation": "2.13.86",
88
+ "@acmekit/user": "2.13.86",
89
+ "@acmekit/workflow-engine-inmemory": "2.13.86",
90
+ "@acmekit/workflow-engine-redis": "2.13.86",
91
91
  "@inquirer/checkbox": "^2.3.11",
92
92
  "@inquirer/input": "^2.2.9",
93
93
  "boxen": "^5.0.1",
@@ -106,7 +106,7 @@
106
106
  },
107
107
  "peerDependencies": {
108
108
  "@acmekit/docs-bundler": "^2.13.42",
109
- "@acmekit/framework": "2.13.84",
109
+ "@acmekit/framework": "2.13.86",
110
110
  "@jimsheen/yalc": "^1.2.2",
111
111
  "@swc/core": "^1.7.28",
112
112
  "posthog-node": "^5.11.0",