@entifix/testing-unit 0.1.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/LICENSE +21 -0
- package/README.md +60 -0
- package/dist/builders/entity-builder.d.ts +26 -0
- package/dist/builders/entity-builder.d.ts.map +1 -0
- package/dist/builders/entity-builder.js +30 -0
- package/dist/builders/entity-builder.js.map +1 -0
- package/dist/contracts/entity-repository.contract.d.ts +36 -0
- package/dist/contracts/entity-repository.contract.d.ts.map +1 -0
- package/dist/contracts/entity-repository.contract.js +273 -0
- package/dist/contracts/entity-repository.contract.js.map +1 -0
- package/dist/contracts/index.d.ts +3 -0
- package/dist/contracts/index.d.ts.map +1 -0
- package/dist/contracts/index.js +4 -0
- package/dist/contracts/index.js.map +1 -0
- package/dist/contracts/transaction-ports.contract.d.ts +44 -0
- package/dist/contracts/transaction-ports.contract.d.ts.map +1 -0
- package/dist/contracts/transaction-ports.contract.js +203 -0
- package/dist/contracts/transaction-ports.contract.js.map +1 -0
- package/dist/doubles/configuration.d.ts +36 -0
- package/dist/doubles/configuration.d.ts.map +1 -0
- package/dist/doubles/configuration.js +31 -0
- package/dist/doubles/configuration.js.map +1 -0
- package/dist/doubles/entity-repository.d.ts +24 -0
- package/dist/doubles/entity-repository.d.ts.map +1 -0
- package/dist/doubles/entity-repository.js +159 -0
- package/dist/doubles/entity-repository.js.map +1 -0
- package/dist/doubles/link-resolver.d.ts +17 -0
- package/dist/doubles/link-resolver.d.ts.map +1 -0
- package/dist/doubles/link-resolver.js +34 -0
- package/dist/doubles/link-resolver.js.map +1 -0
- package/dist/doubles/transaction-ports.d.ts +70 -0
- package/dist/doubles/transaction-ports.d.ts.map +1 -0
- package/dist/doubles/transaction-ports.js +145 -0
- package/dist/doubles/transaction-ports.js.map +1 -0
- package/dist/drivers/fake-amqp.d.ts +91 -0
- package/dist/drivers/fake-amqp.d.ts.map +1 -0
- package/dist/drivers/fake-amqp.js +164 -0
- package/dist/drivers/fake-amqp.js.map +1 -0
- package/dist/drivers/fake-mongo.d.ts +59 -0
- package/dist/drivers/fake-mongo.d.ts.map +1 -0
- package/dist/drivers/fake-mongo.js +603 -0
- package/dist/drivers/fake-mongo.js.map +1 -0
- package/dist/drivers/fake-redis.d.ts +32 -0
- package/dist/drivers/fake-redis.d.ts.map +1 -0
- package/dist/drivers/fake-redis.js +164 -0
- package/dist/drivers/fake-redis.js.map +1 -0
- package/dist/drivers/fake-sql.d.ts +58 -0
- package/dist/drivers/fake-sql.d.ts.map +1 -0
- package/dist/drivers/fake-sql.js +102 -0
- package/dist/drivers/fake-sql.js.map +1 -0
- package/dist/drivers/index.d.ts +5 -0
- package/dist/drivers/index.d.ts.map +1 -0
- package/dist/drivers/index.js +6 -0
- package/dist/drivers/index.js.map +1 -0
- package/dist/effect/run.d.ts +34 -0
- package/dist/effect/run.d.ts.map +1 -0
- package/dist/effect/run.js +48 -0
- package/dist/effect/run.js.map +1 -0
- package/dist/http/entity-handlers.d.ts +37 -0
- package/dist/http/entity-handlers.d.ts.map +1 -0
- package/dist/http/entity-handlers.js +92 -0
- package/dist/http/entity-handlers.js.map +1 -0
- package/dist/http/index.d.ts +3 -0
- package/dist/http/index.d.ts.map +1 -0
- package/dist/http/index.js +4 -0
- package/dist/http/index.js.map +1 -0
- package/dist/http/server.d.ts +19 -0
- package/dist/http/server.d.ts.map +1 -0
- package/dist/http/server.js +31 -0
- package/dist/http/server.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +4 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/render.d.ts +30 -0
- package/dist/react/render.d.ts.map +1 -0
- package/dist/react/render.js +38 -0
- package/dist/react/render.js.map +1 -0
- package/dist/react/ui-preferences.d.ts +17 -0
- package/dist/react/ui-preferences.d.ts.map +1 -0
- package/dist/react/ui-preferences.js +29 -0
- package/dist/react/ui-preferences.js.map +1 -0
- package/package.json +122 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type DomainEvent, type EntifixError } from '@entifix/core';
|
|
2
|
+
import type { EventBus, LockService, SequenceService, TransactionInbox } from '@entifix/transactions';
|
|
3
|
+
import { Effect } from 'effect';
|
|
4
|
+
export interface InMemoryLockService extends LockService {
|
|
5
|
+
/** Keys currently held, in acquisition order. */
|
|
6
|
+
readonly held: string[];
|
|
7
|
+
/** Every acquire/release in the order it happened. */
|
|
8
|
+
readonly log: ReadonlyArray<{
|
|
9
|
+
action: 'acquire' | 'release';
|
|
10
|
+
key: string;
|
|
11
|
+
}>;
|
|
12
|
+
/**
|
|
13
|
+
* Marks `key` as already taken by someone else, so the next `acquire` fails
|
|
14
|
+
* with {@link EntifixLockError} — the 409 path of the transaction facade.
|
|
15
|
+
*/
|
|
16
|
+
contendOn(key: string): void;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* In-memory {@link LockService}. Acquisition is immediate and uncontended
|
|
20
|
+
* unless {@link InMemoryLockService.contendOn} says otherwise, and `release`
|
|
21
|
+
* only frees a lock whose token matches — the compare-and-delete rule the Redis
|
|
22
|
+
* adapter enforces with a Lua script.
|
|
23
|
+
*/
|
|
24
|
+
export declare const makeInMemoryLockService: () => InMemoryLockService;
|
|
25
|
+
export interface InMemorySequenceService extends SequenceService {
|
|
26
|
+
/** The current value of every sequence drawn from so far. */
|
|
27
|
+
readonly values: Readonly<Record<string, number>>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* In-memory {@link SequenceService} — a plain counter per name. Atomicity is
|
|
31
|
+
* what the Redis `INCR` adapter buys; single-threaded JS gives it for free here.
|
|
32
|
+
*/
|
|
33
|
+
export declare const makeInMemorySequenceService: () => InMemorySequenceService;
|
|
34
|
+
export interface RecordingEventBus extends EventBus {
|
|
35
|
+
/** Every event published, in order. */
|
|
36
|
+
readonly published: DomainEvent[];
|
|
37
|
+
/**
|
|
38
|
+
* Pushes an event to the subscribers whose pattern matches it, as a topic
|
|
39
|
+
* exchange would. Deliberately not "to all subscribers": routing is the
|
|
40
|
+
* broker's job in production, so a double that ignored it would let a
|
|
41
|
+
* consumer pass its tests while receiving traffic it never bound to.
|
|
42
|
+
*/
|
|
43
|
+
deliver(event: DomainEvent): Effect.Effect<void, EntifixError>;
|
|
44
|
+
/** Makes the next `publish` fail, for the broker-unavailable branch. */
|
|
45
|
+
failNextPublish(): void;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* In-memory {@link EventBus} that records what was published rather than
|
|
49
|
+
* asserting on it. Prefer reading `bus.published` in a test over a mock's call
|
|
50
|
+
* assertions: the expectation then reads as state, not as a spy protocol.
|
|
51
|
+
*/
|
|
52
|
+
export declare const makeRecordingEventBus: () => RecordingEventBus;
|
|
53
|
+
/**
|
|
54
|
+
* An in-memory {@link TransactionInbox}, one per consumer, over a shared claim
|
|
55
|
+
* set.
|
|
56
|
+
*
|
|
57
|
+
* Sharing the set across the inboxes one factory hands out is what makes the
|
|
58
|
+
* contract's "one consumer cannot consume another's claim" case a real
|
|
59
|
+
* assertion, rather than one the double satisfies by only ever having seen a
|
|
60
|
+
* single consumer.
|
|
61
|
+
*/
|
|
62
|
+
export declare const makeInMemoryInboxes: () => {
|
|
63
|
+
/** An inbox for `consumer`, over the shared claim set. */
|
|
64
|
+
for: (consumer: string) => TransactionInbox;
|
|
65
|
+
readonly claims: readonly {
|
|
66
|
+
consumer: string;
|
|
67
|
+
eventId: string;
|
|
68
|
+
}[];
|
|
69
|
+
};
|
|
70
|
+
//# sourceMappingURL=transaction-ports.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transaction-ports.d.ts","sourceRoot":"","sources":["../../src/doubles/transaction-ports.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,WAAW,EAEhB,KAAK,YAAY,EAGlB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EACV,QAAQ,EAER,WAAW,EACX,eAAe,EACf,gBAAgB,EACjB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,MAAM,WAAW,mBAAoB,SAAQ,WAAW;IACtD,iDAAiD;IACjD,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC;IACxB,sDAAsD;IACtD,QAAQ,CAAC,GAAG,EAAE,aAAa,CAAC;QAAE,MAAM,EAAE,SAAS,GAAG,SAAS,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC5E;;;OAGG;IACH,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,QAAO,mBAsC1C,CAAC;AAEF,MAAM,WAAW,uBAAwB,SAAQ,eAAe;IAC9D,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACnD;AAED;;;GAGG;AACH,eAAO,MAAM,2BAA2B,QAAO,uBAc9C,CAAC;AAEF,MAAM,WAAW,iBAAkB,SAAQ,QAAQ;IACjD,uCAAuC;IACvC,QAAQ,CAAC,SAAS,EAAE,WAAW,EAAE,CAAC;IAClC;;;;;OAKG;IACH,OAAO,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC;IAC/D,wEAAwE;IACxE,eAAe,IAAI,IAAI,CAAC;CACzB;AAED;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,QAAO,iBA4CxC,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB;IAI5B,0DAA0D;oBAC1C,MAAM,KAAG,gBAAgB;qBAS3B,SAAS;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE;CAOjE,CAAC"}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { EntifixConnError, EntifixLockError, matchesEventPattern } from '@entifix/core';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
/**
|
|
4
|
+
* In-memory {@link LockService}. Acquisition is immediate and uncontended
|
|
5
|
+
* unless {@link InMemoryLockService.contendOn} says otherwise, and `release`
|
|
6
|
+
* only frees a lock whose token matches — the compare-and-delete rule the Redis
|
|
7
|
+
* adapter enforces with a Lua script.
|
|
8
|
+
*/ export const makeInMemoryLockService = ()=>{
|
|
9
|
+
const tokens = new Map();
|
|
10
|
+
const contended = new Set();
|
|
11
|
+
const log = [];
|
|
12
|
+
let counter = 0;
|
|
13
|
+
return {
|
|
14
|
+
acquire: (key)=>Effect.suspend(()=>{
|
|
15
|
+
if (contended.has(key) || tokens.has(key)) {
|
|
16
|
+
return Effect.fail(new EntifixLockError(`Could not acquire lock "${key}"`, undefined, {
|
|
17
|
+
key
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
const token = `token-${counter += 1}`;
|
|
21
|
+
tokens.set(key, token);
|
|
22
|
+
log.push({
|
|
23
|
+
action: 'acquire',
|
|
24
|
+
key
|
|
25
|
+
});
|
|
26
|
+
return Effect.succeed({
|
|
27
|
+
key,
|
|
28
|
+
token
|
|
29
|
+
});
|
|
30
|
+
}),
|
|
31
|
+
release: (handle)=>Effect.sync(()=>{
|
|
32
|
+
if (tokens.get(handle.key) === handle.token) {
|
|
33
|
+
tokens.delete(handle.key);
|
|
34
|
+
log.push({
|
|
35
|
+
action: 'release',
|
|
36
|
+
key: handle.key
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}),
|
|
40
|
+
get held () {
|
|
41
|
+
return [
|
|
42
|
+
...tokens.keys()
|
|
43
|
+
];
|
|
44
|
+
},
|
|
45
|
+
get log () {
|
|
46
|
+
return log;
|
|
47
|
+
},
|
|
48
|
+
contendOn: (key)=>{
|
|
49
|
+
contended.add(key);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* In-memory {@link SequenceService} — a plain counter per name. Atomicity is
|
|
55
|
+
* what the Redis `INCR` adapter buys; single-threaded JS gives it for free here.
|
|
56
|
+
*/ export const makeInMemorySequenceService = ()=>{
|
|
57
|
+
const sequences = new Map();
|
|
58
|
+
return {
|
|
59
|
+
next: (name)=>Effect.sync(()=>{
|
|
60
|
+
var _sequences_get;
|
|
61
|
+
const next = ((_sequences_get = sequences.get(name)) != null ? _sequences_get : 0) + 1;
|
|
62
|
+
sequences.set(name, next);
|
|
63
|
+
return next;
|
|
64
|
+
}),
|
|
65
|
+
get values () {
|
|
66
|
+
return Object.fromEntries(sequences);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* In-memory {@link EventBus} that records what was published rather than
|
|
72
|
+
* asserting on it. Prefer reading `bus.published` in a test over a mock's call
|
|
73
|
+
* assertions: the expectation then reads as state, not as a spy protocol.
|
|
74
|
+
*/ export const makeRecordingEventBus = ()=>{
|
|
75
|
+
const published = [];
|
|
76
|
+
const handlers = [];
|
|
77
|
+
let failNext = false;
|
|
78
|
+
return {
|
|
79
|
+
publish: (event)=>Effect.suspend(()=>{
|
|
80
|
+
if (failNext) {
|
|
81
|
+
failNext = false;
|
|
82
|
+
return Effect.fail(new EntifixConnError('Event bus unavailable', undefined, {
|
|
83
|
+
eventId: event.id
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
published.push(event);
|
|
87
|
+
return Effect.void;
|
|
88
|
+
}),
|
|
89
|
+
subscribe: (subscription, handle)=>Effect.sync(()=>{
|
|
90
|
+
// Only the pattern is retained: queue durability and the delivery
|
|
91
|
+
// ceiling are broker facts with no in-memory analogue, and faking them
|
|
92
|
+
// would assert against the double rather than against a broker.
|
|
93
|
+
handlers.push({
|
|
94
|
+
pattern: subscription.pattern,
|
|
95
|
+
handle
|
|
96
|
+
});
|
|
97
|
+
}),
|
|
98
|
+
deliver: (event)=>Effect.forEach(handlers.filter(({ pattern })=>matchesEventPattern(pattern, event.name)), ({ handle })=>handle(event), {
|
|
99
|
+
discard: true
|
|
100
|
+
}),
|
|
101
|
+
get published () {
|
|
102
|
+
return published;
|
|
103
|
+
},
|
|
104
|
+
failNextPublish: ()=>{
|
|
105
|
+
failNext = true;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* An in-memory {@link TransactionInbox}, one per consumer, over a shared claim
|
|
111
|
+
* set.
|
|
112
|
+
*
|
|
113
|
+
* Sharing the set across the inboxes one factory hands out is what makes the
|
|
114
|
+
* contract's "one consumer cannot consume another's claim" case a real
|
|
115
|
+
* assertion, rather than one the double satisfies by only ever having seen a
|
|
116
|
+
* single consumer.
|
|
117
|
+
*/ export const makeInMemoryInboxes = ()=>{
|
|
118
|
+
const claimed = new Set();
|
|
119
|
+
return {
|
|
120
|
+
/** An inbox for `consumer`, over the shared claim set. */ for: (consumer)=>({
|
|
121
|
+
claim: (eventId)=>Effect.sync(()=>{
|
|
122
|
+
const key = JSON.stringify([
|
|
123
|
+
consumer,
|
|
124
|
+
eventId
|
|
125
|
+
]);
|
|
126
|
+
if (claimed.has(key)) return 'duplicate';
|
|
127
|
+
claimed.add(key);
|
|
128
|
+
return 'claimed';
|
|
129
|
+
})
|
|
130
|
+
}),
|
|
131
|
+
get claims () {
|
|
132
|
+
return [
|
|
133
|
+
...claimed
|
|
134
|
+
].map((key)=>{
|
|
135
|
+
const [consumer, eventId] = JSON.parse(key);
|
|
136
|
+
return {
|
|
137
|
+
consumer,
|
|
138
|
+
eventId
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
//# sourceMappingURL=transaction-ports.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/doubles/transaction-ports.ts"],"sourcesContent":["import {\n type DomainEvent,\n EntifixConnError,\n type EntifixError,\n EntifixLockError,\n matchesEventPattern,\n} from '@entifix/core';\nimport type {\n EventBus,\n LockHandle,\n LockService,\n SequenceService,\n TransactionInbox,\n} from '@entifix/transactions';\nimport { Effect } from 'effect';\n\nexport interface InMemoryLockService extends LockService {\n /** Keys currently held, in acquisition order. */\n readonly held: string[];\n /** Every acquire/release in the order it happened. */\n readonly log: ReadonlyArray<{ action: 'acquire' | 'release'; key: string }>;\n /**\n * Marks `key` as already taken by someone else, so the next `acquire` fails\n * with {@link EntifixLockError} — the 409 path of the transaction facade.\n */\n contendOn(key: string): void;\n}\n\n/**\n * In-memory {@link LockService}. Acquisition is immediate and uncontended\n * unless {@link InMemoryLockService.contendOn} says otherwise, and `release`\n * only frees a lock whose token matches — the compare-and-delete rule the Redis\n * adapter enforces with a Lua script.\n */\nexport const makeInMemoryLockService = (): InMemoryLockService => {\n const tokens = new Map<string, string>();\n const contended = new Set<string>();\n const log: Array<{ action: 'acquire' | 'release'; key: string }> = [];\n let counter = 0;\n\n return {\n acquire: key =>\n Effect.suspend(() => {\n if (contended.has(key) || tokens.has(key)) {\n return Effect.fail(\n new EntifixLockError(`Could not acquire lock \"${key}\"`, undefined, {\n key,\n }),\n );\n }\n const token = `token-${(counter += 1)}`;\n tokens.set(key, token);\n log.push({ action: 'acquire', key });\n return Effect.succeed({ key, token });\n }),\n release: (handle: LockHandle) =>\n Effect.sync(() => {\n if (tokens.get(handle.key) === handle.token) {\n tokens.delete(handle.key);\n log.push({ action: 'release', key: handle.key });\n }\n }),\n get held() {\n return [...tokens.keys()];\n },\n get log() {\n return log;\n },\n contendOn: key => {\n contended.add(key);\n },\n };\n};\n\nexport interface InMemorySequenceService extends SequenceService {\n /** The current value of every sequence drawn from so far. */\n readonly values: Readonly<Record<string, number>>;\n}\n\n/**\n * In-memory {@link SequenceService} — a plain counter per name. Atomicity is\n * what the Redis `INCR` adapter buys; single-threaded JS gives it for free here.\n */\nexport const makeInMemorySequenceService = (): InMemorySequenceService => {\n const sequences = new Map<string, number>();\n\n return {\n next: name =>\n Effect.sync(() => {\n const next = (sequences.get(name) ?? 0) + 1;\n sequences.set(name, next);\n return next;\n }),\n get values() {\n return Object.fromEntries(sequences);\n },\n };\n};\n\nexport interface RecordingEventBus extends EventBus {\n /** Every event published, in order. */\n readonly published: DomainEvent[];\n /**\n * Pushes an event to the subscribers whose pattern matches it, as a topic\n * exchange would. Deliberately not \"to all subscribers\": routing is the\n * broker's job in production, so a double that ignored it would let a\n * consumer pass its tests while receiving traffic it never bound to.\n */\n deliver(event: DomainEvent): Effect.Effect<void, EntifixError>;\n /** Makes the next `publish` fail, for the broker-unavailable branch. */\n failNextPublish(): void;\n}\n\n/**\n * In-memory {@link EventBus} that records what was published rather than\n * asserting on it. Prefer reading `bus.published` in a test over a mock's call\n * assertions: the expectation then reads as state, not as a spy protocol.\n */\nexport const makeRecordingEventBus = (): RecordingEventBus => {\n const published: DomainEvent[] = [];\n const handlers: Array<{\n pattern: string;\n handle: (event: DomainEvent) => Effect.Effect<void, EntifixError>;\n }> = [];\n let failNext = false;\n\n return {\n publish: event =>\n Effect.suspend(() => {\n if (failNext) {\n failNext = false;\n return Effect.fail(\n new EntifixConnError('Event bus unavailable', undefined, {\n eventId: event.id,\n }),\n );\n }\n published.push(event);\n return Effect.void;\n }),\n subscribe: (subscription, handle) =>\n Effect.sync(() => {\n // Only the pattern is retained: queue durability and the delivery\n // ceiling are broker facts with no in-memory analogue, and faking them\n // would assert against the double rather than against a broker.\n handlers.push({ pattern: subscription.pattern, handle });\n }),\n deliver: event =>\n Effect.forEach(\n handlers.filter(({ pattern }) =>\n matchesEventPattern(pattern, event.name),\n ),\n ({ handle }) => handle(event),\n { discard: true },\n ),\n get published() {\n return published;\n },\n failNextPublish: () => {\n failNext = true;\n },\n };\n};\n\n/**\n * An in-memory {@link TransactionInbox}, one per consumer, over a shared claim\n * set.\n *\n * Sharing the set across the inboxes one factory hands out is what makes the\n * contract's \"one consumer cannot consume another's claim\" case a real\n * assertion, rather than one the double satisfies by only ever having seen a\n * single consumer.\n */\nexport const makeInMemoryInboxes = () => {\n const claimed = new Set<string>();\n\n return {\n /** An inbox for `consumer`, over the shared claim set. */\n for: (consumer: string): TransactionInbox => ({\n claim: eventId =>\n Effect.sync(() => {\n const key = JSON.stringify([consumer, eventId]);\n if (claimed.has(key)) return 'duplicate' as const;\n claimed.add(key);\n return 'claimed' as const;\n }),\n }),\n get claims(): readonly { consumer: string; eventId: string }[] {\n return [...claimed].map(key => {\n const [consumer, eventId] = JSON.parse(key) as [string, string];\n return { consumer, eventId };\n });\n },\n };\n};\n"],"names":["EntifixConnError","EntifixLockError","matchesEventPattern","Effect","makeInMemoryLockService","tokens","Map","contended","Set","log","counter","acquire","key","suspend","has","fail","undefined","token","set","push","action","succeed","release","handle","sync","get","delete","held","keys","contendOn","add","makeInMemorySequenceService","sequences","next","name","values","Object","fromEntries","makeRecordingEventBus","published","handlers","failNext","publish","event","eventId","id","void","subscribe","subscription","pattern","deliver","forEach","filter","discard","failNextPublish","makeInMemoryInboxes","claimed","for","consumer","claim","JSON","stringify","claims","map","parse"],"mappings":"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"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fake of the amqplib channel, not of the {@link EventBus} port.
|
|
3
|
+
*
|
|
4
|
+
* `makeAmqpEventBus` runs on top of it, so the envelope framing, the
|
|
5
|
+
* `prefetch(1)` the saga tracker depends on, and the ack/nack policy are
|
|
6
|
+
* all exercised for real.
|
|
7
|
+
*/
|
|
8
|
+
export interface FakeAmqpMessage {
|
|
9
|
+
content: Buffer;
|
|
10
|
+
}
|
|
11
|
+
export interface FakeAmqpChannel {
|
|
12
|
+
/** Everything published, decoded from the wire. */
|
|
13
|
+
readonly published: ReadonlyArray<{
|
|
14
|
+
exchange: string;
|
|
15
|
+
routingKey: string;
|
|
16
|
+
body: unknown;
|
|
17
|
+
}>;
|
|
18
|
+
/**
|
|
19
|
+
* Every queue binding the adapter asked for. Recorded because the exchange is
|
|
20
|
+
* a topic now: the pattern a subscriber binds is the whole of its routing, so
|
|
21
|
+
* a fake that swallowed it would let a wrong binding pass every test.
|
|
22
|
+
*/
|
|
23
|
+
readonly bindings: ReadonlyArray<{
|
|
24
|
+
queue: string;
|
|
25
|
+
exchange: string;
|
|
26
|
+
pattern: string;
|
|
27
|
+
}>;
|
|
28
|
+
/**
|
|
29
|
+
* Every exchange the adapter declared, with its type. Recorded because the
|
|
30
|
+
* dead-letter exchange is `direct` while the event exchange is `topic`, and a
|
|
31
|
+
* broker will not retype an existing exchange — declaring the wrong type is a
|
|
32
|
+
* failure that only appears against a real broker.
|
|
33
|
+
*/
|
|
34
|
+
readonly exchanges: ReadonlyArray<{
|
|
35
|
+
exchange: string;
|
|
36
|
+
type: string;
|
|
37
|
+
options: Record<string, unknown> | undefined;
|
|
38
|
+
}>;
|
|
39
|
+
/**
|
|
40
|
+
* Every queue the adapter declared, with the arguments it declared it with.
|
|
41
|
+
* The arguments *are* the delivery policy — `x-queue-type`,
|
|
42
|
+
* `x-delivery-limit`, `x-dead-letter-exchange` — so a fake that swallowed
|
|
43
|
+
* them would let a queue with no retry ceiling and no dead-letter path pass
|
|
44
|
+
* every test, which is the exact defect ADR 0030 exists to close.
|
|
45
|
+
*/
|
|
46
|
+
readonly queues: ReadonlyArray<{
|
|
47
|
+
queue: string;
|
|
48
|
+
options: Record<string, unknown> | undefined;
|
|
49
|
+
}>;
|
|
50
|
+
/** Messages acked, so the success policy can be asserted. */
|
|
51
|
+
readonly acked: FakeAmqpMessage[];
|
|
52
|
+
/**
|
|
53
|
+
* Messages nacked, **with their flags**. `requeue` is the whole distinction
|
|
54
|
+
* between a transient failure the broker should retry and a poison message it
|
|
55
|
+
* should quarantine at once, so recording only that a nack happened is how
|
|
56
|
+
* "dead-letters a failed message" stayed a passing test for a bus that
|
|
57
|
+
* discarded it.
|
|
58
|
+
*/
|
|
59
|
+
readonly nacked: ReadonlyArray<{
|
|
60
|
+
message: FakeAmqpMessage;
|
|
61
|
+
allUpTo: boolean;
|
|
62
|
+
requeue: boolean;
|
|
63
|
+
}>;
|
|
64
|
+
/** The prefetch the adapter asked for — 1, or the fold races. */
|
|
65
|
+
readonly prefetchCount: number | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* The consumer tags cancelled, in order.
|
|
68
|
+
*
|
|
69
|
+
* Recorded rather than merely accepted because a graceful shutdown's first
|
|
70
|
+
* phase *is* the cancel (ADR 0030): a connector that dropped the tag on the
|
|
71
|
+
* floor would pass every test while leaving the broker delivering into a
|
|
72
|
+
* process that is closing its connection.
|
|
73
|
+
*/
|
|
74
|
+
readonly cancelled: readonly string[];
|
|
75
|
+
/**
|
|
76
|
+
* Pushes a message to the consumers whose binding matches it, as the broker
|
|
77
|
+
* would. The routing key defaults to the envelope's own `meta.event.name`,
|
|
78
|
+
* which is what the adapter publishes with.
|
|
79
|
+
*/
|
|
80
|
+
deliver(body: unknown, routingKey?: string): Promise<void>;
|
|
81
|
+
/** Pushes a raw payload, for malformed-message paths. */
|
|
82
|
+
deliverRaw(payload: string): Promise<void>;
|
|
83
|
+
/** Simulates the broker cancelling the consumer (amqplib delivers `null`). */
|
|
84
|
+
deliverCancellation(): Promise<void>;
|
|
85
|
+
/** Makes every subsequent channel call throw/reject with `error`. */
|
|
86
|
+
failWith(error: unknown): void;
|
|
87
|
+
/** The object to pass where an amqplib `Channel` is expected. */
|
|
88
|
+
readonly channel: unknown;
|
|
89
|
+
}
|
|
90
|
+
export declare const makeFakeAmqpChannel: () => FakeAmqpChannel;
|
|
91
|
+
//# sourceMappingURL=fake-amqp.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fake-amqp.d.ts","sourceRoot":"","sources":["../../src/drivers/fake-amqp.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AAEH,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAC9B,mDAAmD;IACnD,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC;QAChC,QAAQ,EAAE,MAAM,CAAC;QACjB,UAAU,EAAE,MAAM,CAAC;QACnB,IAAI,EAAE,OAAO,CAAC;KACf,CAAC,CAAC;IACH;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAC;QAC/B,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,EAAE,MAAM,CAAC;QACjB,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC,CAAC;IACH;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC;QAChC,QAAQ,EAAE,MAAM,CAAC;QACjB,IAAI,EAAE,MAAM,CAAC;QACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;KAC9C,CAAC,CAAC;IACH;;;;;;OAMG;IACH,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;QAC7B,KAAK,EAAE,MAAM,CAAC;QACd,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;KAC9C,CAAC,CAAC;IACH,6DAA6D;IAC7D,QAAQ,CAAC,KAAK,EAAE,eAAe,EAAE,CAAC;IAClC;;;;;;OAMG;IACH,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;QAC7B,OAAO,EAAE,eAAe,CAAC;QACzB,OAAO,EAAE,OAAO,CAAC;QACjB,OAAO,EAAE,OAAO,CAAC;KAClB,CAAC,CAAC;IACH,iEAAiE;IACjE,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3C;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC;;;;OAIG;IACH,OAAO,CAAC,IAAI,EAAE,OAAO,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3D,yDAAyD;IACzD,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3C,8EAA8E;IAC9E,mBAAmB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,qEAAqE;IACrE,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;IAC/B,iEAAiE;IACjE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC3B;AASD,eAAO,MAAM,mBAAmB,QAAO,eA0KtC,CAAC"}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { matchesEventPattern } from '@entifix/core';
|
|
2
|
+
/** The routing key a body would have been published with. */ const routingKeyOf = (body)=>{
|
|
3
|
+
var _body_meta;
|
|
4
|
+
const event = body == null ? void 0 : (_body_meta = body.meta) == null ? void 0 : _body_meta.event;
|
|
5
|
+
return typeof (event == null ? void 0 : event.name) === 'string' ? event.name : '';
|
|
6
|
+
};
|
|
7
|
+
export const makeFakeAmqpChannel = ()=>{
|
|
8
|
+
const published = [];
|
|
9
|
+
const bindings = [];
|
|
10
|
+
const exchanges = [];
|
|
11
|
+
const queues = [];
|
|
12
|
+
const acked = [];
|
|
13
|
+
const nacked = [];
|
|
14
|
+
let consumer;
|
|
15
|
+
const cancelled = [];
|
|
16
|
+
let consumerTags = 0;
|
|
17
|
+
let boundQueue;
|
|
18
|
+
let prefetchCount;
|
|
19
|
+
let failure;
|
|
20
|
+
const guard = ()=>{
|
|
21
|
+
if (failure !== undefined) throw failure;
|
|
22
|
+
};
|
|
23
|
+
const channel = {
|
|
24
|
+
publish: (exchange, routingKey, content)=>{
|
|
25
|
+
guard();
|
|
26
|
+
published.push({
|
|
27
|
+
exchange,
|
|
28
|
+
routingKey,
|
|
29
|
+
body: JSON.parse(content.toString())
|
|
30
|
+
});
|
|
31
|
+
return true;
|
|
32
|
+
},
|
|
33
|
+
prefetch: async (count)=>{
|
|
34
|
+
guard();
|
|
35
|
+
prefetchCount = count;
|
|
36
|
+
},
|
|
37
|
+
assertExchange: async (exchange, type, options)=>{
|
|
38
|
+
guard();
|
|
39
|
+
exchanges.push({
|
|
40
|
+
exchange,
|
|
41
|
+
type,
|
|
42
|
+
options
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
exchange
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
assertQueue: async (queue, options)=>{
|
|
49
|
+
guard();
|
|
50
|
+
const name = queue === '' ? 'amq.gen-fake' : queue;
|
|
51
|
+
queues.push({
|
|
52
|
+
queue: name,
|
|
53
|
+
options
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
queue: name
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
bindQueue: async (queue, exchange, pattern)=>{
|
|
60
|
+
guard();
|
|
61
|
+
bindings.push({
|
|
62
|
+
queue,
|
|
63
|
+
exchange,
|
|
64
|
+
pattern
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
consume: async (queue, handler)=>{
|
|
68
|
+
guard();
|
|
69
|
+
consumer = handler;
|
|
70
|
+
boundQueue = queue;
|
|
71
|
+
// A fresh tag per `consume`, so a reconnect's re-bind is distinguishable
|
|
72
|
+
// from the binding it replaced — the connector cancels what it currently
|
|
73
|
+
// holds, and a constant tag would hide it cancelling a stale one.
|
|
74
|
+
consumerTags += 1;
|
|
75
|
+
return {
|
|
76
|
+
consumerTag: `fake-consumer-${consumerTags}`
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
cancel: async (consumerTag)=>{
|
|
80
|
+
guard();
|
|
81
|
+
cancelled.push(consumerTag);
|
|
82
|
+
consumer = undefined;
|
|
83
|
+
},
|
|
84
|
+
ack: (message)=>{
|
|
85
|
+
acked.push(message);
|
|
86
|
+
},
|
|
87
|
+
nack: (message, allUpTo, requeue)=>{
|
|
88
|
+
nacked.push({
|
|
89
|
+
message,
|
|
90
|
+
allUpTo,
|
|
91
|
+
requeue
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
close: async ()=>{
|
|
95
|
+
guard();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* The adapter acks/nacks from inside a floating promise chain, so a delivery
|
|
100
|
+
* is only settled once the microtask queue drains.
|
|
101
|
+
*/ const settle = ()=>new Promise((resolve)=>setTimeout(resolve, 0));
|
|
102
|
+
/**
|
|
103
|
+
* Whether the consumer's queue is bound to a pattern matching `routingKey`.
|
|
104
|
+
* The broker decides this in production, so the fake has to as well — without
|
|
105
|
+
* it every subscriber sees every message and the topic exchange is untested.
|
|
106
|
+
*/ const routes = (routingKey)=>bindings.some((binding)=>binding.queue === boundQueue && matchesEventPattern(binding.pattern, routingKey));
|
|
107
|
+
/**
|
|
108
|
+
* `routingKey === undefined` means "the broker already routed this" — used by
|
|
109
|
+
* the malformed-payload path, where the message reached the queue and it is
|
|
110
|
+
* the *content* that is wrong.
|
|
111
|
+
*/ const push = async (payload, routingKey)=>{
|
|
112
|
+
if (consumer === undefined) {
|
|
113
|
+
throw new Error('fake amqp: nothing subscribed yet');
|
|
114
|
+
}
|
|
115
|
+
if (routingKey !== undefined && !routes(routingKey)) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
consumer({
|
|
119
|
+
content: Buffer.from(payload)
|
|
120
|
+
});
|
|
121
|
+
await settle();
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
get published () {
|
|
125
|
+
return published;
|
|
126
|
+
},
|
|
127
|
+
get bindings () {
|
|
128
|
+
return bindings;
|
|
129
|
+
},
|
|
130
|
+
get exchanges () {
|
|
131
|
+
return exchanges;
|
|
132
|
+
},
|
|
133
|
+
get queues () {
|
|
134
|
+
return queues;
|
|
135
|
+
},
|
|
136
|
+
get acked () {
|
|
137
|
+
return acked;
|
|
138
|
+
},
|
|
139
|
+
get nacked () {
|
|
140
|
+
return nacked;
|
|
141
|
+
},
|
|
142
|
+
get prefetchCount () {
|
|
143
|
+
return prefetchCount;
|
|
144
|
+
},
|
|
145
|
+
get cancelled () {
|
|
146
|
+
return cancelled;
|
|
147
|
+
},
|
|
148
|
+
deliver: (body, routingKey)=>push(JSON.stringify(body), routingKey != null ? routingKey : routingKeyOf(body)),
|
|
149
|
+
deliverRaw: (payload)=>push(payload, undefined),
|
|
150
|
+
deliverCancellation: async ()=>{
|
|
151
|
+
if (consumer === undefined) {
|
|
152
|
+
throw new Error('fake amqp: nothing subscribed yet');
|
|
153
|
+
}
|
|
154
|
+
consumer(null);
|
|
155
|
+
await settle();
|
|
156
|
+
},
|
|
157
|
+
failWith: (error)=>{
|
|
158
|
+
failure = error;
|
|
159
|
+
},
|
|
160
|
+
channel
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
//# sourceMappingURL=fake-amqp.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/drivers/fake-amqp.ts"],"sourcesContent":["import { matchesEventPattern } from '@entifix/core';\n\n/**\n * A fake of the amqplib channel, not of the {@link EventBus} port.\n *\n * `makeAmqpEventBus` runs on top of it, so the envelope framing, the\n * `prefetch(1)` the saga tracker depends on, and the ack/nack policy are\n * all exercised for real.\n */\n\nexport interface FakeAmqpMessage {\n content: Buffer;\n}\n\nexport interface FakeAmqpChannel {\n /** Everything published, decoded from the wire. */\n readonly published: ReadonlyArray<{\n exchange: string;\n routingKey: string;\n body: unknown;\n }>;\n /**\n * Every queue binding the adapter asked for. Recorded because the exchange is\n * a topic now: the pattern a subscriber binds is the whole of its routing, so\n * a fake that swallowed it would let a wrong binding pass every test.\n */\n readonly bindings: ReadonlyArray<{\n queue: string;\n exchange: string;\n pattern: string;\n }>;\n /**\n * Every exchange the adapter declared, with its type. Recorded because the\n * dead-letter exchange is `direct` while the event exchange is `topic`, and a\n * broker will not retype an existing exchange — declaring the wrong type is a\n * failure that only appears against a real broker.\n */\n readonly exchanges: ReadonlyArray<{\n exchange: string;\n type: string;\n options: Record<string, unknown> | undefined;\n }>;\n /**\n * Every queue the adapter declared, with the arguments it declared it with.\n * The arguments *are* the delivery policy — `x-queue-type`,\n * `x-delivery-limit`, `x-dead-letter-exchange` — so a fake that swallowed\n * them would let a queue with no retry ceiling and no dead-letter path pass\n * every test, which is the exact defect ADR 0030 exists to close.\n */\n readonly queues: ReadonlyArray<{\n queue: string;\n options: Record<string, unknown> | undefined;\n }>;\n /** Messages acked, so the success policy can be asserted. */\n readonly acked: FakeAmqpMessage[];\n /**\n * Messages nacked, **with their flags**. `requeue` is the whole distinction\n * between a transient failure the broker should retry and a poison message it\n * should quarantine at once, so recording only that a nack happened is how\n * \"dead-letters a failed message\" stayed a passing test for a bus that\n * discarded it.\n */\n readonly nacked: ReadonlyArray<{\n message: FakeAmqpMessage;\n allUpTo: boolean;\n requeue: boolean;\n }>;\n /** The prefetch the adapter asked for — 1, or the fold races. */\n readonly prefetchCount: number | undefined;\n /**\n * The consumer tags cancelled, in order.\n *\n * Recorded rather than merely accepted because a graceful shutdown's first\n * phase *is* the cancel (ADR 0030): a connector that dropped the tag on the\n * floor would pass every test while leaving the broker delivering into a\n * process that is closing its connection.\n */\n readonly cancelled: readonly string[];\n /**\n * Pushes a message to the consumers whose binding matches it, as the broker\n * would. The routing key defaults to the envelope's own `meta.event.name`,\n * which is what the adapter publishes with.\n */\n deliver(body: unknown, routingKey?: string): Promise<void>;\n /** Pushes a raw payload, for malformed-message paths. */\n deliverRaw(payload: string): Promise<void>;\n /** Simulates the broker cancelling the consumer (amqplib delivers `null`). */\n deliverCancellation(): Promise<void>;\n /** Makes every subsequent channel call throw/reject with `error`. */\n failWith(error: unknown): void;\n /** The object to pass where an amqplib `Channel` is expected. */\n readonly channel: unknown;\n}\n\n/** The routing key a body would have been published with. */\nconst routingKeyOf = (body: unknown): string => {\n const event = (body as { meta?: { event?: { name?: unknown } } })?.meta\n ?.event;\n return typeof event?.name === 'string' ? event.name : '';\n};\n\nexport const makeFakeAmqpChannel = (): FakeAmqpChannel => {\n const published: Array<{\n exchange: string;\n routingKey: string;\n body: unknown;\n }> = [];\n const bindings: Array<{ queue: string; exchange: string; pattern: string }> =\n [];\n const exchanges: Array<{\n exchange: string;\n type: string;\n options: Record<string, unknown> | undefined;\n }> = [];\n const queues: Array<{\n queue: string;\n options: Record<string, unknown> | undefined;\n }> = [];\n const acked: FakeAmqpMessage[] = [];\n const nacked: Array<{\n message: FakeAmqpMessage;\n allUpTo: boolean;\n requeue: boolean;\n }> = [];\n let consumer: ((message: FakeAmqpMessage | null) => void) | undefined;\n const cancelled: string[] = [];\n let consumerTags = 0;\n let boundQueue: string | undefined;\n let prefetchCount: number | undefined;\n let failure: unknown;\n\n const guard = () => {\n if (failure !== undefined) throw failure;\n };\n\n const channel = {\n publish: (exchange: string, routingKey: string, content: Buffer) => {\n guard();\n published.push({\n exchange,\n routingKey,\n body: JSON.parse(content.toString()) as unknown,\n });\n return true;\n },\n prefetch: async (count: number) => {\n guard();\n prefetchCount = count;\n },\n assertExchange: async (\n exchange: string,\n type: string,\n options?: Record<string, unknown>,\n ) => {\n guard();\n exchanges.push({ exchange, type, options });\n return { exchange };\n },\n assertQueue: async (queue: string, options?: Record<string, unknown>) => {\n guard();\n const name = queue === '' ? 'amq.gen-fake' : queue;\n queues.push({ queue: name, options });\n return { queue: name };\n },\n bindQueue: async (queue: string, exchange: string, pattern: string) => {\n guard();\n bindings.push({ queue, exchange, pattern });\n },\n consume: async (\n queue: string,\n handler: (message: FakeAmqpMessage | null) => void,\n ) => {\n guard();\n consumer = handler;\n boundQueue = queue;\n // A fresh tag per `consume`, so a reconnect's re-bind is distinguishable\n // from the binding it replaced — the connector cancels what it currently\n // holds, and a constant tag would hide it cancelling a stale one.\n consumerTags += 1;\n return { consumerTag: `fake-consumer-${consumerTags}` };\n },\n cancel: async (consumerTag: string) => {\n guard();\n cancelled.push(consumerTag);\n consumer = undefined;\n },\n ack: (message: FakeAmqpMessage) => {\n acked.push(message);\n },\n nack: (message: FakeAmqpMessage, allUpTo: boolean, requeue: boolean) => {\n nacked.push({ message, allUpTo, requeue });\n },\n close: async () => {\n guard();\n },\n };\n\n /**\n * The adapter acks/nacks from inside a floating promise chain, so a delivery\n * is only settled once the microtask queue drains.\n */\n const settle = () => new Promise<void>(resolve => setTimeout(resolve, 0));\n\n /**\n * Whether the consumer's queue is bound to a pattern matching `routingKey`.\n * The broker decides this in production, so the fake has to as well — without\n * it every subscriber sees every message and the topic exchange is untested.\n */\n const routes = (routingKey: string) =>\n bindings.some(\n binding =>\n binding.queue === boundQueue &&\n matchesEventPattern(binding.pattern, routingKey),\n );\n\n /**\n * `routingKey === undefined` means \"the broker already routed this\" — used by\n * the malformed-payload path, where the message reached the queue and it is\n * the *content* that is wrong.\n */\n const push = async (payload: string, routingKey: string | undefined) => {\n if (consumer === undefined) {\n throw new Error('fake amqp: nothing subscribed yet');\n }\n if (routingKey !== undefined && !routes(routingKey)) {\n return;\n }\n consumer({ content: Buffer.from(payload) });\n await settle();\n };\n\n return {\n get published() {\n return published;\n },\n get bindings() {\n return bindings;\n },\n get exchanges() {\n return exchanges;\n },\n get queues() {\n return queues;\n },\n get acked() {\n return acked;\n },\n get nacked() {\n return nacked;\n },\n get prefetchCount() {\n return prefetchCount;\n },\n get cancelled() {\n return cancelled;\n },\n deliver: (body, routingKey) =>\n push(JSON.stringify(body), routingKey ?? routingKeyOf(body)),\n deliverRaw: payload => push(payload, undefined),\n deliverCancellation: async () => {\n if (consumer === undefined) {\n throw new Error('fake amqp: nothing subscribed yet');\n }\n consumer(null);\n await settle();\n },\n failWith: error => {\n failure = error;\n },\n channel,\n };\n};\n"],"names":["matchesEventPattern","routingKeyOf","body","event","meta","name","makeFakeAmqpChannel","published","bindings","exchanges","queues","acked","nacked","consumer","cancelled","consumerTags","boundQueue","prefetchCount","failure","guard","undefined","channel","publish","exchange","routingKey","content","push","JSON","parse","toString","prefetch","count","assertExchange","type","options","assertQueue","queue","bindQueue","pattern","consume","handler","consumerTag","cancel","ack","message","nack","allUpTo","requeue","close","settle","Promise","resolve","setTimeout","routes","some","binding","payload","Error","Buffer","from","deliver","stringify","deliverRaw","deliverCancellation","failWith","error"],"mappings":"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"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fake of the mongodb driver, not of the repository port.
|
|
3
|
+
*
|
|
4
|
+
* It sits one level *below* `makeMongoRepository`, so the adapter's own code —
|
|
5
|
+
* the filter translation, the projection, the upsert rule, the error mapping —
|
|
6
|
+
* actually runs and is measured by coverage. A fake at the port level would
|
|
7
|
+
* skip all of it.
|
|
8
|
+
*
|
|
9
|
+
* Only the surface the adapter uses is implemented; anything else is a
|
|
10
|
+
* deliberate `TypeError` rather than a silent no-op.
|
|
11
|
+
*/
|
|
12
|
+
type Document = Record<string, unknown>;
|
|
13
|
+
export interface FakeMongoCollection {
|
|
14
|
+
/** The documents currently stored, as the driver would return them. */
|
|
15
|
+
readonly documents: Document[];
|
|
16
|
+
}
|
|
17
|
+
export interface FakeMongoDb {
|
|
18
|
+
/** Replaces a collection's contents. */
|
|
19
|
+
seed(collection: string, documents: Document[]): void;
|
|
20
|
+
/** Reads a collection's current contents. */
|
|
21
|
+
read(collection: string): Document[];
|
|
22
|
+
/**
|
|
23
|
+
* Makes every subsequent driver call reject with `error`, so the adapter's
|
|
24
|
+
* `EntifixConnError` mapping is reachable. Pass `undefined` to restore.
|
|
25
|
+
*/
|
|
26
|
+
failWith(error: unknown): void;
|
|
27
|
+
/**
|
|
28
|
+
* Makes only `operation` reject, leaving the rest working — the way a read
|
|
29
|
+
* that succeeds and a count that then fails reaches a distinct error branch.
|
|
30
|
+
*/
|
|
31
|
+
failOn(operation: string, error: unknown): void;
|
|
32
|
+
/** Every operation performed, for assertions about what the adapter did. */
|
|
33
|
+
readonly operations: ReadonlyArray<{
|
|
34
|
+
collection: string;
|
|
35
|
+
op: string;
|
|
36
|
+
}>;
|
|
37
|
+
/**
|
|
38
|
+
* Opens a session whose `withTransaction` rolls the whole store back when its
|
|
39
|
+
* body throws. What a service hands to `client.startSession()`; see the
|
|
40
|
+
* implementation for why it is atomicity and not isolation.
|
|
41
|
+
*/
|
|
42
|
+
startSession(): {
|
|
43
|
+
withTransaction(body: () => Promise<unknown>): Promise<unknown>;
|
|
44
|
+
endSession(): Promise<void>;
|
|
45
|
+
readonly hasEnded: boolean;
|
|
46
|
+
};
|
|
47
|
+
/** The object to hand to `makeMongoRepository` in place of a real `Db`. */
|
|
48
|
+
readonly db: unknown;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Builds an in-memory stand-in for a mongodb `Db`.
|
|
52
|
+
*
|
|
53
|
+
* `db` is typed `unknown` on purpose: casting it at the call site keeps this
|
|
54
|
+
* package free of a hard dependency on the `mongodb` types while remaining
|
|
55
|
+
* honest that it is not a real driver.
|
|
56
|
+
*/
|
|
57
|
+
export declare const makeFakeMongoDb: (seed?: Record<string, Document[]>) => FakeMongoDb;
|
|
58
|
+
export {};
|
|
59
|
+
//# sourceMappingURL=fake-mongo.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fake-mongo.d.ts","sourceRoot":"","sources":["../../src/drivers/fake-mongo.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,KAAK,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAuMxC,MAAM,WAAW,mBAAmB;IAClC,uEAAuE;IACvE,QAAQ,CAAC,SAAS,EAAE,QAAQ,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,WAAW;IAC1B,wCAAwC;IACxC,IAAI,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,IAAI,CAAC;IACtD,6CAA6C;IAC7C,IAAI,CAAC,UAAU,EAAE,MAAM,GAAG,QAAQ,EAAE,CAAC;IACrC;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;IAC/B;;;OAGG;IACH,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;IAChD,4EAA4E;IAC5E,QAAQ,CAAC,UAAU,EAAE,aAAa,CAAC;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvE;;;;OAIG;IACH,YAAY,IAAI;QACd,eAAe,CAAC,IAAI,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;QAChE,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;QAC5B,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;KAC5B,CAAC;IACF,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;CACtB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,eAAe,GAC1B,OAAM,MAAM,CAAC,MAAM,EAAE,QAAQ,EAAE,CAAM,KACpC,WA0dF,CAAC"}
|