@starci/jest-preset 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +126 -20
- package/builders.cjs +15 -0
- package/builders.d.ts +2 -0
- package/cache.cjs +67 -0
- package/cache.d.ts +31 -0
- package/entity-manager.cjs +145 -0
- package/entity-manager.d.ts +103 -0
- package/ids.cjs +41 -0
- package/ids.d.ts +13 -0
- package/index.cjs +100 -43
- package/index.d.ts +14 -11
- package/lock.cjs +56 -0
- package/lock.d.ts +26 -0
- package/matchers.cjs +45 -0
- package/matchers.d.ts +17 -0
- package/mock.cjs +7 -7
- package/outbox.cjs +53 -0
- package/outbox.d.ts +32 -0
- package/package.json +39 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 2.0.0 - unreleased
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- Added: the three path aliases (`@features/*`, `@modules/*`, `@tests/*`) are part of the preset (`MODULE_NAME_MAPPER`), matching the aliases the managed `tsconfig.json` declares.
|
|
7
|
+
- Added: `coverageProvider: "v8"`, so branch coverage counts real source, not the helpers TypeScript emits.
|
|
8
|
+
- 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`.
|
|
9
|
+
- Unchanged: ts-jest with `diagnostics: false` (`isolatedModules` comes from `@starci/tsconfig`), `mock<T>()` and `FakeClock`.
|
|
10
|
+
- 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`.
|
|
11
|
+
- 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.
|
|
12
|
+
- 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`.
|
|
13
|
+
- 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,137 @@
|
|
|
1
1
|
# @starci/jest-preset
|
|
2
2
|
|
|
3
|
-
The jest config of a Nest repository
|
|
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
|
-
|
|
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
|
-
- **
|
|
20
|
-
`src/tests/e2e
|
|
21
|
-
-
|
|
22
|
-
|
|
23
|
-
`
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
+
|
|
119
|
+
## `builder`, `fakeIds` and the Outcome matchers
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { builder, fakeIds } from "@starci/jest-preset"
|
|
123
|
+
|
|
124
|
+
const options = builder<CommissionOptions>({ bps: 3000, enabled: true }) // a function returning the defaults with overrides applied
|
|
125
|
+
const disabled = options({ enabled: false })
|
|
126
|
+
const ids = fakeIds() // ids.next() === "00000000-0000-4000-8000-000000000001", then ...0002; ids.issued, ids.reset()
|
|
127
|
+
|
|
128
|
+
expect(outcome).toBeRefused("COMMISSION_SELF_REFERRAL") // { kind: "refused" } with this code
|
|
129
|
+
expect(outcome).toBeRefused({ code: "PLAN_LIMIT", params: { max: 3 } }) // and these params
|
|
130
|
+
expect(outcome).toSucceedWith(accrual) // { kind: "ok" } whose value equals `accrual`
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The matchers are installed in the unit project only (`setupFilesAfterEnv`); their types are declared globally by importing the
|
|
134
|
+
package.
|
|
29
135
|
|
|
30
136
|
## `mock<T>()`
|
|
31
137
|
|
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
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
|
package/index.cjs
CHANGED
|
@@ -1,29 +1,31 @@
|
|
|
1
1
|
"use strict"
|
|
2
2
|
|
|
3
3
|
const { mock, createMock } = require("./mock.cjs")
|
|
4
|
+
const { mockEntityManager, fakeTransaction } = require("./entity-manager.cjs")
|
|
5
|
+
const { FakeClock } = require("./clock.cjs")
|
|
6
|
+
const { fakeCache } = require("./cache.cjs")
|
|
7
|
+
const { fakeLock } = require("./lock.cjs")
|
|
8
|
+
const { recordingOutbox } = require("./outbox.cjs")
|
|
9
|
+
const { builder } = require("./builders.cjs")
|
|
10
|
+
const { fakeIds, FakeIds } = require("./ids.cjs")
|
|
4
11
|
|
|
5
12
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Sonar
|
|
10
|
-
* coverage.exclusions = src/tests/**, e2e specs, entrypoints and declaration files.
|
|
13
|
+
* Coverage is measured on services only: the one place business logic lives (owner-locked unit standard). Handlers,
|
|
14
|
+
* resolvers, controllers and consumers are thin, and helpers called by a service are covered through the service's own
|
|
15
|
+
* spec, so every `*.service.ts` under `src` is the whole denominator and every file in it must reach 100 on every metric.
|
|
16
|
+
* Sonar does not read coverage at all (the gate fails on imported issues only), so there is no second list to keep in step.
|
|
11
17
|
*/
|
|
12
|
-
const COVERAGE_SOURCES = ["src/**/*.ts"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const COVERAGE_ONLY_EXCLUSIONS = ["src/tests/**", "**/*.d.ts", "**/main.ts"]
|
|
17
|
-
const COVERAGE_EXCLUDES = [...SONAR_EXCLUSIONS, ...COVERAGE_ONLY_EXCLUSIONS]
|
|
18
|
+
const COVERAGE_SOURCES = ["src/**/*.service.ts"]
|
|
19
|
+
const COVERAGE_EXCLUDES = ["src/tests/**", "**/dist/**", "**/coverage/**"]
|
|
20
|
+
/** The four metrics, each held at 100 per file. */
|
|
21
|
+
const COVERAGE_THRESHOLD = Object.freeze({ lines: 100, branches: 100, functions: 100, statements: 100 })
|
|
18
22
|
|
|
19
23
|
function collectCoverageFrom() {
|
|
20
24
|
return [...COVERAGE_SOURCES, ...COVERAGE_EXCLUDES.map((glob) => `!${glob}`)]
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
return COVERAGE_ONLY_EXCLUSIONS.join(",")
|
|
26
|
-
}
|
|
27
|
+
/** Sonar `sonar.exclusions`: specs, e2e specs, dist and coverage output are not analysed at all. */
|
|
28
|
+
const SONAR_EXCLUSIONS = ["**/*.spec.ts", "**/*.e2e-spec.ts", "**/dist/**", "**/coverage/**"]
|
|
27
29
|
|
|
28
30
|
function sonarExclusions() {
|
|
29
31
|
return SONAR_EXCLUSIONS.join(",")
|
|
@@ -33,8 +35,8 @@ const TS_TRANSFORM = String.raw`^.+\.ts$`
|
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
37
|
* ts-jest without a type check (`diagnostics: false`; `isolatedModules: true` comes from @starci/tsconfig, which is where
|
|
36
|
-
* ts-jest 29.4 reads it — the ts-jest option of the same name is deprecated): types are checked once, by `typecheck` / `typecheck:
|
|
37
|
-
* `npm run test:e2e`
|
|
38
|
+
* ts-jest 29.4 reads it — the ts-jest option of the same name is deprecated): types are checked once, by `typecheck` / `typecheck:tests`, never per test file.
|
|
39
|
+
* `npm run test:e2e` (and `test:integration`, `test:contract`) runs `npm run typecheck:tests` first so nobody runs a world spec on code that
|
|
38
40
|
* does not type-check.
|
|
39
41
|
*/
|
|
40
42
|
function transform(tsconfig) {
|
|
@@ -42,59 +44,114 @@ function transform(tsconfig) {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
/**
|
|
45
|
-
* The
|
|
47
|
+
* The compiler options the UNIT project overlays on the repository tsconfig (ts-jest merges an inline `tsconfig` object over the
|
|
48
|
+
* `tsconfig.json` it finds under `rootDir`). They exist so that per-file 100 coverage is reachable for a decorated service:
|
|
49
|
+
* - `isolatedModules: false`: with it on, TypeScript cannot tell whether an imported name is a class, and emits a
|
|
50
|
+
* `typeof (_a = typeof Dep !== "undefined" && Dep) === "function" ? _a : Object` guard per class-typed constructor parameter for
|
|
51
|
+
* `design:paramtypes`; the `Object` arm can never run, so the branch stays uncovered. With the full language service the
|
|
52
|
+
* metadata is emitted as plain `Dep`.
|
|
53
|
+
* - `importHelpers: true`: the decorator helpers (`__decorate`, `__param`, `__metadata`) come from `tslib`, not inlined per file
|
|
54
|
+
* with branches of their own. The repository lists `tslib` in its devDependencies.
|
|
55
|
+
* Integration, e2e and contract keep the repository setting: they measure nothing.
|
|
56
|
+
*/
|
|
57
|
+
const UNIT_COMPILER_OPTIONS = Object.freeze({ isolatedModules: false, importHelpers: true })
|
|
58
|
+
|
|
59
|
+
function unitTransform() {
|
|
60
|
+
return transform({ ...UNIT_COMPILER_OPTIONS })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The path aliases of a StarCi back end: the same three `paths` the managed `tsconfig.json` declares, so jest resolves what
|
|
65
|
+
* `tsc` resolves. They are part of the preset, not an option: a repository has no other alias.
|
|
66
|
+
*/
|
|
67
|
+
const MODULE_NAME_MAPPER = Object.freeze({
|
|
68
|
+
"^@features/(.*)$": "<rootDir>/src/features/$1",
|
|
69
|
+
"^@modules/(.*)$": "<rootDir>/src/modules/$1",
|
|
70
|
+
"^@tests/(.*)$": "<rootDir>/src/tests/$1",
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
/** The test folders under `src/tests/` that a project other than `unit` owns (owner test layout 2026-09-30). */
|
|
74
|
+
const TEST_KIND_FOLDERS = Object.freeze(["world", "integration", "e2e", "contract"])
|
|
75
|
+
const underTests = (folder) => String.raw`[\\/]src[\\/]tests[\\/]` + folder + String.raw`[\\/]`
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The whole jest config of a Nest repository: projects `unit` (colocated `<name>.spec.ts`), `integration`
|
|
79
|
+
* (`src/tests/integration/<capability>/<name>.integration-spec.ts`), `e2e` (`src/tests/e2e/<area>/<name>.e2e-spec.ts`) and
|
|
80
|
+
* `contract` (`src/tests/contract/<provider>/<name>.contract-spec.ts`). It takes no options: the repository's `jest.config.js` is a managed file
|
|
81
|
+
* (R05), rendered by `hfs sync` as `module.exports = require("@starci/jest-preset").starciJestConfig()`.
|
|
46
82
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
83
|
+
* The managed scripts select one project each (`test` = unit, `test:integration`, `test:e2e`, `test:contract`); the
|
|
84
|
+
* contract project is never part of `test` or `test:e2e`, and a contract spec skips itself without sandbox config. The
|
|
85
|
+
* test tree compiles against `src/tests/tsconfig.json`, the nearest config of every file under `src/tests/`. The
|
|
86
|
+
* integration, e2e and contract projects share the world's `global-setup.ts`/`global-teardown.ts` (`src/tests/world/`) and
|
|
87
|
+
* run one worker; the unit project alone has a `setupFilesAfterEnv` (the Outcome matchers).
|
|
88
|
+
* Coverage is collected from every `*.service.ts` only, with a per-file threshold of 100 on lines, branches, functions and
|
|
89
|
+
* statements: the `test` script runs the unit project with `--coverage` and fails below it. It uses v8: istanbul instruments the helpers TypeScript emits (`__decorate`, `__param`, `__awaiter`, interop wrappers)
|
|
90
|
+
* as thousands of branches no spec can cover, while v8 measures the real source.
|
|
53
91
|
*/
|
|
54
|
-
function starciJestConfig(
|
|
55
|
-
const tsconfig = options.tsconfig ?? "tsconfig.json"
|
|
92
|
+
function starciJestConfig() {
|
|
56
93
|
const shared = {
|
|
57
94
|
preset: "ts-jest",
|
|
58
95
|
testEnvironment: "node",
|
|
59
|
-
rootDir:
|
|
60
|
-
roots:
|
|
61
|
-
moduleNameMapper:
|
|
62
|
-
transform: transform(tsconfig),
|
|
96
|
+
rootDir: ".",
|
|
97
|
+
roots: ["<rootDir>/src", "<rootDir>/apps"],
|
|
98
|
+
moduleNameMapper: { ...MODULE_NAME_MAPPER },
|
|
63
99
|
}
|
|
64
|
-
const
|
|
100
|
+
const suite = (displayName, suffix) => ({
|
|
101
|
+
...shared,
|
|
102
|
+
transform: transform("src/tests/tsconfig.json"),
|
|
103
|
+
displayName,
|
|
104
|
+
maxWorkers: 1,
|
|
105
|
+
testMatch: [`<rootDir>/src/tests/${displayName}/**/*.${suffix}.ts`],
|
|
106
|
+
// The one test world: started once per run of a project that has a test to run, torn down after it.
|
|
107
|
+
globalSetup: "<rootDir>/src/tests/world/global-setup.ts",
|
|
108
|
+
globalTeardown: "<rootDir>/src/tests/world/global-teardown.ts",
|
|
109
|
+
})
|
|
65
110
|
return {
|
|
66
111
|
testTimeout: 120_000,
|
|
112
|
+
coverageProvider: "v8",
|
|
67
113
|
collectCoverageFrom: collectCoverageFrom(),
|
|
114
|
+
// A glob key is applied to every matching file on its own: each service file must reach 100, not the average.
|
|
115
|
+
coverageThreshold: { "./src/**/*.service.ts": { ...COVERAGE_THRESHOLD } },
|
|
68
116
|
coverageDirectory: "coverage",
|
|
69
|
-
coverageReporters: ["
|
|
117
|
+
coverageReporters: ["text-summary", "text"],
|
|
70
118
|
projects: [
|
|
71
119
|
{
|
|
72
120
|
...shared,
|
|
121
|
+
transform: unitTransform(),
|
|
73
122
|
displayName: "unit",
|
|
74
123
|
clearMocks: true,
|
|
124
|
+
// The Outcome matchers (`toBeRefused`, `toSucceedWith`) exist in the unit project only.
|
|
125
|
+
setupFilesAfterEnv: [require.resolve("./matchers.cjs")],
|
|
75
126
|
testMatch: ["**/*.spec.ts"],
|
|
76
|
-
testPathIgnorePatterns: ["/node_modules/",
|
|
77
|
-
...options.unit,
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
...shared,
|
|
81
|
-
displayName: "e2e",
|
|
82
|
-
maxWorkers: 1,
|
|
83
|
-
testMatch: ["<rootDir>/src/tests/e2e/**/*.e2e-spec.ts"],
|
|
84
|
-
testPathIgnorePatterns: live ? ["/node_modules/"] : ["/node_modules/", "[\\/]src[\\/]tests[\\/]e2e[\\/]live[\\/]"],
|
|
85
|
-
...options.e2e,
|
|
127
|
+
testPathIgnorePatterns: ["/node_modules/", ...TEST_KIND_FOLDERS.map(underTests)],
|
|
86
128
|
},
|
|
129
|
+
suite("integration", "integration-spec"),
|
|
130
|
+
suite("e2e", "e2e-spec"),
|
|
131
|
+
suite("contract", "contract-spec"),
|
|
87
132
|
],
|
|
88
133
|
}
|
|
89
134
|
}
|
|
90
135
|
|
|
91
136
|
module.exports = {
|
|
92
137
|
starciJestConfig,
|
|
138
|
+
TEST_KIND_FOLDERS,
|
|
139
|
+
MODULE_NAME_MAPPER,
|
|
93
140
|
mock,
|
|
94
141
|
createMock,
|
|
142
|
+
mockEntityManager,
|
|
143
|
+
fakeTransaction,
|
|
144
|
+
FakeClock,
|
|
145
|
+
fakeCache,
|
|
146
|
+
fakeLock,
|
|
147
|
+
recordingOutbox,
|
|
148
|
+
builder,
|
|
149
|
+
fakeIds,
|
|
150
|
+
FakeIds,
|
|
95
151
|
collectCoverageFrom,
|
|
96
|
-
sonarCoverageExclusions,
|
|
97
152
|
sonarExclusions,
|
|
98
153
|
COVERAGE_SOURCES,
|
|
99
154
|
COVERAGE_EXCLUDES,
|
|
155
|
+
COVERAGE_THRESHOLD,
|
|
156
|
+
UNIT_COMPILER_OPTIONS,
|
|
100
157
|
}
|
package/index.d.ts
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import type { Config } from "jest"
|
|
2
2
|
|
|
3
3
|
export { mock, createMock, MockOf } from "./mock"
|
|
4
|
+
export { mockEntityManager, fakeTransaction, MockEntityManager, FakeTransaction, EntityClass } from "./entity-manager"
|
|
5
|
+
export { fakeCache, FakeCache } from "./cache"
|
|
6
|
+
export { fakeLock, FakeLock } from "./lock"
|
|
7
|
+
export { recordingOutbox, RecordingOutbox } from "./outbox"
|
|
8
|
+
export { builder } from "./builders"
|
|
9
|
+
export { FakeClock } from "./clock"
|
|
10
|
+
export { fakeIds, FakeIds } from "./ids"
|
|
11
|
+
export { RefusalReason } from "./matchers"
|
|
4
12
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
moduleNameMapper?: Record<string, string | string[]>
|
|
9
|
-
roots?: string[]
|
|
10
|
-
unit?: Record<string, unknown>
|
|
11
|
-
e2e?: Record<string, unknown>
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export declare function starciJestConfig(options?: StarciJestOptions): Config
|
|
13
|
+
/** The whole jest config: projects `unit`, `integration`, `e2e` and `contract`. It takes no options; the repository config is a managed file. */
|
|
14
|
+
export declare function starciJestConfig(): Config
|
|
15
|
+
export declare const MODULE_NAME_MAPPER: Readonly<Record<string, string>>
|
|
15
16
|
export declare function collectCoverageFrom(): string[]
|
|
16
|
-
export declare function sonarCoverageExclusions(): string
|
|
17
17
|
export declare function sonarExclusions(): string
|
|
18
18
|
export declare const COVERAGE_SOURCES: string[]
|
|
19
19
|
export declare const COVERAGE_EXCLUDES: string[]
|
|
20
|
+
/** The compiler options the unit project overlays on the repository tsconfig so per-file 100 coverage of a decorated service is reachable. */
|
|
21
|
+
export declare const UNIT_COMPILER_OPTIONS: Readonly<{ isolatedModules: false; importHelpers: true }>
|
|
22
|
+
export declare const COVERAGE_THRESHOLD: Readonly<{ lines: 100; branches: 100; functions: 100; statements: 100 }>
|
package/lock.cjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `fakeLock(clock)` -- a behavioural in-memory double of a lease (cross-replica lock with fencing).
|
|
5
|
+
*
|
|
6
|
+
* `acquire({ name, holder, ttlMs, at? })` grants `{ name, holder, fence }` when nobody holds the name (or the previous
|
|
7
|
+
* ttl ran out on the clock) and answers `null` on contention; the fence grows with every grant of one name.
|
|
8
|
+
* `release({ grant })` lets go only when the grant is still current. A holder acquiring its own live lease renews it.
|
|
9
|
+
* Inspection: `isHeld(name)`, `holderOf(name)`, `fenceOf(name)`. Expiry is driven by the `FakeClock`; a request may carry
|
|
10
|
+
* its own `at`, which wins.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** @param {{ now(): Date }} [clock] */
|
|
14
|
+
function fakeLock(clock) {
|
|
15
|
+
const state = new Map()
|
|
16
|
+
const fences = new Map()
|
|
17
|
+
const nowOf = (params) => {
|
|
18
|
+
const at = params.at ?? clock?.now()
|
|
19
|
+
if (at === undefined) throw new Error("fakeLock: pass the FakeClock, fakeLock(clock), or an `at` on each acquire")
|
|
20
|
+
return at.getTime()
|
|
21
|
+
}
|
|
22
|
+
const liveHold = (name) => {
|
|
23
|
+
const hold = state.get(name)
|
|
24
|
+
if (hold === undefined) return undefined
|
|
25
|
+
if (clock !== undefined && clock.now().getTime() >= hold.expiresAt) {
|
|
26
|
+
state.delete(name)
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
return hold
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
async acquire(params) {
|
|
34
|
+
const at = nowOf(params)
|
|
35
|
+
const held = state.get(params.name)
|
|
36
|
+
const alive = held !== undefined && at < held.expiresAt
|
|
37
|
+
if (alive && held.holder !== params.holder) return null
|
|
38
|
+
const fence = alive ? held.fence : (fences.get(params.name) ?? 0) + 1
|
|
39
|
+
fences.set(params.name, fence)
|
|
40
|
+
state.set(params.name, { holder: params.holder, fence, expiresAt: at + params.ttlMs })
|
|
41
|
+
return { name: params.name, holder: params.holder, fence }
|
|
42
|
+
},
|
|
43
|
+
async release({ grant }) {
|
|
44
|
+
const held = state.get(grant.name)
|
|
45
|
+
if (held !== undefined && held.fence === grant.fence && held.holder === grant.holder) state.delete(grant.name)
|
|
46
|
+
},
|
|
47
|
+
/** Whether a live lease exists for `name`. */
|
|
48
|
+
isHeld: (name) => liveHold(name) !== undefined,
|
|
49
|
+
/** The holder of the live lease, or null. */
|
|
50
|
+
holderOf: (name) => liveHold(name)?.holder ?? null,
|
|
51
|
+
/** The fence of the latest grant of `name`, or null when it was never granted. */
|
|
52
|
+
fenceOf: (name) => fences.get(name) ?? null,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { fakeLock }
|
package/lock.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** What a caller asks for when it wants a named lease. */
|
|
2
|
+
export interface FakeLockAcquire {
|
|
3
|
+
readonly name: string
|
|
4
|
+
readonly holder: string
|
|
5
|
+
readonly ttlMs: number
|
|
6
|
+
/** The instant of the request; defaults to the clock of the double. */
|
|
7
|
+
readonly at?: Date
|
|
8
|
+
}
|
|
9
|
+
/** A granted lease. */
|
|
10
|
+
export interface FakeLockGrant {
|
|
11
|
+
readonly name: string
|
|
12
|
+
readonly holder: string
|
|
13
|
+
readonly fence: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A behavioural in-memory lease: acquire, contention, release, ttl expiry on the clock, growing fence. */
|
|
17
|
+
export interface FakeLock {
|
|
18
|
+
acquire(params: FakeLockAcquire): Promise<FakeLockGrant | null>
|
|
19
|
+
release(params: { readonly grant: FakeLockGrant }): Promise<void>
|
|
20
|
+
isHeld(name: string): boolean
|
|
21
|
+
holderOf(name: string): string | null
|
|
22
|
+
fenceOf(name: string): number | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The lock double; expiry follows `clock`, so `clock.advance(ms)` frees a lease whose ttl ran out. */
|
|
26
|
+
export declare function fakeLock(clock?: { now(): Date }): FakeLock
|
package/matchers.cjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Outcome matchers, registered by the `unit` project's `setupFilesAfterEnv` (nowhere else).
|
|
5
|
+
*
|
|
6
|
+
* They match the platform `Outcome<V, C>` structurally: `{ kind: "ok", value }` or `{ kind: "refused", code, params? }`.
|
|
7
|
+
* `expect(outcome).toBeRefused("STOCK_EXHAUSTED")` (or `{ code, params }`) and `expect(outcome).toSucceedWith(value)`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const isOutcome = (received) => typeof received === "object" && received !== null && (received.kind === "ok" || received.kind === "refused")
|
|
11
|
+
|
|
12
|
+
const notOutcome = (utils, received) => `expected an Outcome ({ kind: "ok" | "refused" }), received ${utils.printReceived(received)}`
|
|
13
|
+
|
|
14
|
+
const matchers = {
|
|
15
|
+
toBeRefused(received, reason) {
|
|
16
|
+
if (!isOutcome(received)) return { pass: false, message: () => notOutcome(this.utils, received) }
|
|
17
|
+
const expected = typeof reason === "string" ? { code: reason } : reason
|
|
18
|
+
const pass =
|
|
19
|
+
received.kind === "refused" &&
|
|
20
|
+
received.code === expected.code &&
|
|
21
|
+
(!("params" in expected) || this.equals(received.params, expected.params))
|
|
22
|
+
return {
|
|
23
|
+
pass,
|
|
24
|
+
message: () =>
|
|
25
|
+
pass
|
|
26
|
+
? `expected the outcome not to be refused with ${this.utils.printExpected(expected)}`
|
|
27
|
+
: `expected the outcome to be refused with ${this.utils.printExpected(expected)}, received ${this.utils.printReceived(received)}`,
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
toSucceedWith(received, value) {
|
|
31
|
+
if (!isOutcome(received)) return { pass: false, message: () => notOutcome(this.utils, received) }
|
|
32
|
+
const pass = received.kind === "ok" && this.equals(received.value, value)
|
|
33
|
+
return {
|
|
34
|
+
pass,
|
|
35
|
+
message: () =>
|
|
36
|
+
pass
|
|
37
|
+
? `expected the outcome not to succeed with ${this.utils.printExpected(value)}`
|
|
38
|
+
: `expected the outcome to succeed with ${this.utils.printExpected(value)}, received ${this.utils.printReceived(received)}`,
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (typeof expect !== "undefined" && typeof expect.extend === "function") expect.extend(matchers)
|
|
44
|
+
|
|
45
|
+
module.exports = { matchers }
|
package/matchers.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/// <reference types="jest" />
|
|
2
|
+
|
|
3
|
+
/** The refusal a matcher expects: a capability code, or a code with the parameters of its display text. */
|
|
4
|
+
export type RefusalReason = string | { readonly code: string; readonly params?: Record<string, unknown> }
|
|
5
|
+
|
|
6
|
+
declare global {
|
|
7
|
+
namespace jest {
|
|
8
|
+
interface Matchers<R> {
|
|
9
|
+
/** The received `Outcome` is `{ kind: "refused" }` with this code (and these params, when given). */
|
|
10
|
+
toBeRefused(reason: RefusalReason): R
|
|
11
|
+
/** The received `Outcome` is `{ kind: "ok" }` whose value equals `value` (recursive equality). */
|
|
12
|
+
toSucceedWith(value: unknown): R
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export declare const matchers: Record<"toBeRefused" | "toSucceedWith", (this: jest.MatcherContext, received: unknown, expected: unknown) => jest.CustomMatcherResult>
|
package/mock.cjs
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* type-checks against `Repo["find"]`. Values passed in `overrides` win over the generated functions and may be
|
|
9
9
|
* partial: the return type is still `T`.
|
|
10
10
|
*
|
|
11
|
-
* `createMock` takes the function factory as an argument so the helper has no hard
|
|
12
|
-
* `jest`; `mock` binds it to `jest.fn`.
|
|
11
|
+
* `createMock` takes the function factory as an argument (called with the property name) so the helper has no hard
|
|
12
|
+
* dependency on a global `jest`; `mock` binds it to `jest.fn`.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
// Probed by promise resolution, jest matchers and serializers: they must read as absent, not as a mock function.
|
|
@@ -24,7 +24,7 @@ function createMock(makeFn) {
|
|
|
24
24
|
if (Object.hasOwn(target, property)) return Reflect.get(target, property, receiver)
|
|
25
25
|
if (typeof property === "symbol" || property in Object.prototype) return Reflect.get(target, property, receiver)
|
|
26
26
|
if (ABSENT.has(property)) return undefined
|
|
27
|
-
if (!own.has(property)) own.set(property, makeFn())
|
|
27
|
+
if (!own.has(property)) own.set(property, makeFn(property))
|
|
28
28
|
return own.get(property)
|
|
29
29
|
},
|
|
30
30
|
set(target, property, value) {
|
|
@@ -41,13 +41,13 @@ function createMock(makeFn) {
|
|
|
41
41
|
|
|
42
42
|
// jest-runtime hands every module it loads, this one included, a module-scoped `jest` object; it is not a property
|
|
43
43
|
// of globalThis, so it is read as a free identifier. @jest/globals is the fallback for a runner that does not.
|
|
44
|
-
function jestFn() {
|
|
45
|
-
if (typeof jest !== "undefined" && typeof jest.fn === "function") return jest.fn()
|
|
44
|
+
function jestFn(implementation) {
|
|
45
|
+
if (typeof jest !== "undefined" && typeof jest.fn === "function") return jest.fn(implementation)
|
|
46
46
|
try {
|
|
47
|
-
return require("@jest/globals").jest.fn()
|
|
47
|
+
return require("@jest/globals").jest.fn(implementation)
|
|
48
48
|
} catch {
|
|
49
49
|
throw new Error("@starci/jest-preset mock<T>() needs the jest runtime; call it from a jest test")
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
module.exports = { mock: createMock(jestFn), createMock }
|
|
53
|
+
module.exports = { mock: createMock(() => jestFn()), createMock, jestFn }
|
package/outbox.cjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `recordingOutbox()` -- an outbox double that records what a service writes, so a spec asserts on `outbox.messages`.
|
|
5
|
+
*
|
|
6
|
+
* `enqueue(manager, message)` records the message with the manager it was written through. `messages` is what the store
|
|
7
|
+
* would hold: one message per (queue, eventId), like the real unique key, so a duplicate write keeps one. `writes` is every
|
|
8
|
+
* call, duplicates included, and `entries` adds `inTransaction`: whether the manager was the view a `fakeTransaction` body
|
|
9
|
+
* received, so a spec proves a message is written in the transaction of the change that caused it.
|
|
10
|
+
*/
|
|
11
|
+
const { isTransactionManager } = require("./entity-manager.cjs")
|
|
12
|
+
|
|
13
|
+
function recordingOutbox() {
|
|
14
|
+
const written = []
|
|
15
|
+
return {
|
|
16
|
+
async enqueue(manager, message) {
|
|
17
|
+
written.push({ message, manager, inTransaction: isTransactionManager(manager) })
|
|
18
|
+
},
|
|
19
|
+
/** The messages the store would hold: the first write of each (queue, eventId). */
|
|
20
|
+
get messages() {
|
|
21
|
+
const seen = new Set()
|
|
22
|
+
return written
|
|
23
|
+
.filter(({ message }) => {
|
|
24
|
+
const key = `${message.queue}\u0000${message.eventId}`
|
|
25
|
+
if (seen.has(key)) return false
|
|
26
|
+
seen.add(key)
|
|
27
|
+
return true
|
|
28
|
+
})
|
|
29
|
+
.map(({ message }) => message)
|
|
30
|
+
},
|
|
31
|
+
/** Every write, in order: `{ message, manager, inTransaction }`. */
|
|
32
|
+
get entries() {
|
|
33
|
+
return [...written]
|
|
34
|
+
},
|
|
35
|
+
/** Every message written, duplicates included. */
|
|
36
|
+
get writes() {
|
|
37
|
+
return written.map(({ message }) => message)
|
|
38
|
+
},
|
|
39
|
+
/** True when something was written and every write went through a transaction manager. */
|
|
40
|
+
get allInTransaction() {
|
|
41
|
+
return written.length > 0 && written.every((entry) => entry.inTransaction)
|
|
42
|
+
},
|
|
43
|
+
/** The stored messages of one queue. */
|
|
44
|
+
messagesOf(queue) {
|
|
45
|
+
return this.messages.filter((message) => message.queue === queue)
|
|
46
|
+
},
|
|
47
|
+
clear() {
|
|
48
|
+
written.length = 0
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { recordingOutbox }
|
package/outbox.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** A message as the outbox port takes it. */
|
|
2
|
+
export interface RecordedMessage {
|
|
3
|
+
readonly queue: string
|
|
4
|
+
readonly eventId: string
|
|
5
|
+
readonly payload: object
|
|
6
|
+
readonly availableAt?: Date
|
|
7
|
+
}
|
|
8
|
+
/** One recorded write. */
|
|
9
|
+
export interface OutboxWrite<M extends RecordedMessage = RecordedMessage> {
|
|
10
|
+
readonly message: M
|
|
11
|
+
readonly manager: unknown
|
|
12
|
+
/** Whether the manager was the view a `fakeTransaction` body received. */
|
|
13
|
+
readonly inTransaction: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** An outbox double that records writes. */
|
|
17
|
+
export interface RecordingOutbox<M extends RecordedMessage = RecordedMessage> {
|
|
18
|
+
enqueue(manager: unknown, message: M): Promise<void>
|
|
19
|
+
/** What the store would hold: the first write of each (queue, eventId). */
|
|
20
|
+
readonly messages: ReadonlyArray<M>
|
|
21
|
+
/** Every write with its manager, duplicates included. */
|
|
22
|
+
readonly entries: ReadonlyArray<OutboxWrite<M>>
|
|
23
|
+
/** Every message written, duplicates included. */
|
|
24
|
+
readonly writes: ReadonlyArray<M>
|
|
25
|
+
/** True when something was written and every write went through a `fakeTransaction` manager. */
|
|
26
|
+
readonly allInTransaction: boolean
|
|
27
|
+
messagesOf(queue: string): ReadonlyArray<M>
|
|
28
|
+
clear(): void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The outbox double. */
|
|
32
|
+
export declare function recordingOutbox<M extends RecordedMessage = RecordedMessage>(): RecordingOutbox<M>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/jest-preset",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "The StarCi jest config for Nest repositories (unit
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "The StarCi jest config for Nest repositories (unit, integration, e2e and contract projects, ts-jest with diagnostics off, per-file 100 coverage of *.service.ts only) and the one shared unit-test double kit: typed mock<T>(), mockEntityManager(), fakeTransaction(), fakeCache(), fakeLock(), recordingOutbox(), FakeClock, fakeIds(), builder() and the Outcome matchers toBeRefused/toSucceedWith.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
7
7
|
"main": "index.cjs",
|
|
@@ -18,13 +18,42 @@
|
|
|
18
18
|
"./clock": {
|
|
19
19
|
"types": "./clock.d.ts",
|
|
20
20
|
"default": "./clock.cjs"
|
|
21
|
+
},
|
|
22
|
+
"./entity-manager": {
|
|
23
|
+
"types": "./entity-manager.d.ts",
|
|
24
|
+
"default": "./entity-manager.cjs"
|
|
25
|
+
},
|
|
26
|
+
"./ids": {
|
|
27
|
+
"types": "./ids.d.ts",
|
|
28
|
+
"default": "./ids.cjs"
|
|
29
|
+
},
|
|
30
|
+
"./matchers": {
|
|
31
|
+
"types": "./matchers.d.ts",
|
|
32
|
+
"default": "./matchers.cjs"
|
|
33
|
+
},
|
|
34
|
+
"./cache": {
|
|
35
|
+
"types": "./cache.d.ts",
|
|
36
|
+
"default": "./cache.cjs"
|
|
37
|
+
},
|
|
38
|
+
"./lock": {
|
|
39
|
+
"types": "./lock.d.ts",
|
|
40
|
+
"default": "./lock.cjs"
|
|
41
|
+
},
|
|
42
|
+
"./outbox": {
|
|
43
|
+
"types": "./outbox.d.ts",
|
|
44
|
+
"default": "./outbox.cjs"
|
|
45
|
+
},
|
|
46
|
+
"./builders": {
|
|
47
|
+
"types": "./builders.d.ts",
|
|
48
|
+
"default": "./builders.cjs"
|
|
21
49
|
}
|
|
22
50
|
},
|
|
23
51
|
"files": [
|
|
24
52
|
"*.cjs",
|
|
25
53
|
"*.d.ts",
|
|
26
54
|
"!*.test.*",
|
|
27
|
-
"README.md"
|
|
55
|
+
"README.md",
|
|
56
|
+
"CHANGELOG.md"
|
|
28
57
|
],
|
|
29
58
|
"publishConfig": {
|
|
30
59
|
"access": "public"
|
|
@@ -32,9 +61,15 @@
|
|
|
32
61
|
"peerDependencies": {
|
|
33
62
|
"@types/jest": ">=29",
|
|
34
63
|
"jest": ">=29",
|
|
35
|
-
"ts-jest": ">=29"
|
|
64
|
+
"ts-jest": ">=29",
|
|
65
|
+
"typeorm": ">=0.3"
|
|
36
66
|
},
|
|
37
67
|
"scripts": {
|
|
38
68
|
"test": "node --test \"*.test.mjs\""
|
|
69
|
+
},
|
|
70
|
+
"peerDependenciesMeta": {
|
|
71
|
+
"typeorm": {
|
|
72
|
+
"optional": true
|
|
73
|
+
}
|
|
39
74
|
}
|
|
40
75
|
}
|