@starci/jest-preset 1.1.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 2.1.0 - 2026-09-30
4
+
5
+ - Added: the claim side of `recordingOutbox()`, so a claim-side consumer is specced with the kit double the `OUTBOX` token requires (`spec-infra-double-from-kit`) instead of `mock<Outbox>()`. `queueRecords(...records)` fills a backlog that `claimDue(params)` hands out in order (only the asked queues, at most `limit`, removed once claimed; an empty backlog is an empty claim, a record queued twice is a duplicate delivery), `failNext(operation, error)` makes the next `enqueue`, `claimDue`, `complete`, `retry` or `bury` reject once, and `claims`, `completed`, `retried`, `buried` and `backlog` expose what happened. `RecordingOutbox<M, R>` takes the claimed record type as a second parameter; `ClaimedRecord`, `ClaimParams`, `RetryParams`, `BuryParams` and `OutboxOperation` are exported from `./outbox`. `clear()` resets the claim side too. No breaking change.
6
+
7
+ ## 2.0.0 - 2026-09-30
8
+
9
+ - Breaking: `starciJestConfig()` takes no options. The repository `jest.config.js` is a managed file, exactly `module.exports = require("@starci/jest-preset").starciJestConfig()`, rendered by `hfs sync` and compared by `hfs check` (HFS_MANAGED_FILE_DRIFT), so `moduleNameMapper`, `roots`, `tsconfig`, `rootDir`, `unit` and `e2e` overrides are gone. `StarciJestOptions` is removed.
10
+ - Added: the three path aliases (`@features/*`, `@modules/*`, `@tests/*`) are part of the preset (`MODULE_NAME_MAPPER`), matching the aliases the managed `tsconfig.json` declares.
11
+ - Added: `coverageProvider: "v8"`, so branch coverage counts real source, not the helpers TypeScript emits.
12
+ - Breaking: four projects, one per test kind (owner test layout 2026-09-30): `unit` (colocated `*.spec.ts`, ignores `src/tests/{world,integration,e2e,contract}/`), `integration` (`src/tests/integration/**/*.integration-spec.ts`), `e2e` (`src/tests/e2e/**/*.e2e-spec.ts`) and `contract` (`src/tests/contract/**/*.contract-spec.ts`). The last three compile against `src/tests/tsconfig.json`, run one worker and share the test world's `globalSetup`/`globalTeardown` (`src/tests/world/global-setup.ts`, `global-teardown.ts`); there is no `setupFilesAfterEnv`. `src/tests/e2e/live/`, `E2E_LIVE` and `test:e2e:live` are gone: a provider sandbox is a contract spec. `typecheck:e2e` is replaced by `typecheck:tests`.
13
+ - Unchanged: ts-jest with `diagnostics: false` (`isolatedModules` comes from `@starci/tsconfig`), `mock<T>()` and `FakeClock`.
14
+ - Added (unit test standard, lane UT): the shared unit double kit on the package root, `mockEntityManager({ method: [Entity, result] })` (typed against the entity, `query: [sql, rows]`, a list of pairs for several answers, every method a `jest.fn`, any unstubbed method throws an error naming it, `create(Entity, plain)` works unstubbed), `fakeTransaction(em)` (`outcomes`, `commits`, `rollbacks`, `committedWrites`, `rolledBackWrites`), `fakeCache(clock)` (JSON round trip, TTL from the clock, `has`, `ttlOf`, `keys`, typed-key and cache-manager shapes), `fakeLock(clock)` (`acquire`, `release`, `isHeld`, `holderOf`, `fenceOf`), `recordingOutbox()` (`enqueue`, `messages`, `writes`, `entries` with `inTransaction`), `builder<T>(defaults)`, `fakeIds()` and the Outcome matchers `toBeRefused` and `toSucceedWith` (installed in the unit project through `setupFilesAfterEnv`). Sub-path exports `./cache`, `./lock`, `./outbox`, `./builders`, `./entity-manager`, `./ids` and `./matchers` were added next to `./mock` and `./clock`.
15
+ - Breaking: coverage is collected from `src/**/*.service.ts` only (minus `src/tests/**`, `dist`, `coverage`), with a per-file threshold of 100 on lines, branches, functions and statements (`coverageThreshold` glob key `./src/**/*.service.ts`); the managed `test` script runs the unit project with `--coverage`. The previous denominator (`src/**/*.ts` and `apps/**/*.ts`) is gone. The unit project overlays `isolatedModules: false` and `importHelpers: true` on the repository tsconfig (exported as `UNIT_COMPILER_OPTIONS`) so a decorated service can reach 100 (no `design:paramtypes` guard branches, no inlined decorator helpers); repositories list `tslib` in devDependencies.
16
+ - Breaking: `sonarCoverageExclusions()` is removed and coverage reporters are `text-summary` and `text` only (no `lcov`): Sonar does not depend on coverage and Codecov is not fed by the preset. `sonarExclusions()` remains and renders `sonar.exclusions`.
17
+ - Breaking (convention): a unit spec exists only as `<name>.service.spec.ts` beside a `*.service.ts` and builds the service with `Test.createTestingModule({ providers })`; `mockEntityManager()` and `fakeTransaction()` from `src/tests/fixtures/database.ts` are replaced by the kit of this package.
package/README.md CHANGED
@@ -1,31 +1,144 @@
1
1
  # @starci/jest-preset
2
2
 
3
- The jest config of a Nest repository, and the typed `mock<T>()` helper. Install it from the npm registry at the exact version in [`knowledge/hfs/canon-pins.yaml`](../../knowledge/hfs/canon-pins.yaml) (see [`packages/README.md`](../README.md)).
3
+ The jest config of a Nest repository and the one shared unit-test double kit (`mock<T>()`, `mockEntityManager`, `fakeTransaction`, `fakeCache`, `fakeLock`, `recordingOutbox`, `builder`, `FakeClock`, `fakeIds` and the Outcome matchers). Install it from the npm registry at the exact version in [`knowledge/hfs/canon-pins.yaml`](../../knowledge/hfs/canon-pins.yaml) (see [`packages/README.md`](../README.md)).
4
4
 
5
5
  ```js
6
- // jest.config.js
7
- const { pathsToModuleNameMapper } = require("ts-jest")
8
- const { starciJestConfig } = require("@starci/jest-preset")
9
- const { compilerOptions } = require("./tsconfig.json")
10
-
11
- module.exports = starciJestConfig({
12
- moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: `${__dirname}/` }),
13
- e2e: { globalSetup: "<rootDir>/src/tests/e2e/setup/global-setup.js" },
14
- })
6
+ // jest.config.js (a managed file: `hfs sync` renders it, `hfs check` compares it; do not edit)
7
+ module.exports = require("@starci/jest-preset").starciJestConfig()
15
8
  ```
16
9
 
10
+ `starciJestConfig()` takes no options: a repository has no jest setting to tune, so the file above is the whole config.
11
+
17
12
  ## What it sets
18
13
 
19
- - **Two projects**: `unit` (`**/*.spec.ts`, mocked, `clearMocks`) and `e2e` (`src/tests/e2e/**/*.e2e-spec.ts`, one worker, 120 s).
20
- `src/tests/e2e/live/` is skipped unless `E2E_LIVE=1`; `test:e2e:live` is `E2E_LIVE=1 jest --selectProjects e2e src/tests/e2e/live`.
21
- - **ts-jest with `diagnostics: false`** in both. `isolatedModules: true` comes from `@starci/tsconfig` (ts-jest 29.4 reads it
22
- there; its own option is deprecated). Types are checked once, by `typecheck` (unit specs included) and `typecheck:e2e`, and
23
- `test:e2e` is `npm run typecheck:e2e && jest --selectProjects e2e`, so nobody runs e2e on code that does not type-check.
24
- - **Coverage that matches Sonar.** `collectCoverageFrom` is `src/**/*.ts` and `apps/**/*.ts` minus specs, e2e specs, `dist`,
25
- `coverage`, `src/tests/**`, `*.d.ts` and `main.ts`: the same set Sonar counts. `sonarExclusions()` and
26
- `sonarCoverageExclusions()` render the two `sonar-project.properties` values from the same lists, so the denominators
27
- cannot drift. Reports are `coverage/lcov.info` (Codecov and Sonar read the one file) and a text summary. Thresholds are not
28
- set here; Codecov's project and patch targets own the gate.
14
+ - **Four projects, one per test kind**: `unit` (`**/*.spec.ts`, which by the unit standard means one `<name>.service.spec.ts` beside each `*.service.ts`; `clearMocks`; the Outcome matchers are installed through `setupFilesAfterEnv`; ignores
15
+ `src/tests/{world,integration,e2e,contract}/`), `integration` (`src/tests/integration/**/*.integration-spec.ts`), `e2e`
16
+ (`src/tests/e2e/**/*.e2e-spec.ts`) and `contract` (`src/tests/contract/**/*.contract-spec.ts`). The last three compile against
17
+ `src/tests/tsconfig.json`, run one worker, and start the one test world through `globalSetup`/`globalTeardown`
18
+ (`src/tests/world/global-setup.ts`, `global-teardown.ts`); only the unit project has a `setupFilesAfterEnv`. The managed scripts select one
19
+ project each: `test`, `test:integration`, `test:e2e`, `test:contract`; a contract spec skips itself without sandbox config.
20
+ - **The three path aliases** `@features/*`, `@modules/*`, `@tests/*` (`MODULE_NAME_MAPPER`), the same ones the managed `tsconfig.json` declares.
21
+ - **ts-jest with `diagnostics: false`** in every project. `isolatedModules: true` comes from `@starci/tsconfig` (ts-jest 29.4 reads it
22
+ there; its own option is deprecated). Types are checked once, by `typecheck` (unit specs included) and `typecheck:tests`
23
+ (`src/tests/tsconfig.json`), and `test:integration`/`test:e2e`/`test:contract` run `typecheck:tests` first, so no world spec
24
+ runs on code that does not type-check.
25
+ - **Coverage of services only, per-file 100.** `collectCoverageFrom` is `src/**/*.service.ts` minus `src/tests/**`, `dist` and
26
+ `coverage`, and `coverageThreshold` is `{ "./src/**/*.service.ts": { lines: 100, branches: 100, functions: 100, statements: 100 } }`,
27
+ a glob key that applies to each file on its own, so every service must reach 100 on every metric (an average never passes). The
28
+ managed `test` script runs the unit project with `--coverage` and fails below it. Coverage is measured by v8, which counts the
29
+ real source. The unit project overlays `isolatedModules: false` and `importHelpers: true` on the repository tsconfig (ts-jest merges an
30
+ inline `tsconfig` object over the `tsconfig.json` it finds): with `isolatedModules` on, TypeScript emits an unreachable
31
+ `typeof Dep !== "undefined" ? Dep : Object` guard per class-typed constructor parameter, and inlined decorator helpers carry branches
32
+ of their own, so 100 would be unreachable. The repository lists `tslib` in its devDependencies (`UNIT_COMPILER_OPTIONS`). Reporters are `text-summary` and
33
+ `text`; there is no `lcov`.
34
+ - **Sonar does not read coverage.** `sonarCoverageExclusions()` and the lcov report are removed. `sonarExclusions()` still renders
35
+ `sonar.exclusions` (`**/*.spec.ts,**/*.e2e-spec.ts,**/dist/**,**/coverage/**`); the quality gate fails on imported issues only.
36
+
37
+ ## The unit standard in one page
38
+
39
+ Only services are unit-tested, through `Test.createTestingModule`, with the doubles of this package and nothing else:
40
+
41
+ ```ts
42
+ import { Test } from "@nestjs/testing"
43
+ import { FakeClock, mockEntityManager } from "@starci/jest-preset"
44
+
45
+ const clock = new FakeClock("2026-01-01T00:00:00.000Z")
46
+ const entityManager = mockEntityManager({ findOne: [Accrual, null] })
47
+ const moduleRef = await Test.createTestingModule({
48
+ providers: [
49
+ CommissionService,
50
+ { provide: PRIMARY_ENTITY_MANAGER, useValue: entityManager }, // exactly the constructor dependencies
51
+ { provide: CLOCK, useValue: clock },
52
+ ],
53
+ }).compile()
54
+ const service = moduleRef.get(CommissionService)
55
+ ```
56
+
57
+ No `new` of the service, no `imports`, no `overrideProvider`, no `jest.mock`, no casts, no `Date.now()` and no `process.env`. The
58
+ full rules and the dependency-to-double-to-cases table are `knowledge/patterns/be/test.yaml` (BE-TEST-1 to BE-TEST-15). Every
59
+ export below is on the package root (`@starci/jest-preset`); the sub-paths `./mock`, `./clock`, `./entity-manager`, `./ids`,
60
+ `./matchers`, `./cache`, `./lock`, `./outbox` and `./builders` exist as well.
61
+
62
+ ## `mockEntityManager` and `fakeTransaction`
63
+
64
+ A strict, typed `EntityManager` double. Describe what the subject should see; every method is a `jest.fn`, and any method the
65
+ subject calls that the spec did not stub throws an error naming the method, so the absence of a stub proves the absence of that
66
+ database call. A wrong entity or SQL text throws too. `create(Entity, plain)` works without a stub.
67
+
68
+ ```ts
69
+ import { mockEntityManager, fakeTransaction } from "@starci/jest-preset"
70
+
71
+ // [Entity, result], typed against the entity: `findOne: [Order, 5]` is a compile error
72
+ const em = mockEntityManager({ findOne: [Order, order], save: [Order, saved] })
73
+ expect(em.save).toHaveBeenCalledWith(expect.objectContaining({ status: "paid" }))
74
+
75
+ // a raw query: [sql, rows]
76
+ const reader = mockEntityManager({ query: ["select 1", [{ n: 1 }]] })
77
+
78
+ // lists: a list of pairs answers several calls in order
79
+ const two = mockEntityManager({ find: [[Order, [orderA]], [Order, [orderB]]] })
80
+
81
+ // a transaction: work runs with a scoped view; commits, rollbacks and writes are recorded
82
+ const tx = fakeTransaction(mockEntityManager({ save: [Order, saved] }))
83
+ await service.place(params) // service calls em.transaction(async (manager) => ...)
84
+ expect(tx.commits).toBe(1)
85
+ expect(tx.committedWrites).toHaveLength(1) // after a throwing body: tx.rollbacks, tx.rolledBackWrites, tx.outcomes
86
+ ```
87
+
88
+ The stubbable methods are `findOne`, `findOneBy`, `findOneOrFail`, `findOneByOrFail`, `find`, `findBy`, `findAndCount`,
89
+ `findAndCountBy`, `count`, `countBy`, `exists`, `existsBy`, `save`, `remove`, `softRemove`, `recover`, `insert`, `update`, `upsert`,
90
+ `delete`, `softDelete`, `restore`, `increment`, `decrement` and `query`. `fakeTransaction(em)` returns `{ em, outcomes, commits,
91
+ rollbacks, committedWrites, rolledBackWrites }`.
92
+
93
+ ## `fakeCache(clock)`, `fakeLock(clock)` and `recordingOutbox()`
94
+
95
+ ```ts
96
+ const clock = new FakeClock("2026-01-01T00:00:00.000Z")
97
+
98
+ const cache = fakeCache(clock) // JSON round trip; TTL from the clock
99
+ await cache.set({ key, args: ["u1"], value: profile }) // the typed-key port: get/set/del({ key, args, value })
100
+ await cache.get({ key, args: ["u1"] })
101
+ await cache.set("raw-key", value, 60_000) // the cache-manager shape: get(text), set(text, value, ttlMs)
102
+ cache.has({ key, args: ["u1"] }) // a live entry exists
103
+ cache.ttlOf({ key, args: ["u1"] }) // seconds left (rounded up), null when absent or expired
104
+ cache.keys() // the texts of the live entries
105
+ clock.advance(301_000) // entries with a 300 s TTL are now expired
106
+
107
+ const lock = fakeLock(clock) // acquire, contention, release, expiry, growing fence
108
+ const grant = await lock.acquire({ name: "writer", holder: "a", ttlMs: 30_000 }) // null when held by another
109
+ lock.isHeld("writer"); lock.holderOf("writer"); lock.fenceOf("writer")
110
+ await lock.release({ grant })
111
+
112
+ const outbox = recordingOutbox() // enqueue(manager, message)
113
+ outbox.messages // what the store would hold: the first write of each (queue, eventId)
114
+ outbox.writes // every message written, duplicates included
115
+ outbox.entries // every write with its manager and `inTransaction`
116
+ outbox.allInTransaction; outbox.messagesOf("queue"); outbox.clear()
117
+
118
+ // the claim side: script what a worker's claim returns
119
+ outbox.queueRecords(record) // the backlog claimDue hands out, in order, filtered by queue and cut at limit
120
+ outbox.queueRecords(record, record) // a duplicate delivery
121
+ await outbox.claimDue(params) // [] when the backlog is empty
122
+ outbox.failNext("complete", new Error("db down")) // the next call of that operation rejects once
123
+ outbox.claims; outbox.completed; outbox.retried; outbox.buried; outbox.backlog
124
+ ```
125
+
126
+ ## `builder`, `fakeIds` and the Outcome matchers
127
+
128
+ ```ts
129
+ import { builder, fakeIds } from "@starci/jest-preset"
130
+
131
+ const options = builder<CommissionOptions>({ bps: 3000, enabled: true }) // a function returning the defaults with overrides applied
132
+ const disabled = options({ enabled: false })
133
+ const ids = fakeIds() // ids.next() === "00000000-0000-4000-8000-000000000001", then ...0002; ids.issued, ids.reset()
134
+
135
+ expect(outcome).toBeRefused("COMMISSION_SELF_REFERRAL") // { kind: "refused" } with this code
136
+ expect(outcome).toBeRefused({ code: "PLAN_LIMIT", params: { max: 3 } }) // and these params
137
+ expect(outcome).toSucceedWith(accrual) // { kind: "ok" } whose value equals `accrual`
138
+ ```
139
+
140
+ The matchers are installed in the unit project only (`setupFilesAfterEnv`); their types are declared globally by importing the
141
+ package.
29
142
 
30
143
  ## `mock<T>()`
31
144
 
package/builders.cjs ADDED
@@ -0,0 +1,15 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * `builder(defaults)` -- the one way a spec makes a plain value with a few fields changed: a module options object
5
+ * (`<CAP>_OPTIONS`), a principal, a request context. Options tokens are provided as REAL values, never as doubles; a
6
+ * builder holds the valid defaults once so each spec states only the field it varies.
7
+ *
8
+ * const options = builder<OrderOptions>({ maxLines: 50, allowGuestCheckout: false })
9
+ * options({ allowGuestCheckout: true }) // a whole OrderOptions with one flag flipped
10
+ */
11
+ function builder(defaults) {
12
+ return (overrides) => ({ ...defaults, ...overrides })
13
+ }
14
+
15
+ module.exports = { builder }
package/builders.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** Makes a builder of `T`: a function returning the defaults with `overrides` applied. */
2
+ export declare function builder<T extends object>(defaults: T): (overrides?: Partial<T>) => T
package/cache.cjs ADDED
@@ -0,0 +1,67 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * `fakeCache(clock)` -- a behavioural in-memory double of a cache port.
5
+ *
6
+ * It keeps entries as JSON text (like a Redis-backed cache, so a Date comes back as a string and a `key.parse` narrows
7
+ * what was stored), expires them from the `FakeClock` (`clock.advance(ms)` is a TTL expiry, no sleep), and offers the
8
+ * inspection a spec needs: `has(key)`, `ttlOf(key)`, `keys()`.
9
+ *
10
+ * Two call shapes are served by the same object, so it stands in for either kind of cache dependency:
11
+ * - the typed-key port: `get({ key, args })`, `set({ key, args, value })`, `del({ key, args })`, where `key` is
12
+ * `{ name, ttl: { seconds }, parse? }` and the entry text is `<name>:<arg>:...`;
13
+ * - the cache-manager shape: `get(text)`, `set(text, value, ttlMs?)`, `del(text)`.
14
+ * `has`, `ttlOf` and `del` accept a request or the entry text.
15
+ */
16
+
17
+ const textOf = (request) => (typeof request === "string" ? request : [request.key.name, ...(request.args ?? [])].join(":"))
18
+
19
+ /** @param {{ now(): Date }} clock */
20
+ function fakeCache(clock) {
21
+ if (!clock || typeof clock.now !== "function") throw new Error("fakeCache: pass the FakeClock the spec drives, fakeCache(clock)")
22
+ const entries = new Map()
23
+
24
+ const live = (text) => {
25
+ const entry = entries.get(text)
26
+ if (entry === undefined) 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
+
34
+ return {
35
+ async get(request) {
36
+ const entry = live(textOf(request))
37
+ if (entry === undefined) return null
38
+ const stored = JSON.parse(entry.json)
39
+ return typeof request === "string" || typeof request.key.parse !== "function" ? stored : request.key.parse(stored)
40
+ },
41
+ async set(request, value, ttlMs) {
42
+ const text = textOf(request)
43
+ const typed = typeof request !== "string"
44
+ const stored = typed ? request.value : value
45
+ const lifetimeMs = typed ? request.key.ttl.seconds * 1000 : ttlMs
46
+ if (lifetimeMs !== undefined && !(lifetimeMs > 0)) throw new Error(`fakeCache: the ttl of ${text} must be positive`)
47
+ entries.set(text, { json: JSON.stringify(stored), expiresAt: lifetimeMs === undefined ? null : clock.now().getTime() + lifetimeMs })
48
+ },
49
+ async del(request) {
50
+ entries.delete(textOf(request))
51
+ },
52
+ /** Whether a live (not expired) entry exists. */
53
+ has: (request) => live(textOf(request)) !== undefined,
54
+ /** Seconds left before the entry expires (rounded up), null when there is no live entry or it never expires. */
55
+ ttlOf(request) {
56
+ const entry = live(textOf(request))
57
+ if (entry === undefined || entry.expiresAt === null) return null
58
+ return Math.ceil((entry.expiresAt - clock.now().getTime()) / 1000)
59
+ },
60
+ /** The texts of the live entries. */
61
+ keys: () => [...entries.keys()].filter((text) => live(text) !== undefined),
62
+ /** Drops every entry. */
63
+ clear: () => entries.clear(),
64
+ }
65
+ }
66
+
67
+ module.exports = { fakeCache }
package/cache.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ /** The declared key of a typed cache entry: `{ name, ttl, parse? }`. */
2
+ export interface FakeCacheKey<TValue = unknown> {
3
+ readonly name: string
4
+ readonly ttl: { readonly seconds: number }
5
+ readonly parse?: (stored: unknown) => TValue | null
6
+ }
7
+ /** The typed-key request: the key plus the arguments that make the entry unique. */
8
+ export interface FakeCacheRequest<TValue = unknown> {
9
+ readonly key: FakeCacheKey<TValue>
10
+ readonly args?: ReadonlyArray<string>
11
+ }
12
+ export type FakeCacheEntry<TValue = unknown> = FakeCacheRequest<TValue> & { readonly value: TValue }
13
+
14
+ /** A behavioural in-memory cache: JSON round trip, expiry driven by the clock, inspection helpers. */
15
+ export interface FakeCache {
16
+ get<TValue>(request: FakeCacheRequest<TValue>): Promise<TValue | null>
17
+ get(text: string): Promise<unknown>
18
+ set<TValue>(entry: FakeCacheEntry<TValue>): Promise<void>
19
+ set(text: string, value: unknown, ttlMs?: number): Promise<void>
20
+ del(request: FakeCacheRequest | string): Promise<void>
21
+ /** Whether a live (not expired) entry exists. */
22
+ has(request: FakeCacheRequest | string): boolean
23
+ /** Seconds left before the entry expires (rounded up); null when absent, expired or without a ttl. */
24
+ ttlOf(request: FakeCacheRequest | string): number | null
25
+ /** The texts of the live entries. */
26
+ keys(): string[]
27
+ clear(): void
28
+ }
29
+
30
+ /** The cache double; `clock` is the `FakeClock` of the spec, so `clock.advance(ms)` expires entries. */
31
+ export declare function fakeCache(clock: { now(): Date }): FakeCache
@@ -0,0 +1,145 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * `mockEntityManager()` and `fakeTransaction()` -- the one typed double of a typeorm `EntityManager`.
5
+ *
6
+ * `mockEntityManager(stubs)` takes the answers a spec describes as `{ method: [Entity, result] }` (`query` takes
7
+ * `[sql, rows]`; several answers of one method are a list of pairs). Every method is a `jest.fn()`, so
8
+ * `expect(em.save).toHaveBeenCalledWith(...)` works, and a method the subject calls that the spec did NOT stub throws
9
+ * `mockEntityManager: em.<method>() was called but this spec did not stub it`. The absence of a stub is therefore the
10
+ * proof that the subject made no such call. A method stubbed for another entity (or another sql text) throws too and names
11
+ * both. Answers of one method and one entity are handed out in order and the last one repeats. There is no typeorm import
12
+ * here: the types come from `entity-manager.d.ts` (type-only), the runtime is a Proxy.
13
+ *
14
+ * `create(Entity, plain)` is pure construction and works without a stub. `transaction` needs `fakeTransaction`.
15
+ *
16
+ * `fakeTransaction(em)` makes `em.transaction(work)` (and `em.transaction(isolation, work)`) run `work` with a scoped view
17
+ * of the same double and record how it ended (`commit` when `work` resolves, `rollback` when it throws and the error is
18
+ * rethrown) and which writes it made, so a spec can assert that a rolled-back transaction committed nothing.
19
+ */
20
+ const { createMock, jestFn } = require("./mock.cjs")
21
+
22
+ /** The manager views handed to a transaction body; `recordingOutbox` asks whether a manager is one of them. */
23
+ const TRANSACTION_MANAGERS = new WeakSet()
24
+
25
+ /** Methods that change data; a write made inside a transaction is recorded on it. */
26
+ const WRITE_METHODS = new Set([
27
+ "save", "insert", "update", "delete", "remove", "softRemove", "softDelete", "recover", "restore", "upsert",
28
+ "increment", "decrement", "clear",
29
+ ])
30
+ const WRITE_SQL = /^\s*(insert|update|delete|upsert|merge|truncate)\b/i
31
+
32
+ const describeTarget = (target) => (typeof target === "function" ? target.name || "<anonymous class>" : JSON.stringify(String(target)).slice(0, 80))
33
+
34
+ const isPair = (value) => Array.isArray(value) && value.length === 2 && (typeof value[0] === "function" || typeof value[0] === "string")
35
+
36
+ /** `{ method: [target, result] | [[target, result], ...] }` as `Map<method, Array<{ target, result, used }>>`. */
37
+ function tableOf(stubs) {
38
+ const table = new Map()
39
+ for (const [method, value] of Object.entries(stubs)) {
40
+ const pairs = isPair(value) ? [value] : value
41
+ if (!Array.isArray(pairs) || !pairs.every(isPair)) {
42
+ throw new Error(`mockEntityManager: the stub of em.${method}() must be [Entity, result] (em.query: [sql, rows]) or a list of such pairs`)
43
+ }
44
+ table.set(method, pairs.map(([target, result]) => ({ target, result, used: false })))
45
+ }
46
+ return table
47
+ }
48
+
49
+ /**
50
+ * The kit over a function factory (`makeFn(implementation)`); `mockEntityManager` and `fakeTransaction` below bind it to
51
+ * `jest.fn`. The seam lets the package spec run the kit without a jest runtime.
52
+ */
53
+ function createEntityManagerKit(makeFn) {
54
+ function answerOf(method, entries) {
55
+ if (entries === undefined) {
56
+ return () => {
57
+ throw new Error(
58
+ `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.`,
59
+ )
60
+ }
61
+ }
62
+ return async (...args) => {
63
+ const matches = entries.filter((entry) => entry.target === args[0])
64
+ if (matches.length === 0) {
65
+ throw new Error(
66
+ `mockEntityManager: em.${method}(${describeTarget(args[0])}) was called but this spec stubbed em.${method} only for ${entries.map((entry) => describeTarget(entry.target)).join(", ")}`,
67
+ )
68
+ }
69
+ const entry = matches.find((candidate) => !candidate.used) ?? matches[matches.length - 1]
70
+ entry.used = true
71
+ return entry.result
72
+ }
73
+ }
74
+
75
+ /** @param {Record<string, unknown>} [stubs] */
76
+ function mockEntityManager(stubs = {}) {
77
+ const table = tableOf(stubs)
78
+ const create = makeFn((target, plain) => Object.assign(new target(), plain))
79
+ return createMock((method) => makeFn(answerOf(method, table.get(method))))({ create })
80
+ }
81
+
82
+ /** @param {object} [em] - The double to attach the transaction to; a fresh `mockEntityManager()` by default. */
83
+ function fakeTransaction(em = mockEntityManager()) {
84
+ const runs = []
85
+ const scopedFor = (run) => {
86
+ const view = new Proxy(em, {
87
+ get(target, property, receiver) {
88
+ const value = Reflect.get(target, property, receiver)
89
+ if (property === "transaction") return (...args) => args[args.length - 1](view)
90
+ if (typeof property !== "string" || typeof value !== "function") return value
91
+ if (!WRITE_METHODS.has(property) && property !== "query") return value
92
+ return (...args) => {
93
+ if (property !== "query" || WRITE_SQL.test(String(args[0]))) run.writes.push({ method: property, args })
94
+ return value(...args)
95
+ }
96
+ },
97
+ })
98
+ TRANSACTION_MANAGERS.add(view)
99
+ return view
100
+ }
101
+ em.transaction = makeFn(async (...args) => {
102
+ const work = args[args.length - 1]
103
+ if (typeof work !== "function") throw new Error("fakeTransaction: em.transaction() needs a callback as its last argument")
104
+ const run = { outcome: "pending", writes: [] }
105
+ runs.push(run)
106
+ try {
107
+ const result = await work(scopedFor(run))
108
+ run.outcome = "commit"
109
+ return result
110
+ } catch (error) {
111
+ run.outcome = "rollback"
112
+ throw error
113
+ }
114
+ })
115
+ const writesOf = (outcome) => runs.filter((run) => run.outcome === outcome).flatMap((run) => run.writes)
116
+ return {
117
+ em,
118
+ /** How each transaction ended, in call order: "commit" or "rollback". */
119
+ get outcomes() {
120
+ return runs.map((run) => run.outcome)
121
+ },
122
+ get commits() {
123
+ return runs.filter((run) => run.outcome === "commit").length
124
+ },
125
+ get rollbacks() {
126
+ return runs.filter((run) => run.outcome === "rollback").length
127
+ },
128
+ /** The writes (`{ method, args }`) that ended in a commit, in call order. */
129
+ get committedWrites() {
130
+ return writesOf("commit")
131
+ },
132
+ /** The writes that a rollback discarded: made by a body that then threw. */
133
+ get rolledBackWrites() {
134
+ return writesOf("rollback")
135
+ },
136
+ }
137
+ }
138
+
139
+ return { mockEntityManager, fakeTransaction }
140
+ }
141
+
142
+ /** Whether `manager` is the scoped view a `fakeTransaction` body received. */
143
+ const isTransactionManager = (manager) => typeof manager === "object" && manager !== null && TRANSACTION_MANAGERS.has(manager)
144
+
145
+ module.exports = { ...createEntityManagerKit(jestFn), createEntityManagerKit, isTransactionManager }
@@ -0,0 +1,103 @@
1
+ /// <reference types="jest" />
2
+ import type { DeleteResult, EntityManager, InsertResult, UpdateResult } from "typeorm"
3
+ import type { MockOf } from "./mock"
4
+
5
+ /** A typed double of `EntityManager`: assignable wherever an `EntityManager` is required, every method a jest mock. */
6
+ export type MockEntityManager = MockOf<EntityManager>
7
+
8
+ /** An entity class. */
9
+ export type EntityClass<E extends object = object> = new (...args: never[]) => E
10
+
11
+ type Row<C extends EntityClass> = InstanceType<C>
12
+ type Write<T> = Partial<Omit<T, "raw">> & { readonly raw?: unknown }
13
+
14
+ /** The result type of each entity-taking `EntityManager` method, for the entity class `C`. */
15
+ export interface EntityAnswers<C extends EntityClass> {
16
+ findOne: Row<C> | null
17
+ findOneBy: Row<C> | null
18
+ findOneOrFail: Row<C>
19
+ findOneByOrFail: Row<C>
20
+ find: ReadonlyArray<Row<C>>
21
+ findBy: ReadonlyArray<Row<C>>
22
+ findAndCount: readonly [ReadonlyArray<Row<C>>, number]
23
+ findAndCountBy: readonly [ReadonlyArray<Row<C>>, number]
24
+ count: number
25
+ countBy: number
26
+ exists: boolean
27
+ existsBy: boolean
28
+ save: Row<C> | ReadonlyArray<Row<C>>
29
+ remove: Row<C> | ReadonlyArray<Row<C>>
30
+ softRemove: Row<C> | ReadonlyArray<Row<C>>
31
+ recover: Row<C> | ReadonlyArray<Row<C>>
32
+ insert: Write<InsertResult>
33
+ update: Write<UpdateResult>
34
+ upsert: Write<InsertResult>
35
+ delete: Write<DeleteResult>
36
+ softDelete: Write<UpdateResult>
37
+ restore: Write<UpdateResult>
38
+ increment: Write<UpdateResult>
39
+ decrement: Write<UpdateResult>
40
+ }
41
+
42
+ /** The methods a spec may stub as `[Entity, result]`. */
43
+ export type EntityMethod = keyof EntityAnswers<EntityClass>
44
+
45
+ type Pair<M extends EntityMethod, C extends EntityClass> = readonly [entity: C, result: EntityAnswers<C>[M]]
46
+ type Untyped = readonly [unknown, unknown]
47
+
48
+ /** One stub: `[Entity, result]` (`query`: `[sql, rows]`), or a list of such pairs answered in order. */
49
+ export type StubOf<M extends string, V> = M extends "query"
50
+ ? readonly [sql: string, rows: unknown] | ReadonlyArray<readonly [sql: string, rows: unknown]>
51
+ : M extends EntityMethod
52
+ ? V extends readonly [infer C extends EntityClass, unknown]
53
+ ? Pair<M, C>
54
+ : V extends ReadonlyArray<readonly [infer C extends EntityClass, unknown]>
55
+ ? ReadonlyArray<Pair<M, C>>
56
+ : never
57
+ : never
58
+
59
+ /** The stubs of a spec, checked entry by entry against the entity each one names. */
60
+ export type StubsOf<S> = { readonly [M in keyof S & string]: StubOf<M, S[M]> }
61
+
62
+ /**
63
+ * A strict `EntityManager` double. Describe what the subject should see, `{ findOne: [OrderEntity, order] }`; any method
64
+ * the subject calls that the spec did not stub throws an error naming the method, and a wrong entity or sql text throws
65
+ * too. Results are typed against the entity: `findOne: [OrderEntity, 5]` is a compile error.
66
+ */
67
+ export declare function mockEntityManager<
68
+ const S extends { readonly [M in string]?: Untyped | ReadonlyArray<Untyped> } = Record<never, never>,
69
+ >(stubs?: S & StubsOf<S>): MockEntityManager
70
+
71
+ /** A write made inside a transaction. */
72
+ export interface TransactionWrite {
73
+ readonly method: string
74
+ readonly args: ReadonlyArray<unknown>
75
+ }
76
+
77
+ /** The transaction attached to a double by `fakeTransaction`. */
78
+ export interface FakeTransaction<M extends MockEntityManager = MockEntityManager> {
79
+ /** The double whose `transaction(work)` runs `work`. */
80
+ readonly em: M
81
+ /** How each transaction ended, in call order. */
82
+ readonly outcomes: ReadonlyArray<"commit" | "rollback" | "pending">
83
+ /** Number of transactions that committed. */
84
+ readonly commits: number
85
+ /** Number of transactions that rolled back (the callback threw; the error was rethrown). */
86
+ readonly rollbacks: number
87
+ /** The writes of the transactions that committed. */
88
+ readonly committedWrites: ReadonlyArray<TransactionWrite>
89
+ /** The writes of the transactions that rolled back: what a real rollback would have discarded. */
90
+ readonly rolledBackWrites: ReadonlyArray<TransactionWrite>
91
+ }
92
+
93
+ /** Makes `em.transaction(work)` run `work` with a scoped view of the double and record commit, rollback and writes. */
94
+ export declare function fakeTransaction<M extends MockEntityManager = MockEntityManager>(em?: M): FakeTransaction<M>
95
+
96
+ /** Whether `manager` is the view a `fakeTransaction` body received (used by `recordingOutbox`). */
97
+ export declare function isTransactionManager(manager: unknown): boolean
98
+
99
+ /** The kit over a function factory, for callers that bring their own (used by the package spec). */
100
+ export declare function createEntityManagerKit(makeFn: (implementation?: (...args: never[]) => unknown) => unknown): {
101
+ mockEntityManager: typeof mockEntityManager
102
+ fakeTransaction: typeof fakeTransaction
103
+ }
package/ids.cjs ADDED
@@ -0,0 +1,41 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * `fakeIds()` -- the test double for the platform ids port (`{ next(): string }`; the production adapter is
5
+ * `randomUUID()` behind the port). Ids are deterministic and UUID-shaped, so a spec can assert on the exact persisted id:
6
+ * the first is `00000000-0000-4000-8000-000000000001`, the second `...0002`, and so on. A `prefix` (1 to 8 hex digits)
7
+ * changes the leading group so two generators in one spec never collide.
8
+ */
9
+ class FakeIds {
10
+ /** @param {string} [prefix] - 1 to 8 hex digits for the first group; default "00000000". */
11
+ constructor(prefix = "00000000") {
12
+ if (!/^[0-9a-f]{1,8}$/i.test(prefix)) throw new Error("fakeIds: the prefix is 1 to 8 hex digits")
13
+ this._prefix = prefix.padStart(8, "0").toLowerCase()
14
+ this._issued = []
15
+ }
16
+
17
+ /** The next id. */
18
+ next() {
19
+ const n = String(this._issued.length + 1).padStart(12, "0")
20
+ const id = `${this._prefix}-0000-4000-8000-${n}`
21
+ this._issued.push(id)
22
+ return id
23
+ }
24
+
25
+ /** Every id handed out so far, in order. */
26
+ get issued() {
27
+ return [...this._issued]
28
+ }
29
+
30
+ /** Starts the sequence over. */
31
+ reset() {
32
+ this._issued = []
33
+ }
34
+ }
35
+
36
+ /** @param {string} [prefix] */
37
+ function fakeIds(prefix) {
38
+ return new FakeIds(prefix)
39
+ }
40
+
41
+ module.exports = { fakeIds, FakeIds }
package/ids.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /** The platform ids port a spec injects `fakeIds()` for: `{ next(): string }`. */
2
+ export declare class FakeIds {
3
+ constructor(prefix?: string)
4
+ /** The next deterministic UUID-shaped id. */
5
+ next(): string
6
+ /** Every id handed out so far, in order. */
7
+ readonly issued: ReadonlyArray<string>
8
+ /** Starts the sequence over. */
9
+ reset(): void
10
+ }
11
+
12
+ /** A deterministic id generator: `00000000-0000-4000-8000-000000000001`, `...0002`, ... */
13
+ export declare function fakeIds(prefix?: string): FakeIds