@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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/dist/builders/entity-builder.d.ts +26 -0
  4. package/dist/builders/entity-builder.d.ts.map +1 -0
  5. package/dist/builders/entity-builder.js +30 -0
  6. package/dist/builders/entity-builder.js.map +1 -0
  7. package/dist/contracts/entity-repository.contract.d.ts +36 -0
  8. package/dist/contracts/entity-repository.contract.d.ts.map +1 -0
  9. package/dist/contracts/entity-repository.contract.js +273 -0
  10. package/dist/contracts/entity-repository.contract.js.map +1 -0
  11. package/dist/contracts/index.d.ts +3 -0
  12. package/dist/contracts/index.d.ts.map +1 -0
  13. package/dist/contracts/index.js +4 -0
  14. package/dist/contracts/index.js.map +1 -0
  15. package/dist/contracts/transaction-ports.contract.d.ts +44 -0
  16. package/dist/contracts/transaction-ports.contract.d.ts.map +1 -0
  17. package/dist/contracts/transaction-ports.contract.js +203 -0
  18. package/dist/contracts/transaction-ports.contract.js.map +1 -0
  19. package/dist/doubles/configuration.d.ts +36 -0
  20. package/dist/doubles/configuration.d.ts.map +1 -0
  21. package/dist/doubles/configuration.js +31 -0
  22. package/dist/doubles/configuration.js.map +1 -0
  23. package/dist/doubles/entity-repository.d.ts +24 -0
  24. package/dist/doubles/entity-repository.d.ts.map +1 -0
  25. package/dist/doubles/entity-repository.js +159 -0
  26. package/dist/doubles/entity-repository.js.map +1 -0
  27. package/dist/doubles/link-resolver.d.ts +17 -0
  28. package/dist/doubles/link-resolver.d.ts.map +1 -0
  29. package/dist/doubles/link-resolver.js +34 -0
  30. package/dist/doubles/link-resolver.js.map +1 -0
  31. package/dist/doubles/transaction-ports.d.ts +70 -0
  32. package/dist/doubles/transaction-ports.d.ts.map +1 -0
  33. package/dist/doubles/transaction-ports.js +145 -0
  34. package/dist/doubles/transaction-ports.js.map +1 -0
  35. package/dist/drivers/fake-amqp.d.ts +91 -0
  36. package/dist/drivers/fake-amqp.d.ts.map +1 -0
  37. package/dist/drivers/fake-amqp.js +164 -0
  38. package/dist/drivers/fake-amqp.js.map +1 -0
  39. package/dist/drivers/fake-mongo.d.ts +59 -0
  40. package/dist/drivers/fake-mongo.d.ts.map +1 -0
  41. package/dist/drivers/fake-mongo.js +603 -0
  42. package/dist/drivers/fake-mongo.js.map +1 -0
  43. package/dist/drivers/fake-redis.d.ts +32 -0
  44. package/dist/drivers/fake-redis.d.ts.map +1 -0
  45. package/dist/drivers/fake-redis.js +164 -0
  46. package/dist/drivers/fake-redis.js.map +1 -0
  47. package/dist/drivers/fake-sql.d.ts +58 -0
  48. package/dist/drivers/fake-sql.d.ts.map +1 -0
  49. package/dist/drivers/fake-sql.js +102 -0
  50. package/dist/drivers/fake-sql.js.map +1 -0
  51. package/dist/drivers/index.d.ts +5 -0
  52. package/dist/drivers/index.d.ts.map +1 -0
  53. package/dist/drivers/index.js +6 -0
  54. package/dist/drivers/index.js.map +1 -0
  55. package/dist/effect/run.d.ts +34 -0
  56. package/dist/effect/run.d.ts.map +1 -0
  57. package/dist/effect/run.js +48 -0
  58. package/dist/effect/run.js.map +1 -0
  59. package/dist/http/entity-handlers.d.ts +37 -0
  60. package/dist/http/entity-handlers.d.ts.map +1 -0
  61. package/dist/http/entity-handlers.js +92 -0
  62. package/dist/http/entity-handlers.js.map +1 -0
  63. package/dist/http/index.d.ts +3 -0
  64. package/dist/http/index.d.ts.map +1 -0
  65. package/dist/http/index.js +4 -0
  66. package/dist/http/index.js.map +1 -0
  67. package/dist/http/server.d.ts +19 -0
  68. package/dist/http/server.d.ts.map +1 -0
  69. package/dist/http/server.js +31 -0
  70. package/dist/http/server.js.map +1 -0
  71. package/dist/index.d.ts +7 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +8 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/react/index.d.ts +3 -0
  76. package/dist/react/index.d.ts.map +1 -0
  77. package/dist/react/index.js +4 -0
  78. package/dist/react/index.js.map +1 -0
  79. package/dist/react/render.d.ts +30 -0
  80. package/dist/react/render.d.ts.map +1 -0
  81. package/dist/react/render.js +38 -0
  82. package/dist/react/render.js.map +1 -0
  83. package/dist/react/ui-preferences.d.ts +17 -0
  84. package/dist/react/ui-preferences.d.ts.map +1 -0
  85. package/dist/react/ui-preferences.js +29 -0
  86. package/dist/react/ui-preferences.js.map +1 -0
  87. package/package.json +122 -0
@@ -0,0 +1,603 @@
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
+ */ /** The subset of a Mongo query document the adapter produces. */ import { _ as _extends } from "@swc/helpers/_/_extends";
12
+ const isPlainObject = (value)=>typeof value === 'object' && value !== null && !Array.isArray(value);
13
+ const compare = (left, right)=>{
14
+ if (left == null && right == null) return 0;
15
+ if (left == null) return -1;
16
+ if (right == null) return 1;
17
+ if (typeof left === 'number' && typeof right === 'number') return left - right;
18
+ if (left instanceof Date && right instanceof Date) {
19
+ return left.getTime() - right.getTime();
20
+ }
21
+ return String(left).localeCompare(String(right));
22
+ };
23
+ /** Evaluates one `{ $op: value }` condition against a document field. */ const matchesCondition = (actual, condition)=>{
24
+ if (!isPlainObject(condition)) {
25
+ return actual === condition;
26
+ }
27
+ return Object.entries(condition).every(([operator, operand])=>{
28
+ switch(operator){
29
+ case '$eq':
30
+ return actual === operand;
31
+ case '$ne':
32
+ return actual !== operand;
33
+ case '$gt':
34
+ return compare(actual, operand) > 0;
35
+ case '$gte':
36
+ return compare(actual, operand) >= 0;
37
+ case '$lt':
38
+ return compare(actual, operand) < 0;
39
+ case '$lte':
40
+ return compare(actual, operand) <= 0;
41
+ case '$in':
42
+ return operand.includes(actual);
43
+ // Absence, not falsiness. A settlement ledger keys "unpaid" on a `runId`
44
+ // that is not there at all, and `undefined` is what a document without
45
+ // the field reads as — so `{ $exists: false }` must be a different test
46
+ // from `{ $eq: undefined }` would be if the serializer ever wrote one.
47
+ case '$exists':
48
+ return actual !== undefined === operand;
49
+ case '$nin':
50
+ return !operand.includes(actual);
51
+ case '$not':
52
+ return !matchesCondition(actual, operand);
53
+ case '$regex':
54
+ var _condition_$options;
55
+ return new RegExp(operand, (_condition_$options = condition['$options']) != null ? _condition_$options : '').test(String(actual));
56
+ case '$options':
57
+ // Handled together with `$regex`.
58
+ return true;
59
+ default:
60
+ throw new TypeError(`fake mongo: unsupported operator "${operator}"`);
61
+ }
62
+ });
63
+ };
64
+ /**
65
+ * Evaluates one node of an aggregation expression — the `$expr` half of a
66
+ * query, which is a different language from the operators above: it compares
67
+ * two *fields of the same document* rather than a field against a constant.
68
+ *
69
+ * Only the shape a conditional stock write sends is implemented — a
70
+ * `$subtract` of two field paths compared with `$gte` — and anything else is a
71
+ * `TypeError`. That is the same rule the rest of this file follows: a fake that
72
+ * quietly evaluated an expression it does not understand would answer `true`
73
+ * for a document the real server would have refused, which turns an
74
+ * oversell-prevention test into a test that asserts nothing.
75
+ */ const evaluateExpression = (doc, node)=>{
76
+ if (typeof node === 'string' && node.startsWith('$')) {
77
+ return doc[node.slice(1)];
78
+ }
79
+ if (!isPlainObject(node)) {
80
+ return node;
81
+ }
82
+ const entries = Object.entries(node);
83
+ if (entries.length !== 1) {
84
+ throw new TypeError(`fake mongo: $expr node must have exactly one operator, got ${entries.length}`);
85
+ }
86
+ const [operator, operand] = entries[0];
87
+ const operands = operand.map((child)=>evaluateExpression(doc, child));
88
+ switch(operator){
89
+ case '$subtract':
90
+ return operands[0] - operands[1];
91
+ case '$add':
92
+ return operands[0] + operands[1];
93
+ case '$gte':
94
+ return compare(operands[0], operands[1]) >= 0;
95
+ case '$gt':
96
+ return compare(operands[0], operands[1]) > 0;
97
+ case '$lte':
98
+ return compare(operands[0], operands[1]) <= 0;
99
+ case '$lt':
100
+ return compare(operands[0], operands[1]) < 0;
101
+ case '$eq':
102
+ return operands[0] === operands[1];
103
+ default:
104
+ throw new TypeError(`fake mongo: unsupported $expr operator "${operator}"`);
105
+ }
106
+ };
107
+ /**
108
+ * Every value a field path names, the way the server reads one.
109
+ *
110
+ * A dotted path descends, and **an array on the way down is walked
111
+ * element-wise** — which is what makes `{'items.vendorId': x}` match an order
112
+ * with a line for that vendor rather than nothing at all. Without it a query
113
+ * into an embedded collection silently answers empty here while working against
114
+ * a real server, which is worse than an unsupported-operator throw: the spec
115
+ * passes and asserts the opposite of what it claims.
116
+ *
117
+ * The array itself is kept as a candidate beside its elements, because the
118
+ * server matches both — `{ tags: 'a' }` matches `{ tags: ['a'] }` and
119
+ * `{ tags: 'a' }` alike.
120
+ */ const valuesAt = (doc, field)=>{
121
+ let current = [
122
+ doc
123
+ ];
124
+ for (const segment of field.split('.')){
125
+ const next = [];
126
+ for (const value of current){
127
+ const container = Array.isArray(value) ? value : [
128
+ value
129
+ ];
130
+ for (const element of container){
131
+ if (isPlainObject(element)) {
132
+ next.push(element[segment]);
133
+ }
134
+ }
135
+ }
136
+ current = next;
137
+ }
138
+ return current.flatMap((value)=>Array.isArray(value) ? [
139
+ value,
140
+ ...value
141
+ ] : [
142
+ value
143
+ ]);
144
+ };
145
+ /** Operators that exclude a document when a single value matches them. */ const NEGATIVE_OPERATORS = new Set([
146
+ '$ne',
147
+ '$nin',
148
+ '$not'
149
+ ]);
150
+ const isNegativeCondition = (condition)=>isPlainObject(condition) && Object.keys(condition).some((operator)=>NEGATIVE_OPERATORS.has(operator));
151
+ const matches = (doc, query)=>Object.entries(query).every(([field, condition])=>{
152
+ if (field === '$and') {
153
+ return condition.every((child)=>matches(doc, child));
154
+ }
155
+ if (field === '$or') {
156
+ return condition.some((child)=>matches(doc, child));
157
+ }
158
+ if (field === '$expr') {
159
+ return evaluateExpression(doc, condition) === true;
160
+ }
161
+ const candidates = valuesAt(doc, field);
162
+ if (candidates.length === 0) {
163
+ return matchesCondition(undefined, condition);
164
+ }
165
+ // A positive condition matches when **any** value at the path satisfies it;
166
+ // a negative one only when **every** value does. That asymmetry is the
167
+ // server's: `{ 'items.vendorId': { $ne: 'a' } }` must exclude an order that
168
+ // has a line for `a`, not include it because some other line differs.
169
+ return isNegativeCondition(condition) ? candidates.every((value)=>matchesCondition(value, condition)) : candidates.some((value)=>matchesCondition(value, condition));
170
+ });
171
+ /**
172
+ * The error a real server raises when a write violates a unique index.
173
+ *
174
+ * The `code` matters and the message does not: callers recognise a duplicate
175
+ * key by `error.code === 11000` (or the same code inside `writeErrors`), and a
176
+ * fake that threw a plain `Error` would send a retry path that exists precisely
177
+ * for this case down the "unexpected failure" branch instead.
178
+ */ let FakeDuplicateKeyError = class FakeDuplicateKeyError extends Error {
179
+ constructor(collection, key){
180
+ super(`fake mongo: E11000 duplicate key error collection: ${collection} dup key: ${JSON.stringify(key)}`), this.code = 11000;
181
+ this.name = 'MongoServerError';
182
+ }
183
+ };
184
+ /**
185
+ * Builds an in-memory stand-in for a mongodb `Db`.
186
+ *
187
+ * `db` is typed `unknown` on purpose: casting it at the call site keeps this
188
+ * package free of a hard dependency on the `mongodb` types while remaining
189
+ * honest that it is not a real driver.
190
+ */ export const makeFakeMongoDb = (seed = {})=>{
191
+ const collections = new Map(Object.entries(seed).map(([name, docs])=>[
192
+ name,
193
+ docs.map((d)=>_extends({}, d))
194
+ ]));
195
+ const operations = [];
196
+ let failure;
197
+ const failuresByOperation = new Map();
198
+ const documentsOf = (name)=>{
199
+ if (!collections.has(name)) collections.set(name, []);
200
+ return collections.get(name);
201
+ };
202
+ /**
203
+ * The unique indexes `createIndex` has been asked for, per collection, as the
204
+ * field lists they key on.
205
+ *
206
+ * Recording them is not bookkeeping: `updateOne` with `upsert` is atomic per
207
+ * *document*, so two concurrent upserts for the same key each fail to see the
208
+ * other's uncommitted insert and each create a row. The index is what makes
209
+ * the fold singular, and a fake that accepted `createIndex` as a no-op would
210
+ * report a service as covered while the property the index exists for went
211
+ * untested (ADR 0010).
212
+ */ const uniqueIndexes = new Map();
213
+ const uniqueIndexesOf = (name)=>{
214
+ var _uniqueIndexes_get;
215
+ return (_uniqueIndexes_get = uniqueIndexes.get(name)) != null ? _uniqueIndexes_get : [];
216
+ };
217
+ /**
218
+ * Refuses `candidate` if it would duplicate an existing document under one of
219
+ * the collection's unique indexes.
220
+ *
221
+ * `ignored` is the document being replaced by an update, which must not count
222
+ * as its own duplicate. A key whose value is `undefined` on the candidate is
223
+ * skipped: a real index treats a missing field as `null` and would collide,
224
+ * but nothing in this repo indexes an optional member, and guessing wrong
225
+ * here would fail writes the server accepts.
226
+ */ const assertUnique = (name, candidate, ignored)=>{
227
+ for (const fields of uniqueIndexesOf(name)){
228
+ if (fields.some((field)=>candidate[field] === undefined)) continue;
229
+ const clash = documentsOf(name).find((doc)=>doc !== ignored && fields.every((field)=>doc[field] === candidate[field]));
230
+ if (clash !== undefined) {
231
+ throw new FakeDuplicateKeyError(name, Object.fromEntries(fields.map((field)=>[
232
+ field,
233
+ candidate[field]
234
+ ])));
235
+ }
236
+ }
237
+ };
238
+ const record = (name, op, produce)=>{
239
+ operations.push({
240
+ collection: name,
241
+ op
242
+ });
243
+ const scoped = failuresByOperation.get(op);
244
+ if (scoped !== undefined) return Promise.reject(scoped);
245
+ if (failure !== undefined) return Promise.reject(failure);
246
+ // `try`/`catch` rather than `Promise.resolve(produce())`: a real driver
247
+ // returns a promise and *rejects*, so a fake that threw synchronously would
248
+ // need different handling at every call site than the thing it stands in for.
249
+ try {
250
+ return Promise.resolve(produce());
251
+ } catch (error) {
252
+ return Promise.reject(error);
253
+ }
254
+ };
255
+ /** Applies `{ projection: { _id: 0 } }`, the only projection the adapter uses. */ const project = (doc, options)=>{
256
+ const copy = _extends({}, doc);
257
+ if ((options == null ? void 0 : options.projection) && options.projection['_id'] === 0) {
258
+ delete copy['_id'];
259
+ }
260
+ return copy;
261
+ };
262
+ const collection = (name)=>({
263
+ find: (query = {}, options)=>{
264
+ let sortSpec = {};
265
+ let skipCount = 0;
266
+ let limitCount = Infinity;
267
+ const cursor = {
268
+ sort (spec) {
269
+ sortSpec = spec;
270
+ return cursor;
271
+ },
272
+ skip (count) {
273
+ skipCount = count;
274
+ return cursor;
275
+ },
276
+ limit (count) {
277
+ limitCount = count;
278
+ return cursor;
279
+ },
280
+ toArray: ()=>record(name, 'find', ()=>{
281
+ const matched = documentsOf(name).filter((doc)=>matches(doc, query));
282
+ const sorted = Object.entries(sortSpec).reduceRight((items, [field, direction])=>[
283
+ ...items
284
+ ].sort((left, right)=>compare(left[field], right[field]) * direction), matched);
285
+ return sorted.slice(skipCount, skipCount + limitCount).map((doc)=>project(doc, options));
286
+ })
287
+ };
288
+ return cursor;
289
+ },
290
+ findOne: (query, options)=>record(name, 'findOne', ()=>{
291
+ const found = documentsOf(name).find((doc)=>matches(doc, query));
292
+ return found === undefined ? null : project(found, options);
293
+ }),
294
+ countDocuments: (query = {})=>record(name, 'countDocuments', ()=>documentsOf(name).filter((doc)=>matches(doc, query)).length),
295
+ // Used by the services' own seeding, which the e2e `mock` profile runs for
296
+ // real so both profiles serve the same catalog.
297
+ insertMany: (documents)=>record(name, 'insertMany', ()=>{
298
+ const stored = documentsOf(name);
299
+ for (const document of documents){
300
+ assertUnique(name, document);
301
+ stored.push(_extends({}, document));
302
+ }
303
+ return {
304
+ insertedCount: documents.length
305
+ };
306
+ }),
307
+ /**
308
+ * A single insert — how an append-only ledger grows, and how a hold is
309
+ * written beside the counter it moved.
310
+ *
311
+ * The options argument is accepted and ignored except for its `session`,
312
+ * which the transaction wrapper already handles: a caller that passes one
313
+ * is inside `withTransaction`, and this fake's rollback is a snapshot of
314
+ * the whole store rather than anything per-operation.
315
+ */ insertOne: (document)=>record(name, 'insertOne', ()=>{
316
+ var _document_id;
317
+ assertUnique(name, document);
318
+ documentsOf(name).push(_extends({}, document));
319
+ return {
320
+ acknowledged: true,
321
+ insertedId: (_document_id = document['id']) != null ? _document_id : null
322
+ };
323
+ }),
324
+ replaceOne: (query, replacement, options)=>record(name, 'replaceOne', ()=>{
325
+ const documents = documentsOf(name);
326
+ const index = documents.findIndex((doc)=>matches(doc, query));
327
+ if (index === -1) {
328
+ if (!(options == null ? void 0 : options.upsert)) return {
329
+ matchedCount: 0,
330
+ upsertedCount: 0
331
+ };
332
+ documents.push(_extends({}, replacement));
333
+ return {
334
+ matchedCount: 0,
335
+ upsertedCount: 1
336
+ };
337
+ }
338
+ documents[index] = _extends({}, replacement);
339
+ return {
340
+ matchedCount: 1,
341
+ upsertedCount: 0
342
+ };
343
+ }),
344
+ /**
345
+ * Partial update. `$set`, `$addToSet`, `$inc` and `$setOnInsert` are
346
+ * implemented — the operators the adapters and the stock ledger use — and
347
+ * **anything else throws**, because a fake that silently accepted `$unset`
348
+ * without applying it would let a spec pass on an update that never
349
+ * happened.
350
+ *
351
+ * `$addToSet` is what links an identifier onto `user-identity.identifiers`
352
+ * without duplicating it on a repair run; supporting it here rather than
353
+ * ignoring it is the difference between the e2e proving the link and merely
354
+ * not noticing its absence.
355
+ *
356
+ * `$inc` with `upsert` is how a quantity moves. It matters that it is a
357
+ * real increment and not a read-modify-write even here: the whole rule it
358
+ * implements is that an absolute-value write loses updates, so a fake that
359
+ * computed the new total from a value it had already read would model the
360
+ * bug rather than the fix.
361
+ */ updateOne: (query, update, options)=>record(name, 'updateOne', ()=>{
362
+ var _update_$setOnInsert;
363
+ const supported = [
364
+ '$set',
365
+ '$addToSet',
366
+ '$inc',
367
+ '$setOnInsert'
368
+ ];
369
+ const unsupported = Object.keys(update).filter((operator)=>!supported.includes(operator));
370
+ if (unsupported.length > 0) {
371
+ throw new Error(`fake-mongo: updateOne does not implement ${unsupported.join(', ')}`);
372
+ }
373
+ // A real server rejects an update whose operators both name one field,
374
+ // and the message is the one this repro'd against: `$setOnInsert` that
375
+ // also named a field `$inc` touches. Reproducing the refusal is what
376
+ // keeps the fake from blessing an update Mongo would refuse.
377
+ const conflicting = Object.keys((_update_$setOnInsert = update.$setOnInsert) != null ? _update_$setOnInsert : {}).filter((field)=>{
378
+ var _update_$inc, _update_$set;
379
+ return field in ((_update_$inc = update.$inc) != null ? _update_$inc : {}) || field in ((_update_$set = update.$set) != null ? _update_$set : {});
380
+ });
381
+ if (conflicting.length > 0) {
382
+ throw new Error(`fake-mongo: updateOne has conflicting paths in $setOnInsert and $inc/$set: ${conflicting.join(', ')}`);
383
+ }
384
+ /** `$set`, `$addToSet` and `$inc`, which apply on both branches. */ const applyOperators = (base)=>{
385
+ var _update_$set, _update_$addToSet, _update_$inc;
386
+ const updated = _extends({}, base, (_update_$set = update.$set) != null ? _update_$set : {});
387
+ for (const [field, value] of Object.entries((_update_$addToSet = update.$addToSet) != null ? _update_$addToSet : {})){
388
+ const current = updated[field];
389
+ const array = Array.isArray(current) ? current : [];
390
+ if (!array.includes(value)) {
391
+ updated[field] = [
392
+ ...array,
393
+ value
394
+ ];
395
+ }
396
+ }
397
+ for (const [field, delta] of Object.entries((_update_$inc = update.$inc) != null ? _update_$inc : {})){
398
+ // An absent field is created at the increment's value, which is
399
+ // exactly why `$setOnInsert` must not also name it.
400
+ const current = updated[field];
401
+ updated[field] = (typeof current === 'number' ? current : 0) + delta;
402
+ }
403
+ return updated;
404
+ };
405
+ const documents = documentsOf(name);
406
+ const index = documents.findIndex((doc)=>matches(doc, query));
407
+ if (index === -1) {
408
+ var _update_$setOnInsert1;
409
+ if ((options == null ? void 0 : options.upsert) !== true) {
410
+ return {
411
+ matchedCount: 0,
412
+ modifiedCount: 0,
413
+ upsertedCount: 0
414
+ };
415
+ }
416
+ // The upserted document starts from the query's *equality* fields,
417
+ // the way a real server seeds one — an operator condition contributes
418
+ // nothing, which is why a `$expr`-guarded upsert would insert a row
419
+ // missing the very field it filtered on.
420
+ const seedFields = Object.fromEntries(Object.entries(query).filter(([field, condition])=>!field.startsWith('$') && !isPlainObject(condition)));
421
+ const inserted = applyOperators(_extends({}, seedFields, (_update_$setOnInsert1 = update.$setOnInsert) != null ? _update_$setOnInsert1 : {}));
422
+ assertUnique(name, inserted);
423
+ documents.push(inserted);
424
+ return {
425
+ matchedCount: 0,
426
+ modifiedCount: 0,
427
+ upsertedCount: 1
428
+ };
429
+ }
430
+ const updated = applyOperators(documents[index]);
431
+ assertUnique(name, updated, documents[index]);
432
+ documents[index] = updated;
433
+ return {
434
+ matchedCount: 1,
435
+ modifiedCount: 1,
436
+ upsertedCount: 0
437
+ };
438
+ }),
439
+ /**
440
+ * `$set` across every matching document, and the count of what changed.
441
+ *
442
+ * ⚠️ **The count is what a caller branches on.** A settlement run claims its
443
+ * ledger lines with this and reads `modifiedCount === 0` as "another replica
444
+ * took them all" — so a fake returning a fixed `1`, or the match count
445
+ * rather than the modified count, would make the losing replica behave like
446
+ * the winning one and the conditional claim would test nothing.
447
+ *
448
+ * `$set` only. Anything else throws, for the reason `updateOne` gives:
449
+ * silently accepting an operator it does not apply lets a spec pass on an
450
+ * update that never happened.
451
+ */ updateMany: (query, update)=>record(name, 'updateMany', ()=>{
452
+ const unsupported = Object.keys(update).filter((operator)=>operator !== '$set');
453
+ if (unsupported.length > 0) {
454
+ throw new Error(`fake-mongo: updateMany does not implement ${unsupported.join(', ')}`);
455
+ }
456
+ const documents = documentsOf(name);
457
+ let modifiedCount = 0;
458
+ documents.forEach((doc, index)=>{
459
+ var _update_$set;
460
+ if (!matches(doc, query)) return;
461
+ const updated = _extends({}, doc, (_update_$set = update.$set) != null ? _update_$set : {});
462
+ assertUnique(name, updated, doc);
463
+ documents[index] = updated;
464
+ modifiedCount += 1;
465
+ });
466
+ return {
467
+ matchedCount: modifiedCount,
468
+ modifiedCount
469
+ };
470
+ }),
471
+ /**
472
+ * Update one document and hand back the result, upserting when asked.
473
+ *
474
+ * ⚠️ **`returnDocument: 'after'` is the only mode implemented, and the
475
+ * default here is `'after'` rather than the driver's `'before'`** — because
476
+ * the callers that need this primitive at all need the merged document. A
477
+ * two-message join upserts its own half and then asks "is the pair complete
478
+ * now?", which is a question only the *after* state answers. `'before'`
479
+ * throws rather than quietly returning the wrong half.
480
+ *
481
+ * Implemented over {@link updateOne} so the upsert seeding rule, the
482
+ * conflicting-path refusal and the unique-index enforcement are the same
483
+ * ones, rather than a second copy that could drift from them.
484
+ */ findOneAndUpdate: (query, update, options)=>record(name, 'findOneAndUpdate', async ()=>{
485
+ if ((options == null ? void 0 : options.returnDocument) === 'before') {
486
+ throw new Error('fake-mongo: findOneAndUpdate only implements returnDocument: "after"');
487
+ }
488
+ // Where the document is *before* the write, because afterwards the
489
+ // query may no longer find it: an upsert's operator conditions need not
490
+ // hold of the row it just inserted — `{ folded: { $exists: false } }`
491
+ // matches nothing once `folded` has been set. `documentsOf` hands back
492
+ // the live array, which `updateOne` mutates in place or pushes onto, so
493
+ // the position is enough to identify it either way.
494
+ const documents = documentsOf(name);
495
+ const index = documents.findIndex((doc)=>matches(doc, query));
496
+ const result = await collection(name).updateOne(query, update, {
497
+ upsert: (options == null ? void 0 : options.upsert) === true
498
+ });
499
+ if (result.matchedCount === 0 && result.upsertedCount === 0) {
500
+ return null;
501
+ }
502
+ return index === -1 ? documents[documents.length - 1] : documents[index];
503
+ }),
504
+ deleteOne: (query)=>record(name, 'deleteOne', ()=>{
505
+ const documents = documentsOf(name);
506
+ const index = documents.findIndex((doc)=>matches(doc, query));
507
+ if (index === -1) return {
508
+ deletedCount: 0
509
+ };
510
+ documents.splice(index, 1);
511
+ return {
512
+ deletedCount: 1
513
+ };
514
+ }),
515
+ /**
516
+ * Declares an index, and **enforces it when it is unique**.
517
+ *
518
+ * Idempotent, like the real one: a service that ensures its index per
519
+ * request rather than at boot — because a tenant database only appears on
520
+ * first write — calls this on every write.
521
+ */ createIndex: (spec, options)=>record(name, 'createIndex', ()=>{
522
+ if ((options == null ? void 0 : options['unique']) === true) {
523
+ var _uniqueIndexes_get;
524
+ const fields = Object.keys(spec);
525
+ const existing = (_uniqueIndexes_get = uniqueIndexes.get(name)) != null ? _uniqueIndexes_get : [];
526
+ const already = existing.some((candidate)=>candidate.length === fields.length && candidate.every((field, at)=>field === fields[at]));
527
+ if (!already) {
528
+ uniqueIndexes.set(name, [
529
+ ...existing,
530
+ fields
531
+ ]);
532
+ }
533
+ }
534
+ return {
535
+ spec,
536
+ options
537
+ };
538
+ })
539
+ });
540
+ /**
541
+ * A session whose `withTransaction` really rolls back.
542
+ *
543
+ * ⚠️ **It is atomicity, not isolation, and the difference decides where a
544
+ * test may live.** The store is a single-threaded object graph, so nothing
545
+ * runs concurrently with the body and a "race" here resolves in the order it
546
+ * was written: a concurrency property — two writers contending for the last
547
+ * unit — is green under this fake no matter what the code does, and belongs
548
+ * in a `*.live.spec.ts` against real Mongo (ADR 0010).
549
+ *
550
+ * What it *does* model is the abort path, which is worth having: a body that
551
+ * throws leaves the store exactly as it found it, so a conditional write that
552
+ * moves a counter and then fails to write its row cannot leave the counter
553
+ * moved. That is a real bug shape, and one a fake without rollback would hide
554
+ * by committing half of it.
555
+ */ const startSession = ()=>{
556
+ let open = true;
557
+ return {
558
+ withTransaction: async (body)=>{
559
+ const snapshot = new Map([
560
+ ...collections
561
+ ].map(([name, docs])=>[
562
+ name,
563
+ docs.map((doc)=>_extends({}, doc))
564
+ ]));
565
+ try {
566
+ return await body();
567
+ } catch (error) {
568
+ collections.clear();
569
+ for (const [name, docs] of snapshot)collections.set(name, docs);
570
+ throw error;
571
+ }
572
+ },
573
+ endSession: ()=>{
574
+ open = false;
575
+ return Promise.resolve();
576
+ },
577
+ get hasEnded () {
578
+ return !open;
579
+ }
580
+ };
581
+ };
582
+ return {
583
+ seed: (name, documents)=>{
584
+ collections.set(name, documents.map((doc)=>_extends({}, doc)));
585
+ },
586
+ read: (name)=>documentsOf(name).map((doc)=>_extends({}, doc)),
587
+ failWith: (error)=>{
588
+ failure = error;
589
+ },
590
+ failOn: (operation, error)=>{
591
+ failuresByOperation.set(operation, error);
592
+ },
593
+ get operations () {
594
+ return operations;
595
+ },
596
+ startSession,
597
+ db: {
598
+ collection
599
+ }
600
+ };
601
+ };
602
+
603
+ //# sourceMappingURL=fake-mongo.js.map