@starci/jest-preset 2.5.0 → 2.5.1

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 CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.5.1 - 2026-10-29
4
+
5
+ - Fixed: `fakeTransaction` records a `query` that calls a migration's database function the way `callDbFunction` writes it (`SELECT * FROM "<schema>"."<fn>"(...)`, any schema but `pg_catalog`) as a write, so a rolled-back inbox claim or event send appears in `rolledBackWrites`; any other `SELECT` stays a read.
6
+ - Added: `fakeInbox().claimRow(source, eventId, tx, { entity, row })`, the double of the lite `EntityInbox` claim: it refuses a missing or blank id (`DATABASE_WEBHOOK_DELIVERY_ID_REQUIRED`), claims the trimmed id and, for the winning claim only, records the row `row(id)` builds in `receipts` (with the manager and `inTransaction`) and answers `receipt-<n>`; a repeat, or a pair marked by `receiptExists(source, eventId)`, answers undefined. `failNext("claimRow", error)` scripts a failure.
7
+
3
8
  ## 2.5.0 - 2026-10-29
4
9
 
5
10
  - Added: `fakeQueryBuilder(answers)` and `queueQueryBuilders(em, answers)` (also at `@starci/jest-preset/query-builder`), the double of `createQueryBuilder(Entity, "alias")` beside `mockEntityManager`: every chained call is recorded and returned to the chain, the terminals resolve the spec's answers. Lite persistence reads through entities and that builder (ruling `lite-persistence-typeorm-entities`), so the double moves here from the apps.
package/README.md CHANGED
@@ -103,7 +103,9 @@ expect(tx.committedWrites).toHaveLength(1) // after a throwing body: tx.r
103
103
  The stubbable methods are `findOne`, `findOneBy`, `findOneOrFail`, `findOneByOrFail`, `find`, `findBy`, `findAndCount`,
104
104
  `findAndCountBy`, `count`, `countBy`, `exists`, `existsBy`, `save`, `remove`, `softRemove`, `recover`, `insert`, `update`, `upsert`,
105
105
  `delete`, `softDelete`, `restore`, `increment`, `decrement` and `query`. `fakeTransaction(em)` returns `{ em, outcomes, commits,
106
- rollbacks, committedWrites, rolledBackWrites }`.
106
+ rollbacks, committedWrites, rolledBackWrites }`. A write is a write method, a `query` of a DML statement, or a `query` that calls a
107
+ migration's database function the way `callDbFunction` does (`SELECT * FROM "public"."inbox_claim_event"($1, $2)`, any schema but
108
+ `pg_catalog`); any other `SELECT` is a read.
107
109
 
108
110
  ## `fakeQueryBuilder` and `queueQueryBuilders`
109
111
 
@@ -156,6 +158,10 @@ await inbox.claim("sepay", "e-1") // true the first time, false for the s
156
158
  await inbox.release("sepay", "e-1") // gives the claim back: the next claim answers true
157
159
  inbox.seen("sepay", "e-2") // a redelivery: the next claim of the pair answers false
158
160
  inbox.claims; inbox.claimed; inbox.released; inbox.failNext("claim", new Error("db down")); inbox.clear()
161
+ // claimRow: the EntityInbox claim that inserts the receipt row only for the winning claim and answers its id
162
+ await inbox.claimRow("webhook.sepay", "e-3", tx, { entity: SepayInboxEntity, row: (deliveryId) => ({ deliveryId }) }) // "receipt-1"
163
+ inbox.receipts // every inserted receipt: id, source, eventId, entity, row, tx, inTransaction
164
+ inbox.receiptExists("webhook.sepay", "e-4") // the receipt row is already there: the winning claim inserts nothing, answers undefined
159
165
 
160
166
  const clientInbox = fakeInbox(clock) // the driven clock stamps client claims for retention tests
161
167
  await clientInbox.runClient({ principalId: "p-1", operation: "checkout.place", idempotencyKey: "key-1", request: { quantity: 2 } }, tx, async () => ({ kind: "ok", value: { orderId: "o-1" } }))
@@ -14,7 +14,9 @@
14
14
  *
15
15
  * `fakeTransaction(em)` makes `em.transaction(work)` (and `em.transaction(isolation, work)`) run `work` with a scoped view
16
16
  * of the same double and record how it ended (`commit` when `work` resolves, `rollback` when it throws and the error is
17
- * rethrown) and which writes it made, so a spec can assert that a rolled-back transaction committed nothing.
17
+ * rethrown) and which writes it made, so a spec can assert that a rolled-back transaction committed nothing. A write is a
18
+ * write method (`save`, `insert`, ...), a `query` of a DML statement, or a `query` calling a migration's database function
19
+ * the way `callDbFunction` does (`SELECT * FROM "public"."inbox_claim_event"($1, $2)`); any other `SELECT` is a read.
18
20
  */
19
21
  import type { DeleteResult, EntityManager, InsertResult, UpdateResult } from "typeorm";
20
22
  import type { MockOf } from "./mock.js";
@@ -12,6 +12,14 @@ const WRITE_METHODS = new Set([
12
12
  "increment", "decrement", "clear",
13
13
  ]);
14
14
  const WRITE_SQL = /^\s*(insert|update|delete|upsert|merge|truncate)\b/i;
15
+ /**
16
+ * A call of a migration's database function in the shape `callDbFunction` writes (`SELECT * FROM "<schema>"."<fn>"(...)`), in
17
+ * any schema but the pg_catalog built-ins: such a function holds a write TypeORM cannot express (an inbox claim, an event send),
18
+ * so the transaction that makes the call records it as a write.
19
+ */
20
+ const DB_FUNCTION_SQL = /^\s*select\s+\*\s+from\s+"(?!pg_catalog")[^"]+"\."[^"]+"\s*\(/i;
21
+ /** Whether a `query` writes: a DML statement, or a call of a migration's database function. */
22
+ const isWriteSql = (sql) => WRITE_SQL.test(String(sql)) || DB_FUNCTION_SQL.test(String(sql));
15
23
  const describeTarget = (target) => typeof target === "function" ? target.name || "<anonymous class>" : JSON.stringify(String(target)).slice(0, 80);
16
24
  const isPair = (value) => Array.isArray(value) && value.length === 2 && (typeof value[0] === "function" || typeof value[0] === "string");
17
25
  /** `{ method: [target, result] | [[target, result], ...] }` as `Map<method, Array<{ target, result, used }>>`. */
@@ -74,7 +82,7 @@ function createEntityManagerKit(makeFn) {
74
82
  if (!WRITE_METHODS.has(property) && property !== "query")
75
83
  return value;
76
84
  return (...args) => {
77
- if (property !== "query" || WRITE_SQL.test(String(args[0])))
85
+ if (property !== "query" || isWriteSql(args[0]))
78
86
  run.writes.push({ method: property, args });
79
87
  return value(...args);
80
88
  };
package/dist/inbox.d.ts CHANGED
@@ -4,7 +4,20 @@ export interface InboxEntry {
4
4
  readonly eventId: string;
5
5
  }
6
6
  /** The inbox operations `failNext` can make reject. */
7
- export type InboxOperation = "claim" | "release" | "runClient" | "sweepClients";
7
+ export type InboxOperation = "claim" | "claimRow" | "release" | "runClient" | "sweepClients";
8
+ /** The receipt `claimRow` inserts for a winning claim: its entity, and its row built from the normalized delivery id. */
9
+ export interface InboxReceiptRequest<Row> {
10
+ readonly entity: unknown;
11
+ readonly row: (eventId: string) => Row;
12
+ }
13
+ /** One receipt row `claimRow` inserted, with the id it answered and the manager passed by its caller. */
14
+ export interface InboxReceipt extends InboxEntry {
15
+ readonly id: string;
16
+ readonly entity: unknown;
17
+ readonly row: unknown;
18
+ readonly tx: unknown;
19
+ readonly inTransaction: boolean;
20
+ }
8
21
  /** The client operation namespace, exact key and JSON request whose outcome the inbox stores. */
9
22
  export interface InboxClientOperation {
10
23
  readonly principalId: string;
@@ -27,6 +40,13 @@ export interface InboxSweep {
27
40
  export interface FakeInbox {
28
41
  /** True for the first claim of a (source, eventId) pair, false for every later one until it is released. */
29
42
  claim(source: string, eventId: string): Promise<boolean>;
43
+ /**
44
+ * The `EntityInbox` claim: refuses a missing or blank delivery id, claims the trimmed id, and only for the winning claim
45
+ * inserts the receipt row and answers its id (`receipt-<n>`); a repeat, or a receipt that already exists, answers undefined.
46
+ */
47
+ claimRow<Row>(source: string, eventId: string | undefined, tx: unknown, receipt: InboxReceiptRequest<Row>): Promise<string | undefined>;
48
+ /** Marks the receipt row of a pair as already inserted: its next winning `claimRow` inserts nothing and answers undefined. */
49
+ receiptExists(source: string, eventId: string): void;
30
50
  /** Gives an ordinary delivery claim back while preserving client receipts and their outcomes. */
31
51
  release(source: string, eventId: string): Promise<void>;
32
52
  /** Runs once for the client namespace and key, replays the JSON outcome and refuses a different body. */
@@ -47,6 +67,8 @@ export interface FakeInbox {
47
67
  readonly clientEntries: ReadonlyArray<InboxClientEntry>;
48
68
  /** Every sweep attempt with its cutoff and transaction, including a scripted failure. */
49
69
  readonly sweeps: ReadonlyArray<InboxSweep>;
70
+ /** Every receipt row `claimRow` inserted, in order, with its id, row and manager. */
71
+ readonly receipts: ReadonlyArray<InboxReceipt>;
50
72
  clear(): void;
51
73
  }
52
74
  /**
@@ -57,7 +79,9 @@ export interface FakeInbox {
57
79
  * real unique key; `release(source, eventId)` gives an ordinary delivery claim back while preserving client outcomes.
58
80
  * `claims` is every claim in order, `claimed` the pairs that are held now, `released` every release, `seen(source, eventId)` marks a
59
81
  * pair as already delivered (a redelivery), and `failNext(operation, error)` makes the next call of that operation reject once, so
60
- * a spec drives the failure path.
82
+ * a spec drives the failure path. `claimRow(source, eventId, tx, { entity, row })` is the `EntityInbox` claim: it refuses a
83
+ * missing or blank id with `DATABASE_WEBHOOK_DELIVERY_ID_REQUIRED`, claims the trimmed id and, for the winning claim only, records
84
+ * the receipt row `row(id)` builds in `receipts` and answers its id; `receiptExists(source, eventId)` makes that insert a no-op.
61
85
  */
62
86
  /** The inbox double. Pass a driven clock to test retention; omitted clocks stamp client claims at the Unix epoch. */
63
87
  export declare function fakeInbox(clock?: {
package/dist/inbox.js CHANGED
@@ -35,7 +35,9 @@ const snapshot = (value, ancestors = new Set()) => {
35
35
  * real unique key; `release(source, eventId)` gives an ordinary delivery claim back while preserving client outcomes.
36
36
  * `claims` is every claim in order, `claimed` the pairs that are held now, `released` every release, `seen(source, eventId)` marks a
37
37
  * pair as already delivered (a redelivery), and `failNext(operation, error)` makes the next call of that operation reject once, so
38
- * a spec drives the failure path.
38
+ * a spec drives the failure path. `claimRow(source, eventId, tx, { entity, row })` is the `EntityInbox` claim: it refuses a
39
+ * missing or blank id with `DATABASE_WEBHOOK_DELIVERY_ID_REQUIRED`, claims the trimmed id and, for the winning claim only, records
40
+ * the receipt row `row(id)` builds in `receipts` and answers its id; `receiptExists(source, eventId)` makes that insert a no-op.
39
41
  */
40
42
  /** The inbox double. Pass a driven clock to test retention; omitted clocks stamp client claims at the Unix epoch. */
41
43
  function fakeInbox(clock) {
@@ -45,6 +47,8 @@ function fakeInbox(clock) {
45
47
  const clients = new Map();
46
48
  const clientEntries = [];
47
49
  const sweeps = [];
50
+ const receipts = [];
51
+ const existingReceipts = new Set();
48
52
  const failures = new Map();
49
53
  const keyOf = (source, eventId) => `${source}\u0000${eventId}`;
50
54
  const failIfScripted = (operation) => {
@@ -106,6 +110,27 @@ function fakeInbox(clock) {
106
110
  held.add(key);
107
111
  return true;
108
112
  },
113
+ async claimRow(source, eventId, tx, receipt) {
114
+ failIfScripted("claimRow");
115
+ const normalized = eventId?.trim();
116
+ if (!normalized)
117
+ throw inboxError("DATABASE_WEBHOOK_DELIVERY_ID_REQUIRED");
118
+ claims.push({ source, eventId: normalized });
119
+ const key = keyOf(source, normalized);
120
+ if (held.has(key))
121
+ return undefined;
122
+ held.add(key);
123
+ if (existingReceipts.has(key))
124
+ return undefined;
125
+ existingReceipts.add(key);
126
+ const id = `receipt-${String(receipts.length + 1)}`;
127
+ receipts.push({ id, source, eventId: normalized, entity: receipt.entity, row: receipt.row(normalized), tx, inTransaction: (0, entity_manager_ts_1.isTransactionManager)(tx) });
128
+ return id;
129
+ },
130
+ /** Marks the receipt row of a pair as already inserted, so its next winning `claimRow` answers undefined. */
131
+ receiptExists(source, eventId) {
132
+ existingReceipts.add(keyOf(source, eventId));
133
+ },
109
134
  async release(source, eventId) {
110
135
  failIfScripted("release");
111
136
  released.push({ source, eventId });
@@ -160,7 +185,13 @@ function fakeInbox(clock) {
160
185
  get sweeps() {
161
186
  return [...sweeps];
162
187
  },
188
+ /** Every receipt row `claimRow` inserted, in order. */
189
+ get receipts() {
190
+ return [...receipts];
191
+ },
163
192
  clear() {
193
+ receipts.length = 0;
194
+ existingReceipts.clear();
164
195
  held.clear();
165
196
  claims.length = 0;
166
197
  released.length = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/jest-preset",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
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(), fakeQueryBuilder(), queueQueryBuilders(), fakeCache(), fakeLock(), recordingEventBus(), recordingQueueOutbox(), fakeInbox(), FakeClock, fakeIds(), builder() and the Outcome matchers toBeRefused/toSucceedWith.",
5
5
  "license": "MIT",
6
6
  "author": "starci183",