@acmekit/acmekit 2.13.84 → 2.13.85

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:** Add `skipDependencyValidation: true` and mock peer services via `injectedDependencies`.
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
@@ -126,6 +130,8 @@ integrationTestRunner({
126
130
  | `http` | `boolean` | `false` | Set `true` to boot full Express server for HTTP tests |
127
131
  | `additionalModules` | `Record<string, any>` | `{}` | Extra modules to load alongside the plugin |
128
132
  | `injectedDependencies` | `Record<string, any>` | `{}` | Mock services to register in the container |
133
+ | `skipDependencyValidation` | `boolean` | `false` | Skip `definePlugin({ dependencies })` validation — use when peer plugins aren't installed |
134
+ | `pluginModuleOptions` | `Record<string, Record<string, any>>` | `{}` | Per-module options keyed by module name (e.g., provider config) |
129
135
  | `dbName` | `string` | auto-generated | Override the computed DB name |
130
136
  | `schema` | `string` | `"public"` | Postgres schema |
131
137
  | `debug` | `boolean` | `false` | Enables DB query logging |
@@ -534,6 +540,232 @@ container.resolve("auth") // ❌
534
540
 
535
541
  ---
536
542
 
543
+ ## Unit Tests (No Framework Bootstrap)
544
+
545
+ For providers, utility functions, and standalone classes that don't need the database or AcmeKit container. Uses plain Jest — no `integrationTestRunner`.
546
+
547
+ **File naming:** `src/**/__tests__/<name>.unit.spec.ts` — matches `TEST_TYPE=unit` in `jest.config.js`.
548
+
549
+ ### jest.mock Hoisting (Temporal Dead Zone)
550
+
551
+ `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`.
552
+
553
+ ```typescript
554
+ // WRONG — TDZ error: mockSign is not yet initialized when factory runs
555
+ const mockSign = jest.fn()
556
+ jest.mock("tronweb", () => ({
557
+ TronWeb: jest.fn().mockImplementation(() => ({ trx: { sign: mockSign } })),
558
+ }))
559
+
560
+ // RIGHT — create mocks INSIDE the factory, expose via module return
561
+ jest.mock("tronweb", () => {
562
+ const mocks = {
563
+ sign: jest.fn(),
564
+ isAddress: jest.fn().mockReturnValue(true),
565
+ }
566
+ const MockTronWeb = jest.fn().mockImplementation(() => ({
567
+ trx: { sign: mocks.sign },
568
+ }))
569
+ MockTronWeb.isAddress = mocks.isAddress
570
+ return { TronWeb: MockTronWeb, __mocks: mocks }
571
+ })
572
+
573
+ // Access mocks after jest.mock via require()
574
+ const { __mocks: tronMocks } = require("tronweb")
575
+ ```
576
+
577
+ **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.
578
+
579
+ ### Provider Unit Test Pattern
580
+
581
+ Providers have a specific structure: constructor receives `(container, options)`, static `identifier`, optional static `validateOptions`. Test each part:
582
+
583
+ ```typescript
584
+ jest.mock("external-sdk", () => {
585
+ const mocks = {
586
+ doThing: jest.fn(),
587
+ }
588
+ const MockClient = jest.fn().mockImplementation(() => ({
589
+ doThing: mocks.doThing,
590
+ }))
591
+ return { Client: MockClient, __mocks: mocks }
592
+ })
593
+
594
+ const { __mocks: sdkMocks } = require("external-sdk")
595
+
596
+ import MyProvider from "../my-provider"
597
+
598
+ describe("MyProvider", () => {
599
+ let provider: MyProvider
600
+
601
+ const mockContainer = {} as any
602
+ const defaultOptions = { apiKey: "test-key" }
603
+
604
+ beforeEach(() => {
605
+ jest.clearAllMocks()
606
+ provider = new MyProvider(mockContainer, defaultOptions)
607
+ })
608
+
609
+ describe("static identifier", () => {
610
+ it("should have correct identifier", () => {
611
+ expect(MyProvider.identifier).toBe("my-provider")
612
+ })
613
+ })
614
+
615
+ describe("validateOptions", () => {
616
+ it("should accept valid options", () => {
617
+ expect(() =>
618
+ MyProvider.validateOptions({ apiKey: "key" })
619
+ ).not.toThrow()
620
+ })
621
+
622
+ it("should reject missing required option", () => {
623
+ expect(() => MyProvider.validateOptions({})).toThrow()
624
+ })
625
+ })
626
+
627
+ describe("doSomething", () => {
628
+ it("should delegate to SDK", async () => {
629
+ sdkMocks.doThing.mockResolvedValue({ success: true })
630
+ const result = await provider.doSomething({ input: "test" })
631
+ expect(result.success).toBe(true)
632
+ expect(sdkMocks.doThing).toHaveBeenCalledWith(
633
+ expect.objectContaining({ input: "test" })
634
+ )
635
+ })
636
+ })
637
+ })
638
+ ```
639
+
640
+ ### Mock Cleanup Between Tests
641
+
642
+ Mock state leaks between `describe` and `it` blocks. **Always add cleanup:**
643
+
644
+ ```typescript
645
+ // Recommended: file-level cleanup
646
+ beforeEach(() => {
647
+ jest.clearAllMocks()
648
+ })
649
+
650
+ // Alternative: per-describe when different describes need different setups
651
+ describe("feature A", () => {
652
+ beforeEach(() => {
653
+ jest.clearAllMocks()
654
+ mockFn.mockResolvedValue("A result")
655
+ })
656
+ })
657
+ ```
658
+
659
+ Without `jest.clearAllMocks()`, a mock called in one test still shows those calls in the next test:
660
+
661
+ ```typescript
662
+ expect(mockSign).not.toHaveBeenCalled() // FAILS — called by prior test
663
+ ```
664
+
665
+ ### Testing Code with Timers
666
+
667
+ When code under test calls `setTimeout`, `setInterval`, or a `sleep()` function, tests time out or run slowly.
668
+
669
+ ```typescript
670
+ // Option 1: Fake timers (for setTimeout/setInterval)
671
+ beforeEach(() => {
672
+ jest.useFakeTimers()
673
+ })
674
+ afterEach(() => {
675
+ jest.useRealTimers()
676
+ })
677
+ it("should retry after delay", async () => {
678
+ const promise = provider.retryOperation()
679
+ await jest.advanceTimersByTimeAsync(3000)
680
+ const result = await promise
681
+ expect(result).toBeDefined()
682
+ })
683
+
684
+ // Option 2: Mock the sleep method (for custom sleep/delay functions)
685
+ it("should complete without waiting", async () => {
686
+ jest.spyOn(provider as any, "sleep_").mockResolvedValue(undefined)
687
+ const result = await provider.longRunningOperation()
688
+ expect(result).toBeDefined()
689
+ })
690
+ ```
691
+
692
+ ### SWC Regex Limitation
693
+
694
+ SWC's regex parser fails on certain complex regex literals. If you get a `Syntax Error` from SWC on a line with a regex:
695
+
696
+ ```typescript
697
+ // WRONG — SWC may fail to parse this
698
+ const pattern = /^(\*|[0-9]+)(\/[0-9]+)?$|^\*\/[0-9]+$/
699
+
700
+ // RIGHT — use RegExp constructor
701
+ const pattern = new RegExp("^(\\*|[0-9]+)(\\/[0-9]+)?$|^\\*\\/[0-9]+$")
702
+ ```
703
+
704
+ ### Verifying Error Paths
705
+
706
+ 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.
707
+
708
+ ```typescript
709
+ // If the method catches errors and returns { success: false }:
710
+ const result = await provider.process(badInput)
711
+ expect(result.success).toBe(false)
712
+
713
+ // If the method throws (no internal try/catch on that path):
714
+ await expect(provider.process(badInput)).rejects.toThrow("invalid")
715
+
716
+ // If validation runs BEFORE a try/catch block:
717
+ // validateInput() throws → not caught by the try/catch in process()
718
+ await expect(provider.process(badInput)).rejects.toThrow("validation")
719
+ ```
720
+
721
+ **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.
722
+
723
+ ---
724
+
725
+ ## Testing Plugins with Dependencies
726
+
727
+ When your plugin depends on other plugins (declared in `definePlugin({ dependencies })`), the test runner validates that all dependencies are installed. For workspace plugins that depend on other workspace plugins, use `skipDependencyValidation`:
728
+
729
+ ```typescript
730
+ integrationTestRunner({
731
+ mode: "plugin",
732
+ pluginPath: process.cwd(),
733
+ skipDependencyValidation: true,
734
+ injectedDependencies: {
735
+ // Mock services your plugin expects from peer plugins
736
+ peerModuleService: { list: jest.fn().mockResolvedValue([]) },
737
+ },
738
+ testSuite: ({ container }) => { ... },
739
+ })
740
+ ```
741
+
742
+ ## Configuring Providers in Plugin Tests
743
+
744
+ Use `pluginModuleOptions` to pass per-module options (e.g., provider configuration) keyed by module name:
745
+
746
+ ```typescript
747
+ integrationTestRunner({
748
+ mode: "plugin",
749
+ pluginPath: process.cwd(),
750
+ pluginModuleOptions: {
751
+ tron: {
752
+ providers: [
753
+ {
754
+ resolve: "./src/providers/energy/own-pool",
755
+ id: "own-pool",
756
+ options: { apiKey: "test-key" },
757
+ },
758
+ ],
759
+ },
760
+ },
761
+ testSuite: ({ container }) => { ... },
762
+ })
763
+ ```
764
+
765
+ `pluginModuleOptions` is merged into the discovered module config AFTER `pluginOptions`, so module-specific options override plugin-level ones.
766
+
767
+ ---
768
+
537
769
  ## Anti-Patterns — NEVER Do These
538
770
 
539
771
  ```typescript
@@ -624,4 +856,39 @@ import jwt from "jsonwebtoken" // ❌
624
856
  { [CLIENT_API_KEY_HEADER]: token }
625
857
 
626
858
  // WRONG — unused imports, JSDoc blocks, type casts in tests
859
+
860
+ // WRONG — referencing file-level const/let inside jest.mock factory (TDZ)
861
+ const mockFn = jest.fn()
862
+ jest.mock("lib", () => ({ thing: mockFn })) // ❌ ReferenceError
863
+ // RIGHT — create mocks inside factory, access via require()
864
+ jest.mock("lib", () => {
865
+ const mocks = { thing: jest.fn() }
866
+ return { ...mocks, __mocks: mocks }
867
+ })
868
+ const { __mocks } = require("lib")
869
+
870
+ // WRONG — no mock cleanup between tests
871
+ describe("A", () => { it("calls mock", () => { mockFn() }) })
872
+ describe("B", () => { it("mock is clean", () => {
873
+ expect(mockFn).not.toHaveBeenCalled() // ❌ fails — leaked from A
874
+ }) })
875
+ // RIGHT — add beforeEach(() => jest.clearAllMocks())
876
+
877
+ // WRONG — real timers in unit tests cause timeouts
878
+ it("should process", async () => {
879
+ await relay.process(data) // ❌ hangs — code calls sleep(3000) internally
880
+ })
881
+ // RIGHT — mock timers or sleep method
882
+ jest.useFakeTimers()
883
+ // or: jest.spyOn(relay as any, "sleep_").mockResolvedValue(undefined)
884
+
885
+ // WRONG — complex regex literal that SWC can't parse
886
+ const re = /^(\*|[0-9]+)(\/[0-9]+)?$/ // ❌ SWC Syntax Error
887
+ // RIGHT
888
+ const re = new RegExp("^(\\*|[0-9]+)(\\/[0-9]+)?$")
889
+
890
+ // WRONG — assuming method returns error without reading implementation
891
+ const result = await provider.process(bad)
892
+ expect(result.success).toBe(false) // ❌ actually throws
893
+ // RIGHT — read implementation first to check if it throws or returns
627
894
  ```
@@ -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: add `skipDependencyValidation: true` + mock peer services via `injectedDependencies`
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.85",
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.85"
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.85",
56
+ "@acmekit/analytics": "2.13.85",
57
+ "@acmekit/analytics-local": "2.13.85",
58
+ "@acmekit/analytics-posthog": "2.13.85",
59
+ "@acmekit/api-key": "2.13.85",
60
+ "@acmekit/auth": "2.13.85",
61
+ "@acmekit/auth-emailpass": "2.13.85",
62
+ "@acmekit/auth-github": "2.13.85",
63
+ "@acmekit/auth-google": "2.13.85",
64
+ "@acmekit/cache-inmemory": "2.13.85",
65
+ "@acmekit/cache-redis": "2.13.85",
66
+ "@acmekit/caching": "2.13.85",
67
+ "@acmekit/caching-redis": "2.13.85",
68
+ "@acmekit/core-flows": "2.13.85",
69
+ "@acmekit/event-bus-local": "2.13.85",
70
+ "@acmekit/event-bus-redis": "2.13.85",
71
+ "@acmekit/file": "2.13.85",
72
+ "@acmekit/file-local": "2.13.85",
73
+ "@acmekit/file-s3": "2.13.85",
74
+ "@acmekit/index": "2.13.85",
75
+ "@acmekit/link-modules": "2.13.85",
76
+ "@acmekit/locking": "2.13.85",
77
+ "@acmekit/locking-postgres": "2.13.85",
78
+ "@acmekit/locking-redis": "2.13.85",
79
+ "@acmekit/notification": "2.13.85",
80
+ "@acmekit/notification-local": "2.13.85",
81
+ "@acmekit/notification-sendgrid": "2.13.85",
82
+ "@acmekit/rbac": "2.13.85",
83
+ "@acmekit/secrets-aws": "2.13.85",
84
+ "@acmekit/secrets-local": "2.13.85",
85
+ "@acmekit/settings": "2.13.85",
86
+ "@acmekit/telemetry": "2.13.85",
87
+ "@acmekit/translation": "2.13.85",
88
+ "@acmekit/user": "2.13.85",
89
+ "@acmekit/workflow-engine-inmemory": "2.13.85",
90
+ "@acmekit/workflow-engine-redis": "2.13.85",
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.85",
110
110
  "@jimsheen/yalc": "^1.2.2",
111
111
  "@swc/core": "^1.7.28",
112
112
  "posthog-node": "^5.11.0",