@starci/jest-preset 2.3.7 → 2.4.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +3 -3
  3. package/{builders.cjs → dist/builders.d.ts} +2 -7
  4. package/dist/builders.js +15 -0
  5. package/dist/cache.d.ts +48 -0
  6. package/dist/cache.js +68 -0
  7. package/dist/clock.d.ts +20 -0
  8. package/dist/clock.js +32 -0
  9. package/dist/entity-manager.d.ts +114 -0
  10. package/dist/entity-manager.js +138 -0
  11. package/dist/event-bus.d.ts +52 -0
  12. package/dist/event-bus.js +106 -0
  13. package/dist/ids.d.ts +21 -0
  14. package/dist/ids.js +42 -0
  15. package/dist/inbox.d.ts +65 -0
  16. package/dist/inbox.js +173 -0
  17. package/dist/index.d.ts +114 -0
  18. package/dist/index.js +289 -0
  19. package/dist/lcov.d.ts +16 -0
  20. package/dist/lcov.js +25 -0
  21. package/dist/lock.d.ts +37 -0
  22. package/dist/lock.js +57 -0
  23. package/dist/matchers.d.ts +27 -0
  24. package/dist/matchers.js +36 -0
  25. package/dist/merge.d.ts +14 -0
  26. package/dist/merge.js +55 -0
  27. package/dist/mock.d.ts +21 -0
  28. package/dist/mock.js +52 -0
  29. package/dist/queue.d.ts +30 -0
  30. package/dist/queue.js +50 -0
  31. package/dist/sonar-exclusions.d.ts +7 -0
  32. package/dist/sonar-exclusions.js +8 -0
  33. package/dist/unit-metadata.d.ts +23 -0
  34. package/dist/unit-metadata.js +71 -0
  35. package/dist/world-runner.d.ts +42 -0
  36. package/dist/world-runner.js +142 -0
  37. package/package.json +42 -32
  38. package/builders.d.ts +0 -2
  39. package/cache.cjs +0 -67
  40. package/cache.d.ts +0 -31
  41. package/clock.cjs +0 -34
  42. package/clock.d.ts +0 -13
  43. package/entity-manager.cjs +0 -146
  44. package/entity-manager.d.ts +0 -103
  45. package/event-bus.cjs +0 -100
  46. package/event-bus.d.ts +0 -54
  47. package/ids.cjs +0 -41
  48. package/ids.d.ts +0 -13
  49. package/inbox.cjs +0 -162
  50. package/inbox.d.ts +0 -58
  51. package/index.cjs +0 -295
  52. package/index.d.ts +0 -38
  53. package/lcov.cjs +0 -25
  54. package/lock.cjs +0 -56
  55. package/lock.d.ts +0 -26
  56. package/matchers.cjs +0 -45
  57. package/matchers.d.ts +0 -17
  58. package/merge.cjs +0 -45
  59. package/mock.cjs +0 -53
  60. package/mock.d.ts +0 -12
  61. package/queue.cjs +0 -51
  62. package/queue.d.ts +0 -26
  63. package/sonar-exclusions.cjs +0 -8
  64. package/unit-metadata.cjs +0 -63
  65. package/world-runner.cjs +0 -142
  66. /package/{unit-metadata.generated.cjs → dist/unit-metadata.generated.js} +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.4.0 - 2026-10-28
4
+
5
+ - Changed: the source is TypeScript in `src/`, built to CommonJS JavaScript in `dist/` (`npm run build`; `prepack` builds), and the declarations are emitted from it instead of hand-written; `main`, `types` and every export (`.`, `./mock`, `./clock`, `./entity-manager`, `./ids`, `./matchers`, `./cache`, `./lock`, `./event-bus`, `./queue`, `./inbox`, `./builders`, `./merge`) point into `dist/`. No rule or behaviour changed; the specifiers are unchanged. The files jest loads by path are now `dist/world-runner.js`, `dist/unit-metadata.js`, `dist/unit-metadata.generated.js` (the coverage anchor, copied byte for byte from `assets/`) and `dist/lcov.js`; `@starci/jest-preset/merge` now has types.
6
+ - Changed: `starciJestConfig` returns `StarciJestConfig` (jest's `Config` with `projects` all objects, a custom reporter path allowed in `coverageReporters` and a `coverageThreshold` keyed by glob only), the type its value always had; `createMock` takes a factory called with the property name; the package specs are `test/*.spec.ts` and load the built `dist/`.
7
+
3
8
  ## 2.3.7 - 2026-10-27
4
9
 
5
10
  - Changed: licensed MIT (was UNLICENSED); the package metadata names the repository, homepage and bugs so npm can attach provenance. No rule or behaviour changed.
package/README.md CHANGED
@@ -19,7 +19,7 @@ and the exclusions. A repository has no other jest setting to tune.
19
19
  `src/tests/{world,integration,e2e,contract}/`), `integration` (`src/tests/integration/**/*.integration-spec.ts`), `e2e`
20
20
  (`src/tests/e2e/**/*.e2e-spec.ts`) and `contract` (`src/tests/contract/**/*.contract-spec.ts`). The last three compile against
21
21
  `src/tests/tsconfig.json`, start the one test world through `globalSetup`/`globalTeardown`
22
- (`src/tests/world/global-setup.ts`, `global-teardown.ts`) and run on the world runner (`world-runner.cjs`, `WORLD_RUNNER`): every
22
+ (`src/tests/world/global-setup.ts`, `global-teardown.ts`) and run on the world runner (`world-runner.js`, `WORLD_RUNNER`): every
23
23
  spec file in a worker process of its own and never in band, so nothing the globalSetup or another file left on a process global
24
24
  (a framework registry such as `@nestjs/graphql`'s type metadata) reaches the file; and up to `min(--maxWorkers, slots)` files at
25
25
  once, each bound to its own data slot of `@starci/test-world` 1.1.0 (`STARCI_TEST_WORLD_SLOT`), never two files on one slot,
@@ -37,8 +37,8 @@ and the exclusions. A repository has no other jest setting to tune.
37
37
  `ruleParams.be.logicRoles` role inside a measured slot must reach 100 on every metric (an average never passes). The
38
38
  managed `test` script runs the unit project with `--coverage` and fails below it. Coverage is measured by v8, which counts the
39
39
  real source. The unit project overlays `isolatedModules: true`, `importHelpers: true` and `sourceMap: true` on the repository tsconfig, so ts-jest
40
- transpiles each file without building a type program. `unit-metadata.cjs`, a ts-jest wrapper and after-transformer, maps
41
- only synthetic `typeof value === "function" ? value : Object` guards inside TypeScript's generated `__metadata("design:paramtypes", ...)` calls to the dedicated `unit-metadata.generated.cjs` coverage anchor. Their runtime guards remain unchanged, including
40
+ transpiles each file without building a type program. `unit-metadata.js`, a ts-jest wrapper and after-transformer, maps
41
+ only synthetic `typeof value === "function" ? value : Object` guards inside TypeScript's generated `__metadata("design:paramtypes", ...)` calls to the dedicated `unit-metadata.generated.js` coverage anchor. Their runtime guards remain unchanged, including
42
42
  the fallback to `Object` for an undefined or non-class dependency; generated branches are not attributed to source parameters.
43
43
  The wrapper restores that second source after ts-jest emits its map. The anchor ignores generated guards only and is omitted from LCOV. Application source calls and guards remain measured. Source maps are enabled even when the build config disables them.
44
44
  Source conditionals, including branches on the same constructor line, still count toward per-file 100 coverage. Decorator helpers
@@ -1,5 +1,3 @@
1
- "use strict"
2
-
3
1
  /**
4
2
  * `builder(defaults)` -- the one way a spec makes a plain value with a few fields changed: a module options object
5
3
  * (`<CAP>_OPTIONS`), a principal, a request context. Options tokens are provided as REAL values, never as doubles; a
@@ -8,8 +6,5 @@
8
6
  * const options = builder<OrderOptions>({ maxLines: 50, allowGuestCheckout: false })
9
7
  * options({ allowGuestCheckout: true }) // a whole OrderOptions with one flag flipped
10
8
  */
11
- function builder(defaults) {
12
- return (overrides) => ({ ...defaults, ...overrides })
13
- }
14
-
15
- module.exports = { builder }
9
+ /** Makes a builder of `T`: a function returning the defaults with `overrides` applied. */
10
+ export declare function builder<T extends object>(defaults: T): (overrides?: Partial<T>) => T;
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.builder = builder;
4
+ /**
5
+ * `builder(defaults)` -- the one way a spec makes a plain value with a few fields changed: a module options object
6
+ * (`<CAP>_OPTIONS`), a principal, a request context. Options tokens are provided as REAL values, never as doubles; a
7
+ * builder holds the valid defaults once so each spec states only the field it varies.
8
+ *
9
+ * const options = builder<OrderOptions>({ maxLines: 50, allowGuestCheckout: false })
10
+ * options({ allowGuestCheckout: true }) // a whole OrderOptions with one flag flipped
11
+ */
12
+ /** Makes a builder of `T`: a function returning the defaults with `overrides` applied. */
13
+ function builder(defaults) {
14
+ return (overrides) => ({ ...defaults, ...overrides });
15
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `fakeCache(clock)` -- a behavioural in-memory double of a cache port.
3
+ *
4
+ * It keeps entries as JSON text (like a Redis-backed cache, so a Date comes back as a string and a `key.parse` narrows
5
+ * what was stored), expires them from the `FakeClock` (`clock.advance(ms)` is a TTL expiry, no sleep), and offers the
6
+ * inspection a spec needs: `has(key)`, `ttlOf(key)`, `keys()`.
7
+ *
8
+ * Two call shapes are served by the same object, so it stands in for either kind of cache dependency:
9
+ * - the typed-key port: `get({ key, args })`, `set({ key, args, value })`, `del({ key, args })`, where `key` is
10
+ * `{ name, ttl: { seconds }, parse? }` and the entry text is `<name>:<arg>:...`;
11
+ * - the cache-manager shape: `get(text)`, `set(text, value, ttlMs?)`, `del(text)`.
12
+ * `has`, `ttlOf` and `del` accept a request or the entry text.
13
+ */
14
+ /** The declared key of a typed cache entry: `{ name, ttl, parse? }`. */
15
+ export interface FakeCacheKey<TValue = unknown> {
16
+ readonly name: string;
17
+ readonly ttl: {
18
+ readonly seconds: number;
19
+ };
20
+ readonly parse?: (stored: unknown) => TValue | null;
21
+ }
22
+ /** The typed-key request: the key plus the arguments that make the entry unique. */
23
+ export interface FakeCacheRequest<TValue = unknown> {
24
+ readonly key: FakeCacheKey<TValue>;
25
+ readonly args?: ReadonlyArray<string>;
26
+ }
27
+ export type FakeCacheEntry<TValue = unknown> = FakeCacheRequest<TValue> & {
28
+ readonly value: TValue;
29
+ };
30
+ /** A behavioural in-memory cache: JSON round trip, expiry driven by the clock, inspection helpers. */
31
+ export interface FakeCache {
32
+ get<TValue>(request: FakeCacheRequest<TValue>): Promise<TValue | null>;
33
+ get(text: string): Promise<unknown>;
34
+ set<TValue>(entry: FakeCacheEntry<TValue>): Promise<void>;
35
+ set(text: string, value: unknown, ttlMs?: number): Promise<void>;
36
+ del(request: FakeCacheRequest | string): Promise<void>;
37
+ /** Whether a live (not expired) entry exists. */
38
+ has(request: FakeCacheRequest | string): boolean;
39
+ /** Seconds left before the entry expires (rounded up); null when absent, expired or without a ttl. */
40
+ ttlOf(request: FakeCacheRequest | string): number | null;
41
+ /** The texts of the live entries. */
42
+ keys(): string[];
43
+ clear(): void;
44
+ }
45
+ /** The cache double; `clock` is the `FakeClock` of the spec, so `clock.advance(ms)` expires entries. */
46
+ export declare function fakeCache(clock: {
47
+ now(): Date;
48
+ }): FakeCache;
package/dist/cache.js ADDED
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ /**
3
+ * `fakeCache(clock)` -- a behavioural in-memory double of a cache port.
4
+ *
5
+ * It keeps entries as JSON text (like a Redis-backed cache, so a Date comes back as a string and a `key.parse` narrows
6
+ * what was stored), expires them from the `FakeClock` (`clock.advance(ms)` is a TTL expiry, no sleep), and offers the
7
+ * inspection a spec needs: `has(key)`, `ttlOf(key)`, `keys()`.
8
+ *
9
+ * Two call shapes are served by the same object, so it stands in for either kind of cache dependency:
10
+ * - the typed-key port: `get({ key, args })`, `set({ key, args, value })`, `del({ key, args })`, where `key` is
11
+ * `{ name, ttl: { seconds }, parse? }` and the entry text is `<name>:<arg>:...`;
12
+ * - the cache-manager shape: `get(text)`, `set(text, value, ttlMs?)`, `del(text)`.
13
+ * `has`, `ttlOf` and `del` accept a request or the entry text.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.fakeCache = fakeCache;
17
+ const textOf = (request) => typeof request === "string" ? request : [request.key.name, ...(request.args ?? [])].join(":");
18
+ /** The cache double; `clock` is the `FakeClock` of the spec, so `clock.advance(ms)` expires entries. */
19
+ function fakeCache(clock) {
20
+ if (!clock || typeof clock.now !== "function")
21
+ throw new Error("fakeCache: pass the FakeClock the spec drives, fakeCache(clock)");
22
+ const entries = new Map();
23
+ const live = (text) => {
24
+ const entry = entries.get(text);
25
+ if (entry === undefined)
26
+ return undefined;
27
+ if (entry.expiresAt !== null && clock.now().getTime() >= entry.expiresAt) {
28
+ entries.delete(text);
29
+ return undefined;
30
+ }
31
+ return entry;
32
+ };
33
+ async function get(request) {
34
+ const entry = live(textOf(request));
35
+ if (entry === undefined)
36
+ return null;
37
+ const stored = JSON.parse(entry.json);
38
+ return typeof request === "string" || typeof request.key.parse !== "function" ? stored : request.key.parse(stored);
39
+ }
40
+ async function set(request, value, ttlMs) {
41
+ const text = textOf(request);
42
+ const stored = typeof request === "string" ? value : request.value;
43
+ const lifetimeMs = typeof request === "string" ? ttlMs : request.key.ttl.seconds * 1000;
44
+ if (lifetimeMs !== undefined && !(lifetimeMs > 0))
45
+ throw new Error(`fakeCache: the ttl of ${text} must be positive`);
46
+ entries.set(text, { json: JSON.stringify(stored), expiresAt: lifetimeMs === undefined ? null : clock.now().getTime() + lifetimeMs });
47
+ }
48
+ return {
49
+ get,
50
+ set,
51
+ async del(request) {
52
+ entries.delete(textOf(request));
53
+ },
54
+ /** Whether a live (not expired) entry exists. */
55
+ has: (request) => live(textOf(request)) !== undefined,
56
+ /** Seconds left before the entry expires (rounded up), null when there is no live entry or it never expires. */
57
+ ttlOf(request) {
58
+ const entry = live(textOf(request));
59
+ if (entry === undefined || entry.expiresAt === null)
60
+ return null;
61
+ return Math.ceil((entry.expiresAt - clock.now().getTime()) / 1000);
62
+ },
63
+ /** The texts of the live entries. */
64
+ keys: () => [...entries.keys()].filter((text) => live(text) !== undefined),
65
+ /** Drops every entry. */
66
+ clear: () => entries.clear(),
67
+ };
68
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `FakeClock` -- the test double for the injected `Clock` port (`platform/clock`).
3
+ *
4
+ * Business code asks its `Clock` port for the time (`clock.now()`) instead of reading `Date.now()` or
5
+ * `new Date()` ambiently (`no-ambient-clock`, R79). A spec then drives time explicitly: it starts the
6
+ * clock at a chosen instant, advances it by a duration, or sets it to a new instant, all without a real
7
+ * sleep and without a race against the wall clock.
8
+ */
9
+ /** The test double for the injected `Clock` port: `{ now(): Date }`, plus spec-only time control. */
10
+ export declare class FakeClock {
11
+ private _now;
12
+ /** @param at - The instant the clock starts at; defaults to now, once, at construction. */
13
+ constructor(at?: Date | number | string);
14
+ /** The current instant, matching the `Clock` port's `now(): Date`. */
15
+ now(): Date;
16
+ /** Moves the clock to `at`. */
17
+ set(at: Date | number | string): void;
18
+ /** Moves the clock forward by `ms` milliseconds (negative moves it back). */
19
+ advance(ms: number): void;
20
+ }
package/dist/clock.js ADDED
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ /**
3
+ * `FakeClock` -- the test double for the injected `Clock` port (`platform/clock`).
4
+ *
5
+ * Business code asks its `Clock` port for the time (`clock.now()`) instead of reading `Date.now()` or
6
+ * `new Date()` ambiently (`no-ambient-clock`, R79). A spec then drives time explicitly: it starts the
7
+ * clock at a chosen instant, advances it by a duration, or sets it to a new instant, all without a real
8
+ * sleep and without a race against the wall clock.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.FakeClock = void 0;
12
+ /** The test double for the injected `Clock` port: `{ now(): Date }`, plus spec-only time control. */
13
+ class FakeClock {
14
+ _now;
15
+ /** @param at - The instant the clock starts at; defaults to now, once, at construction. */
16
+ constructor(at) {
17
+ this._now = at === undefined ? new Date() : new Date(at);
18
+ }
19
+ /** The current instant, matching the `Clock` port's `now(): Date`. */
20
+ now() {
21
+ return new Date(this._now.getTime());
22
+ }
23
+ /** Moves the clock to `at`. */
24
+ set(at) {
25
+ this._now = new Date(at);
26
+ }
27
+ /** Moves the clock forward by `ms` milliseconds (negative moves it back). */
28
+ advance(ms) {
29
+ this._now = new Date(this._now.getTime() + ms);
30
+ }
31
+ }
32
+ exports.FakeClock = FakeClock;
@@ -0,0 +1,114 @@
1
+ /// <reference types="jest" preserve="true" />
2
+ /**
3
+ * `mockEntityManager()` and `fakeTransaction()` -- the one typed double of a typeorm `EntityManager`.
4
+ *
5
+ * `mockEntityManager(stubs)` takes the answers a spec describes as `{ method: [Entity, result] }` (`query` takes
6
+ * `[sql, rows]`; several answers of one method are a list of pairs). Every method is a `jest.fn()`, so
7
+ * `expect(em.save).toHaveBeenCalledWith(...)` works, and a method the subject calls that the spec did NOT stub throws
8
+ * `mockEntityManager: em.<method>() was called but this spec did not stub it`. The absence of a stub is therefore the
9
+ * proof that the subject made no such call. A method stubbed for another entity (or another sql text) throws too and names
10
+ * both. Answers of one method and one entity are handed out in order and the last one repeats. There is no typeorm import
11
+ * at run time: the types come from `typeorm` (type-only), the runtime is a Proxy.
12
+ *
13
+ * `create(Entity, plain)` is pure construction and works without a stub. `transaction` needs `fakeTransaction`.
14
+ *
15
+ * `fakeTransaction(em)` makes `em.transaction(work)` (and `em.transaction(isolation, work)`) run `work` with a scoped view
16
+ * of the same double and record how it ended (`commit` when `work` resolves, `rollback` when it throws and the error is
17
+ * rethrown) and which writes it made, so a spec can assert that a rolled-back transaction committed nothing.
18
+ */
19
+ import type { DeleteResult, EntityManager, InsertResult, UpdateResult } from "typeorm";
20
+ import type { MockOf } from "./mock.ts";
21
+ /** A typed double of `EntityManager`: assignable wherever an `EntityManager` is required, every method a jest mock. */
22
+ export type MockEntityManager = MockOf<EntityManager>;
23
+ /** An entity class. */
24
+ export type EntityClass<E extends object = object> = new (...args: never[]) => E;
25
+ type Row<C extends EntityClass> = InstanceType<C>;
26
+ type Write<T> = Partial<Omit<T, "raw">> & {
27
+ readonly raw?: unknown;
28
+ };
29
+ /** The result type of each entity-taking `EntityManager` method, for the entity class `C`. */
30
+ export interface EntityAnswers<C extends EntityClass> {
31
+ findOne: Row<C> | null;
32
+ findOneBy: Row<C> | null;
33
+ findOneOrFail: Row<C>;
34
+ findOneByOrFail: Row<C>;
35
+ find: ReadonlyArray<Row<C>>;
36
+ findBy: ReadonlyArray<Row<C>>;
37
+ findAndCount: readonly [ReadonlyArray<Row<C>>, number];
38
+ findAndCountBy: readonly [ReadonlyArray<Row<C>>, number];
39
+ count: number;
40
+ countBy: number;
41
+ exists: boolean;
42
+ existsBy: boolean;
43
+ save: Row<C> | ReadonlyArray<Row<C>>;
44
+ remove: Row<C> | ReadonlyArray<Row<C>>;
45
+ softRemove: Row<C> | ReadonlyArray<Row<C>>;
46
+ recover: Row<C> | ReadonlyArray<Row<C>>;
47
+ insert: Write<InsertResult>;
48
+ update: Write<UpdateResult>;
49
+ upsert: Write<InsertResult>;
50
+ delete: Write<DeleteResult>;
51
+ softDelete: Write<UpdateResult>;
52
+ restore: Write<UpdateResult>;
53
+ increment: Write<UpdateResult>;
54
+ decrement: Write<UpdateResult>;
55
+ }
56
+ /** The methods a spec may stub as `[Entity, result]`. */
57
+ export type EntityMethod = keyof EntityAnswers<EntityClass>;
58
+ type Pair<M extends EntityMethod, C extends EntityClass> = readonly [entity: C, result: EntityAnswers<C>[M]];
59
+ type Untyped = readonly [unknown, unknown];
60
+ /** One stub: `[Entity, result]` (`query`: `[sql, rows]`), or a list of such pairs answered in order. */
61
+ export type StubOf<M extends string, V> = M extends "query" ? readonly [sql: string, rows: unknown] | ReadonlyArray<readonly [sql: string, rows: unknown]> : M extends EntityMethod ? V extends readonly [infer C extends EntityClass, unknown] ? Pair<M, C> : V extends ReadonlyArray<readonly [infer C extends EntityClass, unknown]> ? ReadonlyArray<Pair<M, C>> : never : never;
62
+ /** The stubs of a spec, checked entry by entry against the entity each one names. */
63
+ export type StubsOf<S> = {
64
+ readonly [M in keyof S & string]: StubOf<M, S[M]>;
65
+ };
66
+ /**
67
+ * A strict `EntityManager` double. Describe what the subject should see, `{ findOne: [OrderEntity, order] }`; any method
68
+ * the subject calls that the spec did not stub throws an error naming the method, and a wrong entity or sql text throws
69
+ * too. Results are typed against the entity: `findOne: [OrderEntity, 5]` is a compile error.
70
+ */
71
+ export type MockEntityManagerFactory = <const S extends {
72
+ readonly [M in string]?: Untyped | ReadonlyArray<Untyped>;
73
+ } = Record<never, never>>(stubs?: S & StubsOf<S>) => MockEntityManager;
74
+ /** A write made inside a transaction. */
75
+ export interface TransactionWrite {
76
+ readonly method: string;
77
+ readonly args: ReadonlyArray<unknown>;
78
+ }
79
+ /** The transaction attached to a double by `fakeTransaction`. */
80
+ export interface FakeTransaction<M extends MockEntityManager = MockEntityManager> {
81
+ /** The double whose `transaction(work)` runs `work`. */
82
+ readonly em: M;
83
+ /** How each transaction ended, in call order. */
84
+ readonly outcomes: ReadonlyArray<"commit" | "rollback" | "pending">;
85
+ /** Number of transactions that committed. */
86
+ readonly commits: number;
87
+ /** Number of transactions that rolled back (the callback threw; the error was rethrown). */
88
+ readonly rollbacks: number;
89
+ /** The writes of the transactions that committed. */
90
+ readonly committedWrites: ReadonlyArray<TransactionWrite>;
91
+ /** The writes of the transactions that rolled back: what a real rollback would have discarded. */
92
+ readonly rolledBackWrites: ReadonlyArray<TransactionWrite>;
93
+ }
94
+ /** Makes `em.transaction(work)` run `work` with a scoped view whose queryRunner stays active until commit or rollback. */
95
+ export type FakeTransactionFactory = <M extends MockEntityManager = MockEntityManager>(em?: M) => FakeTransaction<M>;
96
+ /**
97
+ * The kit over a function factory (`makeFn(implementation)`); `mockEntityManager` and `fakeTransaction` below bind it to
98
+ * `jest.fn`. The seam lets the package spec run the kit without a jest runtime.
99
+ */
100
+ export declare function createEntityManagerKit(makeFn: (implementation?: (...args: never[]) => unknown) => unknown): {
101
+ mockEntityManager: MockEntityManagerFactory;
102
+ fakeTransaction: FakeTransactionFactory;
103
+ };
104
+ /** Whether `manager` is the scoped view a `fakeTransaction` body received (used by `recordingEventBus` and `recordingQueueOutbox`). */
105
+ export declare const isTransactionManager: (manager: unknown) => boolean;
106
+ /**
107
+ * A strict `EntityManager` double. Describe what the subject should see, `{ findOne: [OrderEntity, order] }`; any method
108
+ * the subject calls that the spec did not stub throws an error naming the method, and a wrong entity or sql text throws
109
+ * too. Results are typed against the entity: `findOne: [OrderEntity, 5]` is a compile error.
110
+ */
111
+ export declare const mockEntityManager: MockEntityManagerFactory;
112
+ /** Makes `em.transaction(work)` run `work` with a scoped view whose queryRunner stays active until commit or rollback. */
113
+ export declare const fakeTransaction: FakeTransactionFactory;
114
+ export {};
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+ /// <reference types="jest" preserve="true" />
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.fakeTransaction = exports.mockEntityManager = exports.isTransactionManager = void 0;
5
+ exports.createEntityManagerKit = createEntityManagerKit;
6
+ const mock_ts_1 = require("./mock.js");
7
+ /** The manager views handed to a transaction body; `recordingEventBus` and `recordingQueueOutbox` ask whether a manager is one of them. */
8
+ const TRANSACTION_MANAGERS = new WeakSet();
9
+ /** Methods that change data; a write made inside a transaction is recorded on it. */
10
+ const WRITE_METHODS = new Set([
11
+ "save", "insert", "update", "delete", "remove", "softRemove", "softDelete", "recover", "restore", "upsert",
12
+ "increment", "decrement", "clear",
13
+ ]);
14
+ const WRITE_SQL = /^\s*(insert|update|delete|upsert|merge|truncate)\b/i;
15
+ const describeTarget = (target) => typeof target === "function" ? target.name || "<anonymous class>" : JSON.stringify(String(target)).slice(0, 80);
16
+ const isPair = (value) => Array.isArray(value) && value.length === 2 && (typeof value[0] === "function" || typeof value[0] === "string");
17
+ /** `{ method: [target, result] | [[target, result], ...] }` as `Map<method, Array<{ target, result, used }>>`. */
18
+ function tableOf(stubs) {
19
+ const table = new Map();
20
+ for (const [method, value] of Object.entries(stubs)) {
21
+ const pairs = isPair(value) ? [value] : value;
22
+ if (!Array.isArray(pairs) || !pairs.every(isPair)) {
23
+ throw new Error(`mockEntityManager: the stub of em.${method}() must be [Entity, result] (em.query: [sql, rows]) or a list of such pairs`);
24
+ }
25
+ table.set(method, pairs.map(([target, result]) => ({ target, result, used: false })));
26
+ }
27
+ return table;
28
+ }
29
+ /**
30
+ * The kit over a function factory (`makeFn(implementation)`); `mockEntityManager` and `fakeTransaction` below bind it to
31
+ * `jest.fn`. The seam lets the package spec run the kit without a jest runtime.
32
+ */
33
+ function createEntityManagerKit(makeFn) {
34
+ function answerOf(method, entries) {
35
+ if (entries === undefined) {
36
+ return () => {
37
+ throw new Error(`mockEntityManager: em.${method}() was called but this spec did not stub it. Stub it with mockEntityManager({ ${method}: [Entity, result] }); an unstubbed call means the subject reached the database where the spec says it must not.`);
38
+ };
39
+ }
40
+ return async (...args) => {
41
+ const matches = entries.filter((entry) => entry.target === args[0]);
42
+ const last = matches[matches.length - 1];
43
+ if (last === undefined) {
44
+ throw new Error(`mockEntityManager: em.${method}(${describeTarget(args[0])}) was called but this spec stubbed em.${method} only for ${entries.map((entry) => describeTarget(entry.target)).join(", ")}`);
45
+ }
46
+ const entry = matches.find((candidate) => !candidate.used) ?? last;
47
+ entry.used = true;
48
+ return entry.result;
49
+ };
50
+ }
51
+ function mockEntityManager(stubs = {}) {
52
+ const table = tableOf(stubs);
53
+ const create = makeFn((target, plain) => Object.assign(new target(), plain));
54
+ return (0, mock_ts_1.createMock)((method) => makeFn(answerOf(method, table.get(method))))({ create });
55
+ }
56
+ function fakeTransaction(em = mockEntityManager()) {
57
+ const runs = [];
58
+ const scopedFor = (run) => {
59
+ const view = new Proxy(em, {
60
+ get(target, property, receiver) {
61
+ if (property === "queryRunner")
62
+ return { get isTransactionActive() { return run.outcome === "pending"; } };
63
+ const value = Reflect.get(target, property, receiver);
64
+ if (property === "transaction") {
65
+ return (...args) => {
66
+ const work = args[args.length - 1];
67
+ if (typeof work !== "function")
68
+ throw new TypeError(`${String(work)} is not a function`);
69
+ return work(view);
70
+ };
71
+ }
72
+ if (typeof property !== "string" || typeof value !== "function")
73
+ return value;
74
+ if (!WRITE_METHODS.has(property) && property !== "query")
75
+ return value;
76
+ return (...args) => {
77
+ if (property !== "query" || WRITE_SQL.test(String(args[0])))
78
+ run.writes.push({ method: property, args });
79
+ return value(...args);
80
+ };
81
+ },
82
+ });
83
+ TRANSACTION_MANAGERS.add(view);
84
+ return view;
85
+ };
86
+ Reflect.set(em, "transaction", makeFn(async (...args) => {
87
+ const work = args[args.length - 1];
88
+ if (typeof work !== "function")
89
+ throw new Error("fakeTransaction: em.transaction() needs a callback as its last argument");
90
+ const run = { outcome: "pending", writes: [] };
91
+ runs.push(run);
92
+ try {
93
+ const result = await work(scopedFor(run));
94
+ run.outcome = "commit";
95
+ return result;
96
+ }
97
+ catch (error) {
98
+ run.outcome = "rollback";
99
+ throw error;
100
+ }
101
+ }));
102
+ const writesOf = (outcome) => runs.filter((run) => run.outcome === outcome).flatMap((run) => run.writes);
103
+ return {
104
+ em,
105
+ /** How each transaction ended, in call order: "commit" or "rollback". */
106
+ get outcomes() {
107
+ return runs.map((run) => run.outcome);
108
+ },
109
+ get commits() {
110
+ return runs.filter((run) => run.outcome === "commit").length;
111
+ },
112
+ get rollbacks() {
113
+ return runs.filter((run) => run.outcome === "rollback").length;
114
+ },
115
+ /** The writes (`{ method, args }`) that ended in a commit, in call order. */
116
+ get committedWrites() {
117
+ return writesOf("commit");
118
+ },
119
+ /** The writes that a rollback discarded: made by a body that then threw. */
120
+ get rolledBackWrites() {
121
+ return writesOf("rollback");
122
+ },
123
+ };
124
+ }
125
+ return { mockEntityManager, fakeTransaction };
126
+ }
127
+ /** Whether `manager` is the scoped view a `fakeTransaction` body received (used by `recordingEventBus` and `recordingQueueOutbox`). */
128
+ const isTransactionManager = (manager) => typeof manager === "object" && manager !== null && TRANSACTION_MANAGERS.has(manager);
129
+ exports.isTransactionManager = isTransactionManager;
130
+ const kit = createEntityManagerKit(mock_ts_1.jestFn);
131
+ /**
132
+ * A strict `EntityManager` double. Describe what the subject should see, `{ findOne: [OrderEntity, order] }`; any method
133
+ * the subject calls that the spec did not stub throws an error naming the method, and a wrong entity or sql text throws
134
+ * too. Results are typed against the entity: `findOne: [OrderEntity, 5]` is a compile error.
135
+ */
136
+ exports.mockEntityManager = kit.mockEntityManager;
137
+ /** Makes `em.transaction(work)` run `work` with a scoped view whose queryRunner stays active until commit or rollback. */
138
+ exports.fakeTransaction = kit.fakeTransaction;
@@ -0,0 +1,52 @@
1
+ /** An event as the bus port takes it: a class instance with a stable id; its name is the static `eventName` of its class. */
2
+ export interface RecordedEvent {
3
+ readonly eventId: string;
4
+ }
5
+ /** The static side of an event class. */
6
+ export interface RecordedEventClass {
7
+ readonly eventName: string;
8
+ }
9
+ /** One recorded publication. */
10
+ export interface EventWrite<E extends RecordedEvent = RecordedEvent> {
11
+ readonly event: E;
12
+ readonly tx: unknown;
13
+ /** Whether `tx` was the view a `fakeTransaction` body received. */
14
+ readonly inTransaction: boolean;
15
+ }
16
+ /** A dead letter as the bus port returns it. */
17
+ export interface RecordedDeadLetter {
18
+ readonly id: string;
19
+ readonly eventName: string;
20
+ readonly eventId: string;
21
+ readonly reason: string;
22
+ readonly attempts: number;
23
+ }
24
+ /** The bus operations `failNext` can make reject. */
25
+ export type EventBusOperation = "publish" | "pendingRetries" | "deadLetters" | "requeue";
26
+ /** An `EventBus` double that records publications and scripts the read side. */
27
+ export interface RecordingEventBus<E extends RecordedEvent = RecordedEvent> {
28
+ publish(event: E, tx: unknown): Promise<void>;
29
+ pendingRetries(eventClass: RecordedEventClass): Promise<number>;
30
+ deadLetters(eventClass: RecordedEventClass): Promise<ReadonlyArray<RecordedDeadLetter>>;
31
+ requeue(id: string): Promise<void>;
32
+ /** What the outbox would hold: the first publication of each (event name, event id). */
33
+ readonly events: ReadonlyArray<E>;
34
+ /** Every publication with its manager, duplicates included. */
35
+ readonly entries: ReadonlyArray<EventWrite<E>>;
36
+ /** Every event published, duplicates included. */
37
+ readonly writes: ReadonlyArray<E>;
38
+ /** True when something was published and every publication went through a `fakeTransaction` manager. */
39
+ readonly allInTransaction: boolean;
40
+ eventsOf(eventClass: RecordedEventClass): ReadonlyArray<E>;
41
+ /** Sets the answer of `pendingRetries`. */
42
+ setPendingRetries(count: number): void;
43
+ /** Adds dead letters `deadLetters` hands out. */
44
+ queueDeadLetters(...items: ReadonlyArray<RecordedDeadLetter>): void;
45
+ /** The ids passed to `requeue`. */
46
+ readonly requeued: ReadonlyArray<string>;
47
+ /** Makes the next call of `operation` reject with `error`, once. */
48
+ failNext(operation: EventBusOperation, error: Error): void;
49
+ clear(): void;
50
+ }
51
+ /** The event bus double. */
52
+ export declare function recordingEventBus<E extends RecordedEvent = RecordedEvent>(): RecordingEventBus<E>;
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.recordingEventBus = recordingEventBus;
4
+ /**
5
+ * `recordingEventBus()` -- an `EventBus` double that records what a domain service publishes, so a spec asserts on `bus.events`.
6
+ *
7
+ * `publish(event, tx)` records the event with the manager it was written through. `events` is what the outbox would hold: one
8
+ * event per (event name, event id), like the real unique key, so a duplicate publication keeps one. `writes` is every call, duplicates
9
+ * included, and `entries` adds `inTransaction`: whether `tx` was the view a `fakeTransaction` body received, so a spec proves an event
10
+ * is published in the transaction of the change that caused it. The name of an event is the static `eventName` of its class.
11
+ *
12
+ * The read side is scripted: `setPendingRetries(count)` sets what `pendingRetries(eventClass)` answers, `deadLetters(eventClass)`
13
+ * returns what `queueDeadLetters(...letters)` filled, `requeue(id)` records the id, and `failNext(operation, error)` makes the next
14
+ * call of that operation reject once, so a spec drives the failure path.
15
+ */
16
+ const entity_manager_ts_1 = require("./entity-manager.js");
17
+ /** The name of an event: the static `eventName` of its class. */
18
+ const nameOf = (event) => {
19
+ const eventClass = event.constructor;
20
+ return typeof eventClass === "function" && "eventName" in eventClass ? eventClass.eventName : undefined;
21
+ };
22
+ /** The event bus double. */
23
+ function recordingEventBus() {
24
+ const written = [];
25
+ const letters = [];
26
+ const requeued = [];
27
+ const failures = new Map();
28
+ let pending = 0;
29
+ const failIfScripted = (operation) => {
30
+ const error = failures.get(operation);
31
+ if (error === undefined)
32
+ return;
33
+ failures.delete(operation);
34
+ throw error;
35
+ };
36
+ return {
37
+ async publish(event, tx) {
38
+ failIfScripted("publish");
39
+ written.push({ event, tx, inTransaction: (0, entity_manager_ts_1.isTransactionManager)(tx) });
40
+ },
41
+ async pendingRetries() {
42
+ failIfScripted("pendingRetries");
43
+ return pending;
44
+ },
45
+ async deadLetters(eventClass) {
46
+ failIfScripted("deadLetters");
47
+ return letters.filter((letter) => letter.eventName === eventClass.eventName);
48
+ },
49
+ async requeue(id) {
50
+ failIfScripted("requeue");
51
+ requeued.push(id);
52
+ },
53
+ /** The events the outbox would hold: the first publication of each (event name, event id). */
54
+ get events() {
55
+ const seen = new Set();
56
+ return written
57
+ .filter(({ event }) => {
58
+ const key = `${String(nameOf(event))}\u0000${event.eventId}`;
59
+ if (seen.has(key))
60
+ return false;
61
+ seen.add(key);
62
+ return true;
63
+ })
64
+ .map(({ event }) => event);
65
+ },
66
+ /** Every publication, in order: `{ event, tx, inTransaction }`. */
67
+ get entries() {
68
+ return [...written];
69
+ },
70
+ /** Every event published, duplicates included. */
71
+ get writes() {
72
+ return written.map(({ event }) => event);
73
+ },
74
+ /** True when something was published and every publication went through a transaction manager. */
75
+ get allInTransaction() {
76
+ return written.length > 0 && written.every((entry) => entry.inTransaction);
77
+ },
78
+ /** The stored events of one class. */
79
+ eventsOf(eventClass) {
80
+ return this.events.filter((event) => nameOf(event) === eventClass.eventName);
81
+ },
82
+ /** Sets the answer of `pendingRetries`. */
83
+ setPendingRetries(count) {
84
+ pending = count;
85
+ },
86
+ /** Adds dead letters `deadLetters` hands out. */
87
+ queueDeadLetters(...items) {
88
+ letters.push(...items);
89
+ },
90
+ /** The ids passed to `requeue`, in order. */
91
+ get requeued() {
92
+ return [...requeued];
93
+ },
94
+ /** Makes the next call of `operation` reject with `error`, once. */
95
+ failNext(operation, error) {
96
+ failures.set(operation, error);
97
+ },
98
+ clear() {
99
+ written.length = 0;
100
+ letters.length = 0;
101
+ requeued.length = 0;
102
+ pending = 0;
103
+ failures.clear();
104
+ },
105
+ };
106
+ }