@entifix/testing-unit 0.1.2 → 0.1.4
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/README.md +3 -0
- package/dist/contracts/entity-repository.contract.d.ts +14 -3
- package/dist/contracts/entity-repository.contract.d.ts.map +1 -1
- package/dist/contracts/entity-repository.contract.js +116 -31
- package/dist/contracts/entity-repository.contract.js.map +1 -1
- package/dist/doubles/entity-repository.d.ts.map +1 -1
- package/dist/doubles/entity-repository.js +15 -4
- package/dist/doubles/entity-repository.js.map +1 -1
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -49,6 +49,9 @@ describeEntityRepositoryContract('in-memory fake', { makeRepository: seed => mak
|
|
|
49
49
|
describeEntityRepositoryContract('mongo adapter over a fake driver', { makeRepository: seed => makeMongoRepository(fakeDb, Widget) });
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
A read-only adapter runs `describeEntityRepositoryReadContract` alone;
|
|
53
|
+
`describeEntityRepositoryContract` is it and `describeEntityRepositoryWriteContract`.
|
|
54
|
+
|
|
52
55
|
This is what keeps a fake from quietly becoming a more forgiving version of the
|
|
53
56
|
thing it stands in for. `@entifix/testing-integration` will later run
|
|
54
57
|
the same suites against real infrastructure.
|
|
@@ -13,8 +13,10 @@ export declare class ContractWidget implements Entity {
|
|
|
13
13
|
set name(value: string | undefined);
|
|
14
14
|
get size(): number | undefined;
|
|
15
15
|
set size(value: number | undefined);
|
|
16
|
+
get tags(): readonly string[];
|
|
17
|
+
set tags(value: readonly string[]);
|
|
16
18
|
}
|
|
17
|
-
export declare const makeContractWidget: (id: EntityId, name: string, size: number) => ContractWidget;
|
|
19
|
+
export declare const makeContractWidget: (id: EntityId, name: string, size: number, tags?: readonly string[]) => ContractWidget;
|
|
18
20
|
export interface EntityRepositoryContractOptions {
|
|
19
21
|
/**
|
|
20
22
|
* Builds a repository over `seed`. Called fresh for each test, so no state
|
|
@@ -22,9 +24,18 @@ export interface EntityRepositoryContractOptions {
|
|
|
22
24
|
*/
|
|
23
25
|
makeRepository(seed: ContractWidget[]): EntityRepository | Promise<EntityRepository>;
|
|
24
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* The reads every {@link EntityRepository} must answer alike: `load` with its
|
|
29
|
+
* paging, filtering and sorting, and `get`. A read-only adapter — one over
|
|
30
|
+
* static files, say — runs this half alone, since it refuses `save` and
|
|
31
|
+
* `delete` by design.
|
|
32
|
+
*/
|
|
33
|
+
export declare const describeEntityRepositoryReadContract: (name: string, { makeRepository }: EntityRepositoryContractOptions) => void;
|
|
34
|
+
/** The writes every {@link EntityRepository} that accepts them must honour. */
|
|
35
|
+
export declare const describeEntityRepositoryWriteContract: (name: string, { makeRepository }: EntityRepositoryContractOptions) => void;
|
|
25
36
|
/**
|
|
26
37
|
* The behaviour every {@link EntityRepository} must exhibit, regardless of what
|
|
27
|
-
* backs it.
|
|
38
|
+
* backs it: both halves.
|
|
28
39
|
*
|
|
29
40
|
* Run it against the in-memory fake *and* against each real adapter. That is
|
|
30
41
|
* what stops a fake from drifting into a more forgiving version of the thing it
|
|
@@ -32,5 +43,5 @@ export interface EntityRepositoryContractOptions {
|
|
|
32
43
|
* not. `entifix-ts-testing-integration` will run this same suite against real
|
|
33
44
|
* infrastructure later.
|
|
34
45
|
*/
|
|
35
|
-
export declare const describeEntityRepositoryContract: (name: string,
|
|
46
|
+
export declare const describeEntityRepositoryContract: (name: string, options: EntityRepositoryContractOptions) => void;
|
|
36
47
|
//# sourceMappingURL=entity-repository.contract.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"entity-repository.contract.d.ts","sourceRoot":"","sources":["../../src/contracts/entity-repository.contract.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAC1D,OAAO,EAAY,KAAK,MAAM,EAAU,KAAK,QAAQ,EAAE,MAAM,eAAe,CAAC;AAM7E;;;;GAIG;AACH,qBACa,cAAe,YAAW,MAAM;;
|
|
1
|
+
{"version":3,"file":"entity-repository.contract.d.ts","sourceRoot":"","sources":["../../src/contracts/entity-repository.contract.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAC1D,OAAO,EAAY,KAAK,MAAM,EAAU,KAAK,QAAQ,EAAE,MAAM,eAAe,CAAC;AAM7E;;;;GAIG;AACH,qBACa,cAAe,YAAW,MAAM;;IAM3C,IACI,EAAE,IAAI,QAAQ,CAEjB;IACD,IAAI,EAAE,CAAC,KAAK,EAAE,QAAQ,EAErB;IAED,IACI,IAAI,IAAI,MAAM,GAAG,SAAS,CAE7B;IACD,IAAI,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAEjC;IAED,IACI,IAAI,IAAI,MAAM,GAAG,SAAS,CAE7B;IACD,IAAI,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAEjC;IAED,IACI,IAAI,IAAI,SAAS,MAAM,EAAE,CAE5B;IACD,IAAI,IAAI,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,EAEhC;CACF;AAED,eAAO,MAAM,kBAAkB,GAC7B,IAAI,QAAQ,EACZ,MAAM,MAAM,EACZ,MAAM,MAAM,EACZ,OAAM,SAAS,MAAM,EAAO,KAC3B,cAOF,CAAC;AAEF,MAAM,WAAW,+BAA+B;IAC9C;;;OAGG;IACH,cAAc,CACZ,IAAI,EAAE,cAAc,EAAE,GACrB,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;CACjD;AAED;;;;;GAKG;AACH,eAAO,MAAM,oCAAoC,GAC/C,MAAM,MAAM,EACZ,oBAAoB,+BAA+B,KAClD,IAsLF,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,qCAAqC,GAChD,MAAM,MAAM,EACZ,oBAAoB,+BAA+B,KAClD,IA8FF,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,gCAAgC,GAC3C,MAAM,MAAM,EACZ,SAAS,+BAA+B,KACvC,IAGF,CAAC"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { _ as _class_private_field_loose_base } from "@swc/helpers/_/_class_private_field_loose_base";
|
|
2
2
|
import { _ as _class_private_field_loose_key } from "@swc/helpers/_/_class_private_field_loose_key";
|
|
3
3
|
import { _ as _apply_decs_2203_r } from "@swc/helpers/_/_apply_decs_2203_r";
|
|
4
|
-
var _dec, _initClass, _dec1, _dec2, _dec3, _initProto;
|
|
4
|
+
var _dec, _initClass, _dec1, _dec2, _dec3, _dec4, _initProto;
|
|
5
5
|
import { accessor, entity } from '@entifix/core';
|
|
6
6
|
import { Exit } from 'effect';
|
|
7
7
|
import { describe, expect, it } from 'vitest';
|
|
@@ -14,9 +14,11 @@ _dec = entity({
|
|
|
14
14
|
type: 'string'
|
|
15
15
|
}), _dec3 = accessor({
|
|
16
16
|
type: 'number'
|
|
17
|
+
}), _dec4 = accessor({
|
|
18
|
+
type: 'scalarCollection'
|
|
17
19
|
});
|
|
18
20
|
let _ContractWidget;
|
|
19
|
-
var _id = /*#__PURE__*/ _class_private_field_loose_key("_id"), _name = /*#__PURE__*/ _class_private_field_loose_key("_name"), _size = /*#__PURE__*/ _class_private_field_loose_key("_size");
|
|
21
|
+
var _id = /*#__PURE__*/ _class_private_field_loose_key("_id"), _name = /*#__PURE__*/ _class_private_field_loose_key("_name"), _size = /*#__PURE__*/ _class_private_field_loose_key("_size"), _tags = /*#__PURE__*/ _class_private_field_loose_key("_tags");
|
|
20
22
|
let ContractWidget = class ContractWidget {
|
|
21
23
|
get id() {
|
|
22
24
|
return _class_private_field_loose_base(this, _id)[_id];
|
|
@@ -36,6 +38,12 @@ let ContractWidget = class ContractWidget {
|
|
|
36
38
|
set size(value) {
|
|
37
39
|
_class_private_field_loose_base(this, _size)[_size] = value;
|
|
38
40
|
}
|
|
41
|
+
get tags() {
|
|
42
|
+
return _class_private_field_loose_base(this, _tags)[_tags];
|
|
43
|
+
}
|
|
44
|
+
set tags(value) {
|
|
45
|
+
_class_private_field_loose_base(this, _tags)[_tags] = value;
|
|
46
|
+
}
|
|
39
47
|
constructor(){
|
|
40
48
|
Object.defineProperty(this, _id, {
|
|
41
49
|
writable: true,
|
|
@@ -49,7 +57,11 @@ let ContractWidget = class ContractWidget {
|
|
|
49
57
|
writable: true,
|
|
50
58
|
value: void 0
|
|
51
59
|
});
|
|
52
|
-
|
|
60
|
+
Object.defineProperty(this, _tags, {
|
|
61
|
+
writable: true,
|
|
62
|
+
value: void 0
|
|
63
|
+
});
|
|
64
|
+
_class_private_field_loose_base(this, _tags)[_tags] = (_initProto(this), []);
|
|
53
65
|
}
|
|
54
66
|
};
|
|
55
67
|
({ e: [_initProto], c: [_ContractWidget, _initClass] } = _apply_decs_2203_r(ContractWidget, [
|
|
@@ -67,29 +79,31 @@ let ContractWidget = class ContractWidget {
|
|
|
67
79
|
_dec3,
|
|
68
80
|
3,
|
|
69
81
|
"size"
|
|
82
|
+
],
|
|
83
|
+
[
|
|
84
|
+
_dec4,
|
|
85
|
+
3,
|
|
86
|
+
"tags"
|
|
70
87
|
]
|
|
71
88
|
], [
|
|
72
89
|
_dec
|
|
73
90
|
]));
|
|
74
91
|
_initClass();
|
|
75
|
-
export const makeContractWidget = (id, name, size)=>{
|
|
92
|
+
export const makeContractWidget = (id, name, size, tags = [])=>{
|
|
76
93
|
const widget = new _ContractWidget();
|
|
77
94
|
widget.id = id;
|
|
78
95
|
widget.name = name;
|
|
79
96
|
widget.size = size;
|
|
97
|
+
widget.tags = tags;
|
|
80
98
|
return widget;
|
|
81
99
|
};
|
|
82
100
|
/**
|
|
83
|
-
* The
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
* not. `entifix-ts-testing-integration` will run this same suite against real
|
|
90
|
-
* infrastructure later.
|
|
91
|
-
*/ export const describeEntityRepositoryContract = (name, { makeRepository })=>{
|
|
92
|
-
describe(`EntityRepository contract: ${name}`, ()=>{
|
|
101
|
+
* The reads every {@link EntityRepository} must answer alike: `load` with its
|
|
102
|
+
* paging, filtering and sorting, and `get`. A read-only adapter — one over
|
|
103
|
+
* static files, say — runs this half alone, since it refuses `save` and
|
|
104
|
+
* `delete` by design.
|
|
105
|
+
*/ export const describeEntityRepositoryReadContract = (name, { makeRepository })=>{
|
|
106
|
+
describe(`EntityRepository read contract: ${name}`, ()=>{
|
|
93
107
|
const seeded = ()=>[
|
|
94
108
|
makeContractWidget('w-1', 'Alpha', 10),
|
|
95
109
|
makeContractWidget('w-2', 'Beta', 20),
|
|
@@ -110,16 +124,6 @@ export const makeContractWidget = (id, name, size)=>{
|
|
|
110
124
|
expect(reloaded.items[0].name).toBe('Alpha');
|
|
111
125
|
});
|
|
112
126
|
});
|
|
113
|
-
it('keeps a saved record unchanged when the saved instance is mutated afterwards', async ()=>{
|
|
114
|
-
await withRepository(async (repository)=>{
|
|
115
|
-
const widget = makeContractWidget('w-9', 'Delta', 40);
|
|
116
|
-
const returned = await runRepository(repository.save(widget));
|
|
117
|
-
widget.name = 'Mutated';
|
|
118
|
-
returned.name = 'Mutated too';
|
|
119
|
-
const stored = await runRepository(repository.get('w-9'));
|
|
120
|
-
expect(stored.name).toBe('Delta');
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
127
|
it('loads every stored entity with its total', async ()=>{
|
|
124
128
|
await withRepository(async (repository)=>{
|
|
125
129
|
const page = await runRepository(repository.load({}));
|
|
@@ -205,6 +209,52 @@ export const makeContractWidget = (id, name, size)=>{
|
|
|
205
209
|
]);
|
|
206
210
|
});
|
|
207
211
|
});
|
|
212
|
+
it('matches `in` and `nin` against any element of an array member', async ()=>{
|
|
213
|
+
await withRepository(async (repository)=>{
|
|
214
|
+
const tagged = await runRepository(repository.load({
|
|
215
|
+
filtering: [
|
|
216
|
+
{
|
|
217
|
+
property: 'tags',
|
|
218
|
+
operator: 'in',
|
|
219
|
+
values: [
|
|
220
|
+
'blue',
|
|
221
|
+
'red'
|
|
222
|
+
]
|
|
223
|
+
}
|
|
224
|
+
]
|
|
225
|
+
}));
|
|
226
|
+
expect(tagged.items.map((item)=>item.id)).toEqual([
|
|
227
|
+
'w-1',
|
|
228
|
+
'w-3'
|
|
229
|
+
]);
|
|
230
|
+
const untagged = await runRepository(repository.load({
|
|
231
|
+
filtering: [
|
|
232
|
+
{
|
|
233
|
+
property: 'tags',
|
|
234
|
+
operator: 'nin',
|
|
235
|
+
values: [
|
|
236
|
+
'blue',
|
|
237
|
+
'red'
|
|
238
|
+
]
|
|
239
|
+
}
|
|
240
|
+
]
|
|
241
|
+
}));
|
|
242
|
+
expect(untagged.items.map((item)=>item.id)).toEqual([
|
|
243
|
+
'w-2'
|
|
244
|
+
]);
|
|
245
|
+
}, [
|
|
246
|
+
makeContractWidget('w-1', 'Alpha', 10, [
|
|
247
|
+
'blue',
|
|
248
|
+
'green'
|
|
249
|
+
]),
|
|
250
|
+
makeContractWidget('w-2', 'Beta', 20, [
|
|
251
|
+
'green'
|
|
252
|
+
]),
|
|
253
|
+
makeContractWidget('w-3', 'Gamma', 30, [
|
|
254
|
+
'red'
|
|
255
|
+
])
|
|
256
|
+
]);
|
|
257
|
+
});
|
|
208
258
|
it('sorts descending when asked to', async ()=>{
|
|
209
259
|
await withRepository(async (repository)=>{
|
|
210
260
|
const page = await runRepository(repository.load({
|
|
@@ -237,6 +287,35 @@ export const makeContractWidget = (id, name, size)=>{
|
|
|
237
287
|
expect(Exit.isFailure(exit)).toBe(true);
|
|
238
288
|
});
|
|
239
289
|
});
|
|
290
|
+
it('loads an empty page from an empty store', async ()=>{
|
|
291
|
+
await withRepository(async (repository)=>{
|
|
292
|
+
const page = await runRepository(repository.load({}));
|
|
293
|
+
expect(page.items).toEqual([]);
|
|
294
|
+
expect(page.total).toBe(0);
|
|
295
|
+
}, []);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
};
|
|
299
|
+
/** The writes every {@link EntityRepository} that accepts them must honour. */ export const describeEntityRepositoryWriteContract = (name, { makeRepository })=>{
|
|
300
|
+
describe(`EntityRepository write contract: ${name}`, ()=>{
|
|
301
|
+
const seeded = ()=>[
|
|
302
|
+
makeContractWidget('w-1', 'Alpha', 10),
|
|
303
|
+
makeContractWidget('w-2', 'Beta', 20),
|
|
304
|
+
makeContractWidget('w-3', 'Gamma', 30)
|
|
305
|
+
];
|
|
306
|
+
const withRepository = async (use, seed = seeded())=>{
|
|
307
|
+
await use(await makeRepository(seed));
|
|
308
|
+
};
|
|
309
|
+
it('keeps a saved record unchanged when the saved instance is mutated afterwards', async ()=>{
|
|
310
|
+
await withRepository(async (repository)=>{
|
|
311
|
+
const widget = makeContractWidget('w-9', 'Delta', 40);
|
|
312
|
+
const returned = await runRepository(repository.save(widget));
|
|
313
|
+
widget.name = 'Mutated';
|
|
314
|
+
returned.name = 'Mutated too';
|
|
315
|
+
const stored = await runRepository(repository.get('w-9'));
|
|
316
|
+
expect(stored.name).toBe('Delta');
|
|
317
|
+
});
|
|
318
|
+
});
|
|
240
319
|
it('mints an id when saving an entity that has none', async ()=>{
|
|
241
320
|
await withRepository(async (repository)=>{
|
|
242
321
|
const created = await runRepository(repository.save(makeContractWidget(undefined, 'Delta', 40)));
|
|
@@ -281,15 +360,21 @@ export const makeContractWidget = (id, name, size)=>{
|
|
|
281
360
|
expect(page.total).toBe(3);
|
|
282
361
|
});
|
|
283
362
|
});
|
|
284
|
-
it('loads an empty page from an empty store', async ()=>{
|
|
285
|
-
await withRepository(async (repository)=>{
|
|
286
|
-
const page = await runRepository(repository.load({}));
|
|
287
|
-
expect(page.items).toEqual([]);
|
|
288
|
-
expect(page.total).toBe(0);
|
|
289
|
-
}, []);
|
|
290
|
-
});
|
|
291
363
|
});
|
|
292
364
|
};
|
|
365
|
+
/**
|
|
366
|
+
* The behaviour every {@link EntityRepository} must exhibit, regardless of what
|
|
367
|
+
* backs it: both halves.
|
|
368
|
+
*
|
|
369
|
+
* Run it against the in-memory fake *and* against each real adapter. That is
|
|
370
|
+
* what stops a fake from drifting into a more forgiving version of the thing it
|
|
371
|
+
* stands in for — the failure mode where a suite is green and production is
|
|
372
|
+
* not. `entifix-ts-testing-integration` will run this same suite against real
|
|
373
|
+
* infrastructure later.
|
|
374
|
+
*/ export const describeEntityRepositoryContract = (name, options)=>{
|
|
375
|
+
describeEntityRepositoryReadContract(name, options);
|
|
376
|
+
describeEntityRepositoryWriteContract(name, options);
|
|
377
|
+
};
|
|
293
378
|
export { _ContractWidget as ContractWidget };
|
|
294
379
|
|
|
295
380
|
//# sourceMappingURL=entity-repository.contract.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/contracts/entity-repository.contract.ts"],"sourcesContent":["import type { EntityRepository } from '@entifix/business';\nimport { accessor, type Entity, entity, type EntityId } from '@entifix/core';\nimport { Exit } from 'effect';\nimport { describe, expect, it } from 'vitest';\n\nimport { runRepository, runRepositoryExit } from '../effect/run.js';\n\n/**\n * The entity every implementation of the contract is exercised against. It is\n * declared here rather than by each caller so the suite controls exactly which\n * fields exist.\n */\n@entity({ key: 'contract-widget' })\nexport class ContractWidget implements Entity {\n #id?: EntityId;\n #name?: string;\n #size?: number;\n\n @accessor({ type: 'id' })\n get id(): EntityId {\n return this.#id;\n }\n set id(value: EntityId) {\n this.#id = value;\n }\n\n @accessor({ type: 'string' })\n get name(): string | undefined {\n return this.#name;\n }\n set name(value: string | undefined) {\n this.#name = value;\n }\n\n @accessor({ type: 'number' })\n get size(): number | undefined {\n return this.#size;\n }\n set size(value: number | undefined) {\n this.#size = value;\n }\n}\n\nexport const makeContractWidget = (\n id: EntityId,\n name: string,\n size: number,\n): ContractWidget => {\n const widget = new ContractWidget();\n widget.id = id;\n widget.name = name;\n widget.size = size;\n return widget;\n};\n\nexport interface EntityRepositoryContractOptions {\n /**\n * Builds a repository over `seed`. Called fresh for each test, so no state\n * leaks between them.\n */\n makeRepository(\n seed: ContractWidget[],\n ): EntityRepository | Promise<EntityRepository>;\n}\n\n/**\n * The behaviour every {@link EntityRepository} must exhibit, regardless of what\n * backs it.\n *\n * Run it against the in-memory fake *and* against each real adapter. That is\n * what stops a fake from drifting into a more forgiving version of the thing it\n * stands in for — the failure mode where a suite is green and production is\n * not. `entifix-ts-testing-integration` will run this same suite against real\n * infrastructure later.\n */\nexport const describeEntityRepositoryContract = (\n name: string,\n { makeRepository }: EntityRepositoryContractOptions,\n): void => {\n describe(`EntityRepository contract: ${name}`, () => {\n const seeded = () => [\n makeContractWidget('w-1', 'Alpha', 10),\n makeContractWidget('w-2', 'Beta', 20),\n makeContractWidget('w-3', 'Gamma', 30),\n ];\n\n const withRepository = async (\n use: (repository: EntityRepository) => Promise<void>,\n seed: ContractWidget[] = seeded(),\n ) => {\n await use(await makeRepository(seed));\n };\n\n it('keeps a record unchanged when a read instance is mutated without a save', async () => {\n await withRepository(async repository => {\n const read = await runRepository(repository.get<ContractWidget>('w-1'));\n read.name = 'Mutated';\n\n const again = await runRepository(\n repository.get<ContractWidget>('w-1'),\n );\n expect(again.name).toBe('Alpha');\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n page.items[0]!.name = 'Mutated';\n const reloaded = await runRepository(\n repository.load<ContractWidget>({}),\n );\n expect(reloaded.items[0]!.name).toBe('Alpha');\n });\n });\n\n it('keeps a saved record unchanged when the saved instance is mutated afterwards', async () => {\n await withRepository(async repository => {\n const widget = makeContractWidget('w-9', 'Delta', 40);\n const returned = await runRepository(repository.save(widget));\n\n widget.name = 'Mutated';\n returned.name = 'Mutated too';\n\n const stored = await runRepository(\n repository.get<ContractWidget>('w-9'),\n );\n expect(stored.name).toBe('Delta');\n });\n });\n\n it('loads every stored entity with its total', async () => {\n await withRepository(async repository => {\n const page = await runRepository(repository.load<ContractWidget>({}));\n\n expect(page.total).toBe(3);\n expect(page.items.map(item => item.name)).toEqual([\n 'Alpha',\n 'Beta',\n 'Gamma',\n ]);\n });\n });\n\n it('echoes the request it served on the page', async () => {\n await withRepository(async repository => {\n const request = { page: 1, pageSize: 2 };\n const page = await runRepository(\n repository.load<ContractWidget>(request),\n );\n\n expect(page.request).toEqual(request);\n });\n });\n\n it('pages with a 1-based page number', async () => {\n await withRepository(async repository => {\n const page = await runRepository(\n repository.load<ContractWidget>({ page: 2, pageSize: 2 }),\n );\n\n expect(page.items).toHaveLength(1);\n expect(page.items[0]?.name).toBe('Gamma');\n // The total counts matches, not the page.\n expect(page.total).toBe(3);\n });\n });\n\n it('filters by an equality operator', async () => {\n await withRepository(async repository => {\n const page = await runRepository(\n repository.load<ContractWidget>({\n filtering: [{ property: 'name', operator: 'eq', value: 'Beta' }],\n }),\n );\n\n expect(page.items).toHaveLength(1);\n expect(page.items[0]?.id).toBe('w-2');\n expect(page.total).toBe(1);\n });\n });\n\n it('filters by a range operator', async () => {\n await withRepository(async repository => {\n const page = await runRepository(\n repository.load<ContractWidget>({\n filtering: [\n { property: 'size', operator: 'between', start: 15, end: 35 },\n ],\n }),\n );\n\n expect(page.items.map(item => item.id)).toEqual(['w-2', 'w-3']);\n });\n });\n\n it('matches `like` case-insensitively on a substring', async () => {\n await withRepository(async repository => {\n const page = await runRepository(\n repository.load<ContractWidget>({\n filtering: [{ property: 'name', operator: 'like', value: 'et' }],\n }),\n );\n\n expect(page.items.map(item => item.id)).toEqual(['w-2']);\n });\n });\n\n it('sorts descending when asked to', async () => {\n await withRepository(async repository => {\n const page = await runRepository(\n repository.load<ContractWidget>({\n sorting: [{ 0: { property: 'size', type: 'desc' } }],\n }),\n );\n\n expect(page.items.map(item => item.size)).toEqual([30, 20, 10]);\n });\n });\n\n it('reads a single entity by id', async () => {\n await withRepository(async repository => {\n const found = await runRepository(\n repository.get<ContractWidget>('w-2'),\n );\n\n expect(found.id).toBe('w-2');\n expect(found.name).toBe('Beta');\n });\n });\n\n it('fails rather than resolving undefined for an unknown id', async () => {\n await withRepository(async repository => {\n const exit = await runRepositoryExit(\n repository.get<ContractWidget>('missing'),\n );\n\n expect(Exit.isFailure(exit)).toBe(true);\n });\n });\n\n it('mints an id when saving an entity that has none', async () => {\n await withRepository(async repository => {\n const created = await runRepository(\n repository.save<ContractWidget>(\n makeContractWidget(undefined, 'Delta', 40),\n ),\n );\n\n expect(created.id).toBeDefined();\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n expect(page.total).toBe(4);\n });\n });\n\n it('overwrites in place when saving an entity that already has an id', async () => {\n await withRepository(async repository => {\n await runRepository(\n repository.save<ContractWidget>(\n makeContractWidget('w-2', 'Renamed', 25),\n ),\n );\n\n const found = await runRepository(\n repository.get<ContractWidget>('w-2'),\n );\n expect(found.name).toBe('Renamed');\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n expect(page.total).toBe(3);\n });\n });\n\n it('deletes by id', async () => {\n await withRepository(async repository => {\n await runRepository(repository.delete<ContractWidget>('w-1'));\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n expect(page.items.map(item => item.id)).toEqual(['w-2', 'w-3']);\n });\n });\n\n it('deletes by entity', async () => {\n await withRepository(async repository => {\n await runRepository(\n repository.delete<ContractWidget>(\n makeContractWidget('w-3', 'Gamma', 30),\n ),\n );\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n expect(page.items.map(item => item.id)).toEqual(['w-1', 'w-2']);\n });\n });\n\n it('treats deleting an unknown id as a no-op', async () => {\n await withRepository(async repository => {\n await runRepository(repository.delete<ContractWidget>('missing'));\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n expect(page.total).toBe(3);\n });\n });\n\n it('loads an empty page from an empty store', async () => {\n await withRepository(async repository => {\n const page = await runRepository(repository.load<ContractWidget>({}));\n\n expect(page.items).toEqual([]);\n expect(page.total).toBe(0);\n }, []);\n });\n });\n};\n"],"names":["accessor","entity","Exit","describe","expect","it","runRepository","runRepositoryExit","key","type","ContractWidget","id","value","name","size","makeContractWidget","widget","describeEntityRepositoryContract","makeRepository","seeded","withRepository","use","seed","repository","read","get","again","toBe","page","load","items","reloaded","returned","save","stored","total","map","item","toEqual","request","pageSize","toHaveLength","filtering","property","operator","start","end","sorting","found","exit","isFailure","created","undefined","toBeDefined","delete"],"mappings":";;;;AACA,SAASA,QAAQ,EAAeC,MAAM,QAAuB,gBAAgB;AAC7E,SAASC,IAAI,QAAQ,SAAS;AAC9B,SAASC,QAAQ,EAAEC,MAAM,EAAEC,EAAE,QAAQ,SAAS;AAE9C,SAASC,aAAa,EAAEC,iBAAiB,QAAQ,mBAAmB;OAOnEN,OAAO;IAAEO,KAAK;AAAkB,YAM9BR,SAAS;IAAES,MAAM;AAAK,YAQtBT,SAAS;IAAES,MAAM;AAAS,YAQ1BT,SAAS;IAAES,MAAM;AAAS;;IApB3B,2DACA,+DACA;AAHK,IAAA,AAAMC,iBAAN,MAAMA;IAKX,IACIC,KAAe;QACjB,OAAO,gCAAA,IAAI,EAAC,KAAA;IACd;IACA,IAAIA,GAAGC,KAAe,EAAE;QACtB,gCAAA,IAAI,EAAC,KAAA,OAAMA;IACb;IAEA,IACIC,OAA2B;QAC7B,OAAO,gCAAA,IAAI,EAAC,OAAA;IACd;IACA,IAAIA,KAAKD,KAAyB,EAAE;QAClC,gCAAA,IAAI,EAAC,OAAA,SAAQA;IACf;IAEA,IACIE,OAA2B;QAC7B,OAAO,gCAAA,IAAI,EAAC,OAAA;IACd;IACA,IAAIA,KAAKF,KAAyB,EAAE;QAClC,gCAAA,IAAI,EAAC,OAAA,SAAQA;IACf;;QA1BA,4BAAA;;mBAAA,KAAA;;QACA,4BAAA;;mBAAA,KAAA;;QACA,4BAAA;;mBAAA,KAAA;;;;AAyBF;4EA5BaF;;;;QAMPC;;;;;QAQAE;;;;;QAQAC;;;;;;AAQN,OAAO,MAAMC,qBAAqB,CAChCJ,IACAE,MACAC;IAEA,MAAME,SAAS,IAAIN;IACnBM,OAAOL,EAAE,GAAGA;IACZK,OAAOH,IAAI,GAAGA;IACdG,OAAOF,IAAI,GAAGA;IACd,OAAOE;AACT,EAAE;AAYF;;;;;;;;;CASC,GACD,OAAO,MAAMC,mCAAmC,CAC9CJ,MACA,EAAEK,cAAc,EAAmC;IAEnDf,SAAS,CAAC,2BAA2B,EAAEU,MAAM,EAAE;QAC7C,MAAMM,SAAS,IAAM;gBACnBJ,mBAAmB,OAAO,SAAS;gBACnCA,mBAAmB,OAAO,QAAQ;gBAClCA,mBAAmB,OAAO,SAAS;aACpC;QAED,MAAMK,iBAAiB,OACrBC,KACAC,OAAyBH,QAAQ;YAEjC,MAAME,IAAI,MAAMH,eAAeI;QACjC;QAEAjB,GAAG,2EAA2E;YAC5E,MAAMe,eAAe,OAAMG;gBACzB,MAAMC,OAAO,MAAMlB,cAAciB,WAAWE,GAAG,CAAiB;gBAChED,KAAKX,IAAI,GAAG;gBAEZ,MAAMa,QAAQ,MAAMpB,cAClBiB,WAAWE,GAAG,CAAiB;gBAEjCrB,OAAOsB,MAAMb,IAAI,EAAEc,IAAI,CAAC;gBAExB,MAAMC,OAAO,MAAMtB,cAAciB,WAAWM,IAAI,CAAiB,CAAC;gBAClED,KAAKE,KAAK,CAAC,EAAE,CAAEjB,IAAI,GAAG;gBACtB,MAAMkB,WAAW,MAAMzB,cACrBiB,WAAWM,IAAI,CAAiB,CAAC;gBAEnCzB,OAAO2B,SAASD,KAAK,CAAC,EAAE,CAAEjB,IAAI,EAAEc,IAAI,CAAC;YACvC;QACF;QAEAtB,GAAG,gFAAgF;YACjF,MAAMe,eAAe,OAAMG;gBACzB,MAAMP,SAASD,mBAAmB,OAAO,SAAS;gBAClD,MAAMiB,WAAW,MAAM1B,cAAciB,WAAWU,IAAI,CAACjB;gBAErDA,OAAOH,IAAI,GAAG;gBACdmB,SAASnB,IAAI,GAAG;gBAEhB,MAAMqB,SAAS,MAAM5B,cACnBiB,WAAWE,GAAG,CAAiB;gBAEjCrB,OAAO8B,OAAOrB,IAAI,EAAEc,IAAI,CAAC;YAC3B;QACF;QAEAtB,GAAG,4CAA4C;YAC7C,MAAMe,eAAe,OAAMG;gBACzB,MAAMK,OAAO,MAAMtB,cAAciB,WAAWM,IAAI,CAAiB,CAAC;gBAElEzB,OAAOwB,KAAKO,KAAK,EAAER,IAAI,CAAC;gBACxBvB,OAAOwB,KAAKE,KAAK,CAACM,GAAG,CAACC,CAAAA,OAAQA,KAAKxB,IAAI,GAAGyB,OAAO,CAAC;oBAChD;oBACA;oBACA;iBACD;YACH;QACF;QAEAjC,GAAG,4CAA4C;YAC7C,MAAMe,eAAe,OAAMG;gBACzB,MAAMgB,UAAU;oBAAEX,MAAM;oBAAGY,UAAU;gBAAE;gBACvC,MAAMZ,OAAO,MAAMtB,cACjBiB,WAAWM,IAAI,CAAiBU;gBAGlCnC,OAAOwB,KAAKW,OAAO,EAAED,OAAO,CAACC;YAC/B;QACF;QAEAlC,GAAG,oCAAoC;YACrC,MAAMe,eAAe,OAAMG;oBAMlBK;gBALP,MAAMA,OAAO,MAAMtB,cACjBiB,WAAWM,IAAI,CAAiB;oBAAED,MAAM;oBAAGY,UAAU;gBAAE;gBAGzDpC,OAAOwB,KAAKE,KAAK,EAAEW,YAAY,CAAC;gBAChCrC,QAAOwB,eAAAA,KAAKE,KAAK,CAAC,EAAE,qBAAbF,aAAef,IAAI,EAAEc,IAAI,CAAC;gBACjC,0CAA0C;gBAC1CvB,OAAOwB,KAAKO,KAAK,EAAER,IAAI,CAAC;YAC1B;QACF;QAEAtB,GAAG,mCAAmC;YACpC,MAAMe,eAAe,OAAMG;oBAQlBK;gBAPP,MAAMA,OAAO,MAAMtB,cACjBiB,WAAWM,IAAI,CAAiB;oBAC9Ba,WAAW;wBAAC;4BAAEC,UAAU;4BAAQC,UAAU;4BAAMhC,OAAO;wBAAO;qBAAE;gBAClE;gBAGFR,OAAOwB,KAAKE,KAAK,EAAEW,YAAY,CAAC;gBAChCrC,QAAOwB,eAAAA,KAAKE,KAAK,CAAC,EAAE,qBAAbF,aAAejB,EAAE,EAAEgB,IAAI,CAAC;gBAC/BvB,OAAOwB,KAAKO,KAAK,EAAER,IAAI,CAAC;YAC1B;QACF;QAEAtB,GAAG,+BAA+B;YAChC,MAAMe,eAAe,OAAMG;gBACzB,MAAMK,OAAO,MAAMtB,cACjBiB,WAAWM,IAAI,CAAiB;oBAC9Ba,WAAW;wBACT;4BAAEC,UAAU;4BAAQC,UAAU;4BAAWC,OAAO;4BAAIC,KAAK;wBAAG;qBAC7D;gBACH;gBAGF1C,OAAOwB,KAAKE,KAAK,CAACM,GAAG,CAACC,CAAAA,OAAQA,KAAK1B,EAAE,GAAG2B,OAAO,CAAC;oBAAC;oBAAO;iBAAM;YAChE;QACF;QAEAjC,GAAG,oDAAoD;YACrD,MAAMe,eAAe,OAAMG;gBACzB,MAAMK,OAAO,MAAMtB,cACjBiB,WAAWM,IAAI,CAAiB;oBAC9Ba,WAAW;wBAAC;4BAAEC,UAAU;4BAAQC,UAAU;4BAAQhC,OAAO;wBAAK;qBAAE;gBAClE;gBAGFR,OAAOwB,KAAKE,KAAK,CAACM,GAAG,CAACC,CAAAA,OAAQA,KAAK1B,EAAE,GAAG2B,OAAO,CAAC;oBAAC;iBAAM;YACzD;QACF;QAEAjC,GAAG,kCAAkC;YACnC,MAAMe,eAAe,OAAMG;gBACzB,MAAMK,OAAO,MAAMtB,cACjBiB,WAAWM,IAAI,CAAiB;oBAC9BkB,SAAS;wBAAC;4BAAE,GAAG;gCAAEJ,UAAU;gCAAQlC,MAAM;4BAAO;wBAAE;qBAAE;gBACtD;gBAGFL,OAAOwB,KAAKE,KAAK,CAACM,GAAG,CAACC,CAAAA,OAAQA,KAAKvB,IAAI,GAAGwB,OAAO,CAAC;oBAAC;oBAAI;oBAAI;iBAAG;YAChE;QACF;QAEAjC,GAAG,+BAA+B;YAChC,MAAMe,eAAe,OAAMG;gBACzB,MAAMyB,QAAQ,MAAM1C,cAClBiB,WAAWE,GAAG,CAAiB;gBAGjCrB,OAAO4C,MAAMrC,EAAE,EAAEgB,IAAI,CAAC;gBACtBvB,OAAO4C,MAAMnC,IAAI,EAAEc,IAAI,CAAC;YAC1B;QACF;QAEAtB,GAAG,2DAA2D;YAC5D,MAAMe,eAAe,OAAMG;gBACzB,MAAM0B,OAAO,MAAM1C,kBACjBgB,WAAWE,GAAG,CAAiB;gBAGjCrB,OAAOF,KAAKgD,SAAS,CAACD,OAAOtB,IAAI,CAAC;YACpC;QACF;QAEAtB,GAAG,mDAAmD;YACpD,MAAMe,eAAe,OAAMG;gBACzB,MAAM4B,UAAU,MAAM7C,cACpBiB,WAAWU,IAAI,CACblB,mBAAmBqC,WAAW,SAAS;gBAI3ChD,OAAO+C,QAAQxC,EAAE,EAAE0C,WAAW;gBAE9B,MAAMzB,OAAO,MAAMtB,cAAciB,WAAWM,IAAI,CAAiB,CAAC;gBAClEzB,OAAOwB,KAAKO,KAAK,EAAER,IAAI,CAAC;YAC1B;QACF;QAEAtB,GAAG,oEAAoE;YACrE,MAAMe,eAAe,OAAMG;gBACzB,MAAMjB,cACJiB,WAAWU,IAAI,CACblB,mBAAmB,OAAO,WAAW;gBAIzC,MAAMiC,QAAQ,MAAM1C,cAClBiB,WAAWE,GAAG,CAAiB;gBAEjCrB,OAAO4C,MAAMnC,IAAI,EAAEc,IAAI,CAAC;gBAExB,MAAMC,OAAO,MAAMtB,cAAciB,WAAWM,IAAI,CAAiB,CAAC;gBAClEzB,OAAOwB,KAAKO,KAAK,EAAER,IAAI,CAAC;YAC1B;QACF;QAEAtB,GAAG,iBAAiB;YAClB,MAAMe,eAAe,OAAMG;gBACzB,MAAMjB,cAAciB,WAAW+B,MAAM,CAAiB;gBAEtD,MAAM1B,OAAO,MAAMtB,cAAciB,WAAWM,IAAI,CAAiB,CAAC;gBAClEzB,OAAOwB,KAAKE,KAAK,CAACM,GAAG,CAACC,CAAAA,OAAQA,KAAK1B,EAAE,GAAG2B,OAAO,CAAC;oBAAC;oBAAO;iBAAM;YAChE;QACF;QAEAjC,GAAG,qBAAqB;YACtB,MAAMe,eAAe,OAAMG;gBACzB,MAAMjB,cACJiB,WAAW+B,MAAM,CACfvC,mBAAmB,OAAO,SAAS;gBAIvC,MAAMa,OAAO,MAAMtB,cAAciB,WAAWM,IAAI,CAAiB,CAAC;gBAClEzB,OAAOwB,KAAKE,KAAK,CAACM,GAAG,CAACC,CAAAA,OAAQA,KAAK1B,EAAE,GAAG2B,OAAO,CAAC;oBAAC;oBAAO;iBAAM;YAChE;QACF;QAEAjC,GAAG,4CAA4C;YAC7C,MAAMe,eAAe,OAAMG;gBACzB,MAAMjB,cAAciB,WAAW+B,MAAM,CAAiB;gBAEtD,MAAM1B,OAAO,MAAMtB,cAAciB,WAAWM,IAAI,CAAiB,CAAC;gBAClEzB,OAAOwB,KAAKO,KAAK,EAAER,IAAI,CAAC;YAC1B;QACF;QAEAtB,GAAG,2CAA2C;YAC5C,MAAMe,eAAe,OAAMG;gBACzB,MAAMK,OAAO,MAAMtB,cAAciB,WAAWM,IAAI,CAAiB,CAAC;gBAElEzB,OAAOwB,KAAKE,KAAK,EAAEQ,OAAO,CAAC,EAAE;gBAC7BlC,OAAOwB,KAAKO,KAAK,EAAER,IAAI,CAAC;YAC1B,GAAG,EAAE;QACP;IACF;AACF,EAAE;SAzSF,AAAajB,mBAAAA,cA4BZ"}
|
|
1
|
+
{"version":3,"sources":["../../src/contracts/entity-repository.contract.ts"],"sourcesContent":["import type { EntityRepository } from '@entifix/business';\nimport { accessor, type Entity, entity, type EntityId } from '@entifix/core';\nimport { Exit } from 'effect';\nimport { describe, expect, it } from 'vitest';\n\nimport { runRepository, runRepositoryExit } from '../effect/run.js';\n\n/**\n * The entity every implementation of the contract is exercised against. It is\n * declared here rather than by each caller so the suite controls exactly which\n * fields exist.\n */\n@entity({ key: 'contract-widget' })\nexport class ContractWidget implements Entity {\n #id?: EntityId;\n #name?: string;\n #size?: number;\n #tags: readonly string[] = [];\n\n @accessor({ type: 'id' })\n get id(): EntityId {\n return this.#id;\n }\n set id(value: EntityId) {\n this.#id = value;\n }\n\n @accessor({ type: 'string' })\n get name(): string | undefined {\n return this.#name;\n }\n set name(value: string | undefined) {\n this.#name = value;\n }\n\n @accessor({ type: 'number' })\n get size(): number | undefined {\n return this.#size;\n }\n set size(value: number | undefined) {\n this.#size = value;\n }\n\n @accessor({ type: 'scalarCollection' })\n get tags(): readonly string[] {\n return this.#tags;\n }\n set tags(value: readonly string[]) {\n this.#tags = value;\n }\n}\n\nexport const makeContractWidget = (\n id: EntityId,\n name: string,\n size: number,\n tags: readonly string[] = [],\n): ContractWidget => {\n const widget = new ContractWidget();\n widget.id = id;\n widget.name = name;\n widget.size = size;\n widget.tags = tags;\n return widget;\n};\n\nexport interface EntityRepositoryContractOptions {\n /**\n * Builds a repository over `seed`. Called fresh for each test, so no state\n * leaks between them.\n */\n makeRepository(\n seed: ContractWidget[],\n ): EntityRepository | Promise<EntityRepository>;\n}\n\n/**\n * The reads every {@link EntityRepository} must answer alike: `load` with its\n * paging, filtering and sorting, and `get`. A read-only adapter — one over\n * static files, say — runs this half alone, since it refuses `save` and\n * `delete` by design.\n */\nexport const describeEntityRepositoryReadContract = (\n name: string,\n { makeRepository }: EntityRepositoryContractOptions,\n): void => {\n describe(`EntityRepository read contract: ${name}`, () => {\n const seeded = () => [\n makeContractWidget('w-1', 'Alpha', 10),\n makeContractWidget('w-2', 'Beta', 20),\n makeContractWidget('w-3', 'Gamma', 30),\n ];\n\n const withRepository = async (\n use: (repository: EntityRepository) => Promise<void>,\n seed: ContractWidget[] = seeded(),\n ) => {\n await use(await makeRepository(seed));\n };\n\n it('keeps a record unchanged when a read instance is mutated without a save', async () => {\n await withRepository(async repository => {\n const read = await runRepository(repository.get<ContractWidget>('w-1'));\n read.name = 'Mutated';\n\n const again = await runRepository(\n repository.get<ContractWidget>('w-1'),\n );\n expect(again.name).toBe('Alpha');\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n page.items[0]!.name = 'Mutated';\n const reloaded = await runRepository(\n repository.load<ContractWidget>({}),\n );\n expect(reloaded.items[0]!.name).toBe('Alpha');\n });\n });\n\n it('loads every stored entity with its total', async () => {\n await withRepository(async repository => {\n const page = await runRepository(repository.load<ContractWidget>({}));\n\n expect(page.total).toBe(3);\n expect(page.items.map(item => item.name)).toEqual([\n 'Alpha',\n 'Beta',\n 'Gamma',\n ]);\n });\n });\n\n it('echoes the request it served on the page', async () => {\n await withRepository(async repository => {\n const request = { page: 1, pageSize: 2 };\n const page = await runRepository(\n repository.load<ContractWidget>(request),\n );\n\n expect(page.request).toEqual(request);\n });\n });\n\n it('pages with a 1-based page number', async () => {\n await withRepository(async repository => {\n const page = await runRepository(\n repository.load<ContractWidget>({ page: 2, pageSize: 2 }),\n );\n\n expect(page.items).toHaveLength(1);\n expect(page.items[0]?.name).toBe('Gamma');\n // The total counts matches, not the page.\n expect(page.total).toBe(3);\n });\n });\n\n it('filters by an equality operator', async () => {\n await withRepository(async repository => {\n const page = await runRepository(\n repository.load<ContractWidget>({\n filtering: [{ property: 'name', operator: 'eq', value: 'Beta' }],\n }),\n );\n\n expect(page.items).toHaveLength(1);\n expect(page.items[0]?.id).toBe('w-2');\n expect(page.total).toBe(1);\n });\n });\n\n it('filters by a range operator', async () => {\n await withRepository(async repository => {\n const page = await runRepository(\n repository.load<ContractWidget>({\n filtering: [\n { property: 'size', operator: 'between', start: 15, end: 35 },\n ],\n }),\n );\n\n expect(page.items.map(item => item.id)).toEqual(['w-2', 'w-3']);\n });\n });\n\n it('matches `like` case-insensitively on a substring', async () => {\n await withRepository(async repository => {\n const page = await runRepository(\n repository.load<ContractWidget>({\n filtering: [{ property: 'name', operator: 'like', value: 'et' }],\n }),\n );\n\n expect(page.items.map(item => item.id)).toEqual(['w-2']);\n });\n });\n\n it('matches `in` and `nin` against any element of an array member', async () => {\n await withRepository(\n async repository => {\n const tagged = await runRepository(\n repository.load<ContractWidget>({\n filtering: [\n { property: 'tags', operator: 'in', values: ['blue', 'red'] },\n ],\n }),\n );\n expect(tagged.items.map(item => item.id)).toEqual(['w-1', 'w-3']);\n\n const untagged = await runRepository(\n repository.load<ContractWidget>({\n filtering: [\n { property: 'tags', operator: 'nin', values: ['blue', 'red'] },\n ],\n }),\n );\n expect(untagged.items.map(item => item.id)).toEqual(['w-2']);\n },\n [\n makeContractWidget('w-1', 'Alpha', 10, ['blue', 'green']),\n makeContractWidget('w-2', 'Beta', 20, ['green']),\n makeContractWidget('w-3', 'Gamma', 30, ['red']),\n ],\n );\n });\n\n it('sorts descending when asked to', async () => {\n await withRepository(async repository => {\n const page = await runRepository(\n repository.load<ContractWidget>({\n sorting: [{ 0: { property: 'size', type: 'desc' } }],\n }),\n );\n\n expect(page.items.map(item => item.size)).toEqual([30, 20, 10]);\n });\n });\n\n it('reads a single entity by id', async () => {\n await withRepository(async repository => {\n const found = await runRepository(\n repository.get<ContractWidget>('w-2'),\n );\n\n expect(found.id).toBe('w-2');\n expect(found.name).toBe('Beta');\n });\n });\n\n it('fails rather than resolving undefined for an unknown id', async () => {\n await withRepository(async repository => {\n const exit = await runRepositoryExit(\n repository.get<ContractWidget>('missing'),\n );\n\n expect(Exit.isFailure(exit)).toBe(true);\n });\n });\n\n it('loads an empty page from an empty store', async () => {\n await withRepository(async repository => {\n const page = await runRepository(repository.load<ContractWidget>({}));\n\n expect(page.items).toEqual([]);\n expect(page.total).toBe(0);\n }, []);\n });\n });\n};\n\n/** The writes every {@link EntityRepository} that accepts them must honour. */\nexport const describeEntityRepositoryWriteContract = (\n name: string,\n { makeRepository }: EntityRepositoryContractOptions,\n): void => {\n describe(`EntityRepository write contract: ${name}`, () => {\n const seeded = () => [\n makeContractWidget('w-1', 'Alpha', 10),\n makeContractWidget('w-2', 'Beta', 20),\n makeContractWidget('w-3', 'Gamma', 30),\n ];\n\n const withRepository = async (\n use: (repository: EntityRepository) => Promise<void>,\n seed: ContractWidget[] = seeded(),\n ) => {\n await use(await makeRepository(seed));\n };\n\n it('keeps a saved record unchanged when the saved instance is mutated afterwards', async () => {\n await withRepository(async repository => {\n const widget = makeContractWidget('w-9', 'Delta', 40);\n const returned = await runRepository(repository.save(widget));\n\n widget.name = 'Mutated';\n returned.name = 'Mutated too';\n\n const stored = await runRepository(\n repository.get<ContractWidget>('w-9'),\n );\n expect(stored.name).toBe('Delta');\n });\n });\n\n it('mints an id when saving an entity that has none', async () => {\n await withRepository(async repository => {\n const created = await runRepository(\n repository.save<ContractWidget>(\n makeContractWidget(undefined, 'Delta', 40),\n ),\n );\n\n expect(created.id).toBeDefined();\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n expect(page.total).toBe(4);\n });\n });\n\n it('overwrites in place when saving an entity that already has an id', async () => {\n await withRepository(async repository => {\n await runRepository(\n repository.save<ContractWidget>(\n makeContractWidget('w-2', 'Renamed', 25),\n ),\n );\n\n const found = await runRepository(\n repository.get<ContractWidget>('w-2'),\n );\n expect(found.name).toBe('Renamed');\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n expect(page.total).toBe(3);\n });\n });\n\n it('deletes by id', async () => {\n await withRepository(async repository => {\n await runRepository(repository.delete<ContractWidget>('w-1'));\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n expect(page.items.map(item => item.id)).toEqual(['w-2', 'w-3']);\n });\n });\n\n it('deletes by entity', async () => {\n await withRepository(async repository => {\n await runRepository(\n repository.delete<ContractWidget>(\n makeContractWidget('w-3', 'Gamma', 30),\n ),\n );\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n expect(page.items.map(item => item.id)).toEqual(['w-1', 'w-2']);\n });\n });\n\n it('treats deleting an unknown id as a no-op', async () => {\n await withRepository(async repository => {\n await runRepository(repository.delete<ContractWidget>('missing'));\n\n const page = await runRepository(repository.load<ContractWidget>({}));\n expect(page.total).toBe(3);\n });\n });\n });\n};\n\n/**\n * The behaviour every {@link EntityRepository} must exhibit, regardless of what\n * backs it: both halves.\n *\n * Run it against the in-memory fake *and* against each real adapter. That is\n * what stops a fake from drifting into a more forgiving version of the thing it\n * stands in for — the failure mode where a suite is green and production is\n * not. `entifix-ts-testing-integration` will run this same suite against real\n * infrastructure later.\n */\nexport const describeEntityRepositoryContract = (\n name: string,\n options: EntityRepositoryContractOptions,\n): void => {\n describeEntityRepositoryReadContract(name, options);\n describeEntityRepositoryWriteContract(name, options);\n};\n"],"names":["accessor","entity","Exit","describe","expect","it","runRepository","runRepositoryExit","key","type","ContractWidget","id","value","name","size","tags","makeContractWidget","widget","describeEntityRepositoryReadContract","makeRepository","seeded","withRepository","use","seed","repository","read","get","again","toBe","page","load","items","reloaded","total","map","item","toEqual","request","pageSize","toHaveLength","filtering","property","operator","start","end","tagged","values","untagged","sorting","found","exit","isFailure","describeEntityRepositoryWriteContract","returned","save","stored","created","undefined","toBeDefined","delete","describeEntityRepositoryContract","options"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"entity-repository.d.ts","sourceRoot":"","sources":["../../src/doubles/entity-repository.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAC1D,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,MAAM,
|
|
1
|
+
{"version":3,"file":"entity-repository.d.ts","sourceRoot":"","sources":["../../src/doubles/entity-repository.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAC1D,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,MAAM,EAaZ,MAAM,eAAe,CAAC;AA0KvB,MAAM,WAAW,wBAAyB,SAAQ,gBAAgB;IAChE,uDAAuD;IACvD,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC;IACzB,4DAA4D;IAC5D,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IAC5B;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI,CAAC;CACrC;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,4BAA4B,GACvC,OAAM,MAAM,EAAO,KAClB,wBAqGF,CAAC"}
|
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
import { deserializeSingleEntity, EntifixConnError, extractMetaAccessors, serializeEntity } from '@entifix/core';
|
|
1
|
+
import { deserializeSingleEntity, EntifixConnError, EntityCollectionLink, EntityLink, extractMetaAccessors, serializeEntity } from '@entifix/core';
|
|
2
2
|
import { Effect } from 'effect';
|
|
3
3
|
/** Escapes a string so `like`/`nlike` match literally, as the Mongo adapter does. */ const escapeRegex = (value)=>value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
4
4
|
const isFilterGroup = (node)=>!('property' in node) && (node.operator === 'and' || node.operator === 'or');
|
|
5
|
+
/**
|
|
6
|
+
* A member as the Mongo adapter stores it, and so as its queries see it: a
|
|
7
|
+
* link is its target's id, a collection link the list of their ids. Without
|
|
8
|
+
* this no operator can compare a link, since `eq` against an id is never
|
|
9
|
+
* `true` for an object.
|
|
10
|
+
*/ const storedValue = (value)=>{
|
|
11
|
+
if (value instanceof EntityLink) return value.id;
|
|
12
|
+
if (value instanceof EntityCollectionLink) return value.ids;
|
|
13
|
+
return value;
|
|
14
|
+
};
|
|
15
|
+
/** `$in` semantics: a scalar matches a value, an array when any element does. */ const matchesAny = (actual, values)=>Array.isArray(actual) ? actual.some((element)=>values.includes(element)) : values.includes(actual);
|
|
5
16
|
const compareValues = (left, right)=>{
|
|
6
17
|
if (left === right) return 0;
|
|
7
18
|
// Mongo sorts missing values below present ones.
|
|
@@ -19,7 +30,7 @@ const matchesNode = (item, node)=>{
|
|
|
19
30
|
if (isFilterGroup(node)) {
|
|
20
31
|
return node.operator === 'or' ? node.values.some((child)=>matchesNode(item, child)) : node.values.every((child)=>matchesNode(item, child));
|
|
21
32
|
}
|
|
22
|
-
const actual = item[String(node.property)];
|
|
33
|
+
const actual = storedValue(item[String(node.property)]);
|
|
23
34
|
switch(node.operator){
|
|
24
35
|
case 'eq':
|
|
25
36
|
return actual === node.value;
|
|
@@ -34,9 +45,9 @@ const matchesNode = (item, node)=>{
|
|
|
34
45
|
case 'lte':
|
|
35
46
|
return compareValues(actual, node.value) <= 0;
|
|
36
47
|
case 'in':
|
|
37
|
-
return node.values
|
|
48
|
+
return matchesAny(actual, node.values);
|
|
38
49
|
case 'nin':
|
|
39
|
-
return !node.values
|
|
50
|
+
return !matchesAny(actual, node.values);
|
|
40
51
|
case 'between':
|
|
41
52
|
return compareValues(actual, node.start) >= 0 && compareValues(actual, node.end) <= 0;
|
|
42
53
|
case 'nbetween':
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/doubles/entity-repository.ts"],"sourcesContent":["import type { EntityRepository } from '@entifix/business';\nimport {\n deserializeSingleEntity,\n EntifixConnError,\n type EntifixError,\n type Entity,\n type EntityConstructor,\n type EntityFilter,\n type EntityFiltering,\n type EntityId,\n type EntityLoadRequest,\n type EntityPage,\n type EntitySorting,\n extractMetaAccessors,\n type FilterGroup,\n serializeEntity,\n} from '@entifix/core';\nimport { Effect } from 'effect';\n\n/** Escapes a string so `like`/`nlike` match literally, as the Mongo adapter does. */\nconst escapeRegex = (value: string): string =>\n value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n\nconst isFilterGroup = <TEntity extends Entity>(\n node: EntityFilter<TEntity> | FilterGroup<TEntity>,\n): node is FilterGroup<TEntity> =>\n !('property' in node) && (node.operator === 'and' || node.operator === 'or');\n\nconst compareValues = (left: unknown, right: unknown): number => {\n if (left === right) return 0;\n // Mongo sorts missing values below present ones.\n if (left == null) return -1;\n if (right == null) return 1;\n if (typeof left === 'number' && typeof right === 'number') {\n return left - right;\n }\n if (left instanceof Date && right instanceof Date) {\n return left.getTime() - right.getTime();\n }\n return String(left).localeCompare(String(right));\n};\n\nconst matchesNode = <TEntity extends Entity>(\n item: TEntity,\n node: EntityFilter<TEntity> | FilterGroup<TEntity>,\n): boolean => {\n if (isFilterGroup(node)) {\n return node.operator === 'or'\n ? node.values.some(child => matchesNode(item, child))\n : node.values.every(child => matchesNode(item, child));\n }\n\n const actual = (item as Record<string, unknown>)[String(node.property)];\n\n switch (node.operator) {\n case 'eq':\n return actual === node.value;\n case 'ne':\n return actual !== node.value;\n case 'gt':\n return compareValues(actual, node.value) > 0;\n case 'gte':\n return compareValues(actual, node.value) >= 0;\n case 'lt':\n return compareValues(actual, node.value) < 0;\n case 'lte':\n return compareValues(actual, node.value) <= 0;\n case 'in':\n return node.values.includes(actual as TEntity[keyof TEntity]);\n case 'nin':\n return !node.values.includes(actual as TEntity[keyof TEntity]);\n case 'between':\n return (\n compareValues(actual, node.start) >= 0 &&\n compareValues(actual, node.end) <= 0\n );\n case 'nbetween':\n return !(\n compareValues(actual, node.start) >= 0 &&\n compareValues(actual, node.end) <= 0\n );\n case 'like':\n return new RegExp(escapeRegex(node.value), 'i').test(String(actual));\n case 'nlike':\n return !new RegExp(escapeRegex(node.value), 'i').test(String(actual));\n case 'isNull':\n return actual == null;\n case 'isNotNull':\n return actual != null;\n default: {\n // Exhaustiveness guard — a new operator must be handled explicitly.\n const never: never = node;\n return never;\n }\n }\n};\n\nconst applyFiltering = <TEntity extends Entity>(\n items: TEntity[],\n filtering?: EntityFiltering<TEntity>[],\n): TEntity[] => {\n if (!filtering || filtering.length === 0) return items;\n // Every top-level entry is combined with `and`, matching `translateFiltering`.\n const nodes = filtering.flatMap(entry =>\n Array.isArray(entry) ? entry : [entry],\n );\n return items.filter(item => nodes.every(node => matchesNode(item, node)));\n};\n\nconst applySorting = <TEntity extends Entity>(\n items: TEntity[],\n sorting?: EntitySorting<TEntity>[],\n): TEntity[] => {\n if (!sorting || sorting.length === 0) return items;\n\n const entries = sorting.flatMap(record =>\n Object.keys(record)\n .map(Number)\n .sort((left, right) => left - right)\n .map(priority => record[priority])\n .filter(entry => entry != null),\n );\n\n return [...items].sort((left, right) => {\n for (const entry of entries) {\n const property = String(entry.property);\n const result = compareValues(\n (left as Record<string, unknown>)[property],\n (right as Record<string, unknown>)[property],\n );\n if (result !== 0) return entry.type === 'desc' ? -result : result;\n }\n return 0;\n });\n};\n\n/**\n * A copy of `entity` that shares no state with it.\n *\n * Through the entity's own mapping — serialize, then deserialize — because that\n * is what a real adapter does to every record on the way in and out: a caller\n * can never hold the instance the store keeps, and a member the mapping drops\n * (`hidden`, `readonly`) does not survive a save here either. A class that\n * declares no accessors has no mapping to go through, so it is copied member by\n * member instead; plain spec fixtures are the case this keeps working.\n */\nconst copyOf = <TEntity extends Entity>(\n entity: TEntity,\n): Effect.Effect<TEntity, EntifixError> => {\n const entityConstructor = entity.constructor as EntityConstructor<TEntity>;\n if (extractMetaAccessors(entityConstructor).length === 0) {\n return Effect.succeed(\n Object.assign(\n Object.create(Object.getPrototypeOf(entity) as object) as TEntity,\n entity,\n ),\n );\n }\n // `undefined` only comes back for `null`/`undefined` input, and\n // `serializeEntity` always hands over an object.\n return deserializeSingleEntity(\n entityConstructor,\n serializeEntity(entityConstructor, entity),\n ) as Effect.Effect<TEntity, EntifixError>;\n};\n\nexport interface InMemoryEntityRepository extends EntityRepository {\n /** Everything currently stored, in insertion order. */\n readonly items: Entity[];\n /** Replaces the contents, discarding whatever was there. */\n seed(items: Entity[]): void;\n /**\n * Makes the next call to any method fail with `error`, so error branches are\n * reachable without reaching for a mocking library.\n */\n failNext(error: EntifixError): void;\n}\n\n/**\n * In-memory {@link EntityRepository}: the default double for anything that\n * depends on persistence.\n *\n * Its semantics deliberately mirror `makeMongoRepository` — same filtering and\n * sorting rules, same 1-based paging with a default page size of 10, same\n * \"entity not found\" failure, same id minting on create — and\n * `describeEntityRepositoryContract` holds both to that shared contract.\n */\nexport const makeInMemoryEntityRepository = (\n seed: Entity[] = [],\n): InMemoryEntityRepository => {\n let items = [...seed];\n let pendingFailure: EntifixError | undefined;\n\n /** Consumes a queued failure, if one was armed. */\n const guard = <TValue>(\n run: () => Effect.Effect<TValue, EntifixError>,\n ): Effect.Effect<TValue, EntifixError> => {\n if (pendingFailure !== undefined) {\n const failure = pendingFailure;\n pendingFailure = undefined;\n return Effect.fail(failure);\n }\n return run();\n };\n\n const load = <TEntity extends Entity>(request: EntityLoadRequest<TEntity>) =>\n guard(() => {\n const matched = applySorting(\n applyFiltering(items as TEntity[], request.filtering),\n request.sorting,\n );\n const page = request.page ?? 1;\n const pageSize = request.pageSize ?? 10;\n const start = (page - 1) * pageSize;\n\n return Effect.forEach(\n matched.slice(start, start + pageSize),\n copyOf,\n ).pipe(\n Effect.map(\n copies =>\n ({\n items: copies,\n total: matched.length,\n request,\n }) satisfies EntityPage<TEntity>,\n ),\n );\n });\n\n const get = <TEntity extends Entity>(id: EntityId) =>\n guard(() => {\n const found = items.find(item => item.id === id);\n return found === undefined\n ? Effect.fail(\n new EntifixConnError('Entity not found', undefined, { id }),\n )\n : copyOf(found as TEntity);\n });\n\n const save = <TEntity extends Entity>(entity: TEntity) =>\n guard(() => {\n // A create arrives without an id, so the store mints one — same rule the\n // Mongo adapter follows. The Web Crypto global rather than `node:crypto`,\n // because a browser bundle cannot resolve a `node:` import and an example\n // with no backend runs this repository in the page.\n entity.id = entity.id ?? globalThis.crypto.randomUUID();\n // The store keeps its own copy and hands back another, so neither the\n // caller's instance nor the returned one can change the record without a\n // second save — which a real adapter guarantees by construction.\n return copyOf(entity).pipe(\n Effect.tap(stored => {\n const index = items.findIndex(item => item.id === stored.id);\n if (index === -1) {\n items.push(stored);\n } else {\n items[index] = stored;\n }\n }),\n Effect.flatMap(copyOf),\n );\n });\n\n const remove = <TEntity extends Entity>(entityOrId: EntityId | TEntity) =>\n guard(() => {\n const id =\n entityOrId != null && typeof entityOrId === 'object'\n ? entityOrId.id\n : entityOrId;\n items = items.filter(item => item.id !== id);\n return Effect.succeed(undefined);\n });\n\n return {\n load,\n get,\n save,\n delete: remove,\n get items() {\n return items;\n },\n seed: (next: Entity[]) => {\n items = [...next];\n },\n failNext: (error: EntifixError) => {\n pendingFailure = error;\n },\n // Each method closes over its own state, so nothing is required from the\n // Effect environment; that is assignable to the interface's channel.\n } as unknown as InMemoryEntityRepository;\n};\n"],"names":["deserializeSingleEntity","EntifixConnError","extractMetaAccessors","serializeEntity","Effect","escapeRegex","value","replace","isFilterGroup","node","operator","compareValues","left","right","Date","getTime","String","localeCompare","matchesNode","item","values","some","child","every","actual","property","includes","start","end","RegExp","test","never","applyFiltering","items","filtering","length","nodes","flatMap","entry","Array","isArray","filter","applySorting","sorting","entries","record","Object","keys","map","Number","sort","priority","result","type","copyOf","entity","entityConstructor","succeed","assign","create","getPrototypeOf","makeInMemoryEntityRepository","seed","pendingFailure","guard","run","undefined","failure","fail","load","request","matched","page","pageSize","forEach","slice","pipe","copies","total","get","id","found","find","save","globalThis","crypto","randomUUID","tap","stored","index","findIndex","push","remove","entityOrId","delete","next","failNext","error"],"mappings":"AACA,SACEA,uBAAuB,EACvBC,gBAAgB,EAUhBC,oBAAoB,EAEpBC,eAAe,QACV,gBAAgB;AACvB,SAASC,MAAM,QAAQ,SAAS;AAEhC,mFAAmF,GACnF,MAAMC,cAAc,CAACC,QACnBA,MAAMC,OAAO,CAAC,uBAAuB;AAEvC,MAAMC,gBAAgB,CACpBC,OAEA,CAAE,CAAA,cAAcA,IAAG,KAAOA,CAAAA,KAAKC,QAAQ,KAAK,SAASD,KAAKC,QAAQ,KAAK,IAAG;AAE5E,MAAMC,gBAAgB,CAACC,MAAeC;IACpC,IAAID,SAASC,OAAO,OAAO;IAC3B,iDAAiD;IACjD,IAAID,QAAQ,MAAM,OAAO,CAAC;IAC1B,IAAIC,SAAS,MAAM,OAAO;IAC1B,IAAI,OAAOD,SAAS,YAAY,OAAOC,UAAU,UAAU;QACzD,OAAOD,OAAOC;IAChB;IACA,IAAID,gBAAgBE,QAAQD,iBAAiBC,MAAM;QACjD,OAAOF,KAAKG,OAAO,KAAKF,MAAME,OAAO;IACvC;IACA,OAAOC,OAAOJ,MAAMK,aAAa,CAACD,OAAOH;AAC3C;AAEA,MAAMK,cAAc,CAClBC,MACAV;IAEA,IAAID,cAAcC,OAAO;QACvB,OAAOA,KAAKC,QAAQ,KAAK,OACrBD,KAAKW,MAAM,CAACC,IAAI,CAACC,CAAAA,QAASJ,YAAYC,MAAMG,UAC5Cb,KAAKW,MAAM,CAACG,KAAK,CAACD,CAAAA,QAASJ,YAAYC,MAAMG;IACnD;IAEA,MAAME,SAAS,AAACL,IAAgC,CAACH,OAAOP,KAAKgB,QAAQ,EAAE;IAEvE,OAAQhB,KAAKC,QAAQ;QACnB,KAAK;YACH,OAAOc,WAAWf,KAAKH,KAAK;QAC9B,KAAK;YACH,OAAOkB,WAAWf,KAAKH,KAAK;QAC9B,KAAK;YACH,OAAOK,cAAca,QAAQf,KAAKH,KAAK,IAAI;QAC7C,KAAK;YACH,OAAOK,cAAca,QAAQf,KAAKH,KAAK,KAAK;QAC9C,KAAK;YACH,OAAOK,cAAca,QAAQf,KAAKH,KAAK,IAAI;QAC7C,KAAK;YACH,OAAOK,cAAca,QAAQf,KAAKH,KAAK,KAAK;QAC9C,KAAK;YACH,OAAOG,KAAKW,MAAM,CAACM,QAAQ,CAACF;QAC9B,KAAK;YACH,OAAO,CAACf,KAAKW,MAAM,CAACM,QAAQ,CAACF;QAC/B,KAAK;YACH,OACEb,cAAca,QAAQf,KAAKkB,KAAK,KAAK,KACrChB,cAAca,QAAQf,KAAKmB,GAAG,KAAK;QAEvC,KAAK;YACH,OAAO,CACLjB,CAAAA,cAAca,QAAQf,KAAKkB,KAAK,KAAK,KACrChB,cAAca,QAAQf,KAAKmB,GAAG,KAAK,CAAA;QAEvC,KAAK;YACH,OAAO,IAAIC,OAAOxB,YAAYI,KAAKH,KAAK,GAAG,KAAKwB,IAAI,CAACd,OAAOQ;QAC9D,KAAK;YACH,OAAO,CAAC,IAAIK,OAAOxB,YAAYI,KAAKH,KAAK,GAAG,KAAKwB,IAAI,CAACd,OAAOQ;QAC/D,KAAK;YACH,OAAOA,UAAU;QACnB,KAAK;YACH,OAAOA,UAAU;QACnB;YAAS;gBACP,oEAAoE;gBACpE,MAAMO,QAAetB;gBACrB,OAAOsB;YACT;IACF;AACF;AAEA,MAAMC,iBAAiB,CACrBC,OACAC;IAEA,IAAI,CAACA,aAAaA,UAAUC,MAAM,KAAK,GAAG,OAAOF;IACjD,+EAA+E;IAC/E,MAAMG,QAAQF,UAAUG,OAAO,CAACC,CAAAA,QAC9BC,MAAMC,OAAO,CAACF,SAASA,QAAQ;YAACA;SAAM;IAExC,OAAOL,MAAMQ,MAAM,CAACtB,CAAAA,OAAQiB,MAAMb,KAAK,CAACd,CAAAA,OAAQS,YAAYC,MAAMV;AACpE;AAEA,MAAMiC,eAAe,CACnBT,OACAU;IAEA,IAAI,CAACA,WAAWA,QAAQR,MAAM,KAAK,GAAG,OAAOF;IAE7C,MAAMW,UAAUD,QAAQN,OAAO,CAACQ,CAAAA,SAC9BC,OAAOC,IAAI,CAACF,QACTG,GAAG,CAACC,QACJC,IAAI,CAAC,CAACtC,MAAMC,QAAUD,OAAOC,OAC7BmC,GAAG,CAACG,CAAAA,WAAYN,MAAM,CAACM,SAAS,EAChCV,MAAM,CAACH,CAAAA,QAASA,SAAS;IAG9B,OAAO;WAAIL;KAAM,CAACiB,IAAI,CAAC,CAACtC,MAAMC;QAC5B,KAAK,MAAMyB,SAASM,QAAS;YAC3B,MAAMnB,WAAWT,OAAOsB,MAAMb,QAAQ;YACtC,MAAM2B,SAASzC,cACb,AAACC,IAAgC,CAACa,SAAS,EAC3C,AAACZ,KAAiC,CAACY,SAAS;YAE9C,IAAI2B,WAAW,GAAG,OAAOd,MAAMe,IAAI,KAAK,SAAS,CAACD,SAASA;QAC7D;QACA,OAAO;IACT;AACF;AAEA;;;;;;;;;CASC,GACD,MAAME,SAAS,CACbC;IAEA,MAAMC,oBAAoBD,OAAO,WAAW;IAC5C,IAAIrD,qBAAqBsD,mBAAmBrB,MAAM,KAAK,GAAG;QACxD,OAAO/B,OAAOqD,OAAO,CACnBX,OAAOY,MAAM,CACXZ,OAAOa,MAAM,CAACb,OAAOc,cAAc,CAACL,UACpCA;IAGN;IACA,gEAAgE;IAChE,iDAAiD;IACjD,OAAOvD,wBACLwD,mBACArD,gBAAgBqD,mBAAmBD;AAEvC;AAcA;;;;;;;;CAQC,GACD,OAAO,MAAMM,+BAA+B,CAC1CC,OAAiB,EAAE;IAEnB,IAAI7B,QAAQ;WAAI6B;KAAK;IACrB,IAAIC;IAEJ,iDAAiD,GACjD,MAAMC,QAAQ,CACZC;QAEA,IAAIF,mBAAmBG,WAAW;YAChC,MAAMC,UAAUJ;YAChBA,iBAAiBG;YACjB,OAAO9D,OAAOgE,IAAI,CAACD;QACrB;QACA,OAAOF;IACT;IAEA,MAAMI,OAAO,CAAyBC,UACpCN,MAAM;gBAKSM,eACIA;YALjB,MAAMC,UAAU7B,aACdV,eAAeC,OAAoBqC,QAAQpC,SAAS,GACpDoC,QAAQ3B,OAAO;YAEjB,MAAM6B,QAAOF,gBAAAA,QAAQE,IAAI,YAAZF,gBAAgB;YAC7B,MAAMG,YAAWH,oBAAAA,QAAQG,QAAQ,YAAhBH,oBAAoB;YACrC,MAAM3C,QAAQ,AAAC6C,CAAAA,OAAO,CAAA,IAAKC;YAE3B,OAAOrE,OAAOsE,OAAO,CACnBH,QAAQI,KAAK,CAAChD,OAAOA,QAAQ8C,WAC7BnB,QACAsB,IAAI,CACJxE,OAAO4C,GAAG,CACR6B,CAAAA,SACG,CAAA;oBACC5C,OAAO4C;oBACPC,OAAOP,QAAQpC,MAAM;oBACrBmC;gBACF,CAAA;QAGR;IAEF,MAAMS,MAAM,CAAyBC,KACnChB,MAAM;YACJ,MAAMiB,QAAQhD,MAAMiD,IAAI,CAAC/D,CAAAA,OAAQA,KAAK6D,EAAE,KAAKA;YAC7C,OAAOC,UAAUf,YACb9D,OAAOgE,IAAI,CACT,IAAInE,iBAAiB,oBAAoBiE,WAAW;gBAAEc;YAAG,MAE3D1B,OAAO2B;QACb;IAEF,MAAME,OAAO,CAAyB5B,SACpCS,MAAM;gBAKQT;YAJZ,yEAAyE;YACzE,0EAA0E;YAC1E,0EAA0E;YAC1E,oDAAoD;YACpDA,OAAOyB,EAAE,IAAGzB,aAAAA,OAAOyB,EAAE,YAATzB,aAAa6B,WAAWC,MAAM,CAACC,UAAU;YACrD,sEAAsE;YACtE,yEAAyE;YACzE,iEAAiE;YACjE,OAAOhC,OAAOC,QAAQqB,IAAI,CACxBxE,OAAOmF,GAAG,CAACC,CAAAA;gBACT,MAAMC,QAAQxD,MAAMyD,SAAS,CAACvE,CAAAA,OAAQA,KAAK6D,EAAE,KAAKQ,OAAOR,EAAE;gBAC3D,IAAIS,UAAU,CAAC,GAAG;oBAChBxD,MAAM0D,IAAI,CAACH;gBACb,OAAO;oBACLvD,KAAK,CAACwD,MAAM,GAAGD;gBACjB;YACF,IACApF,OAAOiC,OAAO,CAACiB;QAEnB;IAEF,MAAMsC,SAAS,CAAyBC,aACtC7B,MAAM;YACJ,MAAMgB,KACJa,cAAc,QAAQ,OAAOA,eAAe,WACxCA,WAAWb,EAAE,GACba;YACN5D,QAAQA,MAAMQ,MAAM,CAACtB,CAAAA,OAAQA,KAAK6D,EAAE,KAAKA;YACzC,OAAO5E,OAAOqD,OAAO,CAACS;QACxB;IAEF,OAAO;QACLG;QACAU;QACAI;QACAW,QAAQF;QACR,IAAI3D,SAAQ;YACV,OAAOA;QACT;QACA6B,MAAM,CAACiC;YACL9D,QAAQ;mBAAI8D;aAAK;QACnB;QACAC,UAAU,CAACC;YACTlC,iBAAiBkC;QACnB;IAGF;AACF,EAAE"}
|
|
1
|
+
{"version":3,"sources":["../../src/doubles/entity-repository.ts"],"sourcesContent":["import type { EntityRepository } from '@entifix/business';\nimport {\n deserializeSingleEntity,\n EntifixConnError,\n type EntifixError,\n type Entity,\n EntityCollectionLink,\n type EntityConstructor,\n type EntityFilter,\n type EntityFiltering,\n type EntityId,\n EntityLink,\n type EntityLoadRequest,\n type EntityPage,\n type EntitySorting,\n extractMetaAccessors,\n type FilterGroup,\n serializeEntity,\n} from '@entifix/core';\nimport { Effect } from 'effect';\n\n/** Escapes a string so `like`/`nlike` match literally, as the Mongo adapter does. */\nconst escapeRegex = (value: string): string =>\n value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n\nconst isFilterGroup = <TEntity extends Entity>(\n node: EntityFilter<TEntity> | FilterGroup<TEntity>,\n): node is FilterGroup<TEntity> =>\n !('property' in node) && (node.operator === 'and' || node.operator === 'or');\n\n/**\n * A member as the Mongo adapter stores it, and so as its queries see it: a\n * link is its target's id, a collection link the list of their ids. Without\n * this no operator can compare a link, since `eq` against an id is never\n * `true` for an object.\n */\nconst storedValue = (value: unknown): unknown => {\n if (value instanceof EntityLink) return value.id;\n if (value instanceof EntityCollectionLink) return value.ids;\n return value;\n};\n\n/** `$in` semantics: a scalar matches a value, an array when any element does. */\nconst matchesAny = (actual: unknown, values: readonly unknown[]): boolean =>\n Array.isArray(actual)\n ? actual.some(element => values.includes(element))\n : values.includes(actual);\n\nconst compareValues = (left: unknown, right: unknown): number => {\n if (left === right) return 0;\n // Mongo sorts missing values below present ones.\n if (left == null) return -1;\n if (right == null) return 1;\n if (typeof left === 'number' && typeof right === 'number') {\n return left - right;\n }\n if (left instanceof Date && right instanceof Date) {\n return left.getTime() - right.getTime();\n }\n return String(left).localeCompare(String(right));\n};\n\nconst matchesNode = <TEntity extends Entity>(\n item: TEntity,\n node: EntityFilter<TEntity> | FilterGroup<TEntity>,\n): boolean => {\n if (isFilterGroup(node)) {\n return node.operator === 'or'\n ? node.values.some(child => matchesNode(item, child))\n : node.values.every(child => matchesNode(item, child));\n }\n\n const actual = storedValue(\n (item as Record<string, unknown>)[String(node.property)],\n );\n\n switch (node.operator) {\n case 'eq':\n return actual === node.value;\n case 'ne':\n return actual !== node.value;\n case 'gt':\n return compareValues(actual, node.value) > 0;\n case 'gte':\n return compareValues(actual, node.value) >= 0;\n case 'lt':\n return compareValues(actual, node.value) < 0;\n case 'lte':\n return compareValues(actual, node.value) <= 0;\n case 'in':\n return matchesAny(actual, node.values);\n case 'nin':\n return !matchesAny(actual, node.values);\n case 'between':\n return (\n compareValues(actual, node.start) >= 0 &&\n compareValues(actual, node.end) <= 0\n );\n case 'nbetween':\n return !(\n compareValues(actual, node.start) >= 0 &&\n compareValues(actual, node.end) <= 0\n );\n case 'like':\n return new RegExp(escapeRegex(node.value), 'i').test(String(actual));\n case 'nlike':\n return !new RegExp(escapeRegex(node.value), 'i').test(String(actual));\n case 'isNull':\n return actual == null;\n case 'isNotNull':\n return actual != null;\n default: {\n // Exhaustiveness guard — a new operator must be handled explicitly.\n const never: never = node;\n return never;\n }\n }\n};\n\nconst applyFiltering = <TEntity extends Entity>(\n items: TEntity[],\n filtering?: EntityFiltering<TEntity>[],\n): TEntity[] => {\n if (!filtering || filtering.length === 0) return items;\n // Every top-level entry is combined with `and`, matching `translateFiltering`.\n const nodes = filtering.flatMap(entry =>\n Array.isArray(entry) ? entry : [entry],\n );\n return items.filter(item => nodes.every(node => matchesNode(item, node)));\n};\n\nconst applySorting = <TEntity extends Entity>(\n items: TEntity[],\n sorting?: EntitySorting<TEntity>[],\n): TEntity[] => {\n if (!sorting || sorting.length === 0) return items;\n\n const entries = sorting.flatMap(record =>\n Object.keys(record)\n .map(Number)\n .sort((left, right) => left - right)\n .map(priority => record[priority])\n .filter(entry => entry != null),\n );\n\n return [...items].sort((left, right) => {\n for (const entry of entries) {\n const property = String(entry.property);\n const result = compareValues(\n (left as Record<string, unknown>)[property],\n (right as Record<string, unknown>)[property],\n );\n if (result !== 0) return entry.type === 'desc' ? -result : result;\n }\n return 0;\n });\n};\n\n/**\n * A copy of `entity` that shares no state with it.\n *\n * Through the entity's own mapping — serialize, then deserialize — because that\n * is what a real adapter does to every record on the way in and out: a caller\n * can never hold the instance the store keeps, and a member the mapping drops\n * (`hidden`, `readonly`) does not survive a save here either. A class that\n * declares no accessors has no mapping to go through, so it is copied member by\n * member instead; plain spec fixtures are the case this keeps working.\n */\nconst copyOf = <TEntity extends Entity>(\n entity: TEntity,\n): Effect.Effect<TEntity, EntifixError> => {\n const entityConstructor = entity.constructor as EntityConstructor<TEntity>;\n if (extractMetaAccessors(entityConstructor).length === 0) {\n return Effect.succeed(\n Object.assign(\n Object.create(Object.getPrototypeOf(entity) as object) as TEntity,\n entity,\n ),\n );\n }\n // `undefined` only comes back for `null`/`undefined` input, and\n // `serializeEntity` always hands over an object.\n return deserializeSingleEntity(\n entityConstructor,\n serializeEntity(entityConstructor, entity),\n ) as Effect.Effect<TEntity, EntifixError>;\n};\n\nexport interface InMemoryEntityRepository extends EntityRepository {\n /** Everything currently stored, in insertion order. */\n readonly items: Entity[];\n /** Replaces the contents, discarding whatever was there. */\n seed(items: Entity[]): void;\n /**\n * Makes the next call to any method fail with `error`, so error branches are\n * reachable without reaching for a mocking library.\n */\n failNext(error: EntifixError): void;\n}\n\n/**\n * In-memory {@link EntityRepository}: the default double for anything that\n * depends on persistence.\n *\n * Its semantics deliberately mirror `makeMongoRepository` — same filtering and\n * sorting rules, same 1-based paging with a default page size of 10, same\n * \"entity not found\" failure, same id minting on create — and\n * `describeEntityRepositoryContract` holds both to that shared contract.\n */\nexport const makeInMemoryEntityRepository = (\n seed: Entity[] = [],\n): InMemoryEntityRepository => {\n let items = [...seed];\n let pendingFailure: EntifixError | undefined;\n\n /** Consumes a queued failure, if one was armed. */\n const guard = <TValue>(\n run: () => Effect.Effect<TValue, EntifixError>,\n ): Effect.Effect<TValue, EntifixError> => {\n if (pendingFailure !== undefined) {\n const failure = pendingFailure;\n pendingFailure = undefined;\n return Effect.fail(failure);\n }\n return run();\n };\n\n const load = <TEntity extends Entity>(request: EntityLoadRequest<TEntity>) =>\n guard(() => {\n const matched = applySorting(\n applyFiltering(items as TEntity[], request.filtering),\n request.sorting,\n );\n const page = request.page ?? 1;\n const pageSize = request.pageSize ?? 10;\n const start = (page - 1) * pageSize;\n\n return Effect.forEach(\n matched.slice(start, start + pageSize),\n copyOf,\n ).pipe(\n Effect.map(\n copies =>\n ({\n items: copies,\n total: matched.length,\n request,\n }) satisfies EntityPage<TEntity>,\n ),\n );\n });\n\n const get = <TEntity extends Entity>(id: EntityId) =>\n guard(() => {\n const found = items.find(item => item.id === id);\n return found === undefined\n ? Effect.fail(\n new EntifixConnError('Entity not found', undefined, { id }),\n )\n : copyOf(found as TEntity);\n });\n\n const save = <TEntity extends Entity>(entity: TEntity) =>\n guard(() => {\n // A create arrives without an id, so the store mints one — same rule the\n // Mongo adapter follows. The Web Crypto global rather than `node:crypto`,\n // because a browser bundle cannot resolve a `node:` import and an example\n // with no backend runs this repository in the page.\n entity.id = entity.id ?? globalThis.crypto.randomUUID();\n // The store keeps its own copy and hands back another, so neither the\n // caller's instance nor the returned one can change the record without a\n // second save — which a real adapter guarantees by construction.\n return copyOf(entity).pipe(\n Effect.tap(stored => {\n const index = items.findIndex(item => item.id === stored.id);\n if (index === -1) {\n items.push(stored);\n } else {\n items[index] = stored;\n }\n }),\n Effect.flatMap(copyOf),\n );\n });\n\n const remove = <TEntity extends Entity>(entityOrId: EntityId | TEntity) =>\n guard(() => {\n const id =\n entityOrId != null && typeof entityOrId === 'object'\n ? entityOrId.id\n : entityOrId;\n items = items.filter(item => item.id !== id);\n return Effect.succeed(undefined);\n });\n\n return {\n load,\n get,\n save,\n delete: remove,\n get items() {\n return items;\n },\n seed: (next: Entity[]) => {\n items = [...next];\n },\n failNext: (error: EntifixError) => {\n pendingFailure = error;\n },\n // Each method closes over its own state, so nothing is required from the\n // Effect environment; that is assignable to the interface's channel.\n } as unknown as InMemoryEntityRepository;\n};\n"],"names":["deserializeSingleEntity","EntifixConnError","EntityCollectionLink","EntityLink","extractMetaAccessors","serializeEntity","Effect","escapeRegex","value","replace","isFilterGroup","node","operator","storedValue","id","ids","matchesAny","actual","values","Array","isArray","some","element","includes","compareValues","left","right","Date","getTime","String","localeCompare","matchesNode","item","child","every","property","start","end","RegExp","test","never","applyFiltering","items","filtering","length","nodes","flatMap","entry","filter","applySorting","sorting","entries","record","Object","keys","map","Number","sort","priority","result","type","copyOf","entity","entityConstructor","succeed","assign","create","getPrototypeOf","makeInMemoryEntityRepository","seed","pendingFailure","guard","run","undefined","failure","fail","load","request","matched","page","pageSize","forEach","slice","pipe","copies","total","get","found","find","save","globalThis","crypto","randomUUID","tap","stored","index","findIndex","push","remove","entityOrId","delete","next","failNext","error"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entifix/testing-unit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Test doubles for entifix: in-memory ports, driver fakes for Mongo, Redis, RabbitMQ and SQL, MSW handlers and port contract suites.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -78,18 +78,18 @@
|
|
|
78
78
|
"dependencies": {
|
|
79
79
|
"@effect/sql": "0.52.1",
|
|
80
80
|
"@swc/helpers": "0.5.23",
|
|
81
|
-
"@entifix/
|
|
82
|
-
"@entifix/
|
|
83
|
-
"@entifix/
|
|
81
|
+
"@entifix/transactions": "0.1.4",
|
|
82
|
+
"@entifix/core": "0.1.4",
|
|
83
|
+
"@entifix/business": "0.1.4"
|
|
84
84
|
},
|
|
85
85
|
"peerDependencies": {
|
|
86
86
|
"@effect/vitest": ">=0.30 <1",
|
|
87
87
|
"@testing-library/react": "*",
|
|
88
|
-
"effect": ">=3.22 <4",
|
|
88
|
+
"effect": ">=3.22.2 <4",
|
|
89
89
|
"msw": "*",
|
|
90
90
|
"react": "*",
|
|
91
91
|
"vitest": "*",
|
|
92
|
-
"@entifix/react-controls": "0.1.
|
|
92
|
+
"@entifix/react-controls": "0.1.4"
|
|
93
93
|
},
|
|
94
94
|
"peerDependenciesMeta": {
|
|
95
95
|
"@effect/vitest": {
|
|
@@ -116,6 +116,6 @@
|
|
|
116
116
|
},
|
|
117
117
|
"devDependencies": {
|
|
118
118
|
"@effect/vitest": "^0.30.0",
|
|
119
|
-
"effect": "3.22.
|
|
119
|
+
"effect": "3.22.2"
|
|
120
120
|
}
|
|
121
121
|
}
|