@entifix/testing-e2e 0.1.0-0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/dist/fixtures/entity-backend.d.ts +54 -0
- package/dist/fixtures/entity-backend.d.ts.map +1 -0
- package/dist/fixtures/entity-backend.js +130 -0
- package/dist/fixtures/entity-backend.js.map +1 -0
- package/dist/fixtures/entity-handlers.d.ts +32 -0
- package/dist/fixtures/entity-handlers.d.ts.map +1 -0
- package/dist/fixtures/entity-handlers.js +39 -0
- package/dist/fixtures/entity-handlers.js.map +1 -0
- package/dist/fixtures/fake-infrastructure.d.ts +61 -0
- package/dist/fixtures/fake-infrastructure.d.ts.map +1 -0
- package/dist/fixtures/fake-infrastructure.js +77 -0
- package/dist/fixtures/fake-infrastructure.js.map +1 -0
- package/dist/fixtures/index.d.ts +5 -0
- package/dist/fixtures/index.d.ts.map +1 -0
- package/dist/fixtures/index.js +6 -0
- package/dist/fixtures/index.js.map +1 -0
- package/dist/fixtures/signing-key.d.ts +27 -0
- package/dist/fixtures/signing-key.d.ts.map +1 -0
- package/dist/fixtures/signing-key.js +91 -0
- package/dist/fixtures/signing-key.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/playwright/assert-expected-server.d.ts +43 -0
- package/dist/playwright/assert-expected-server.d.ts.map +1 -0
- package/dist/playwright/assert-expected-server.js +98 -0
- package/dist/playwright/assert-expected-server.js.map +1 -0
- package/dist/playwright/config.d.ts +78 -0
- package/dist/playwright/config.d.ts.map +1 -0
- package/dist/playwright/config.js +96 -0
- package/dist/playwright/config.js.map +1 -0
- package/dist/playwright/entity-table.d.ts +69 -0
- package/dist/playwright/entity-table.d.ts.map +1 -0
- package/dist/playwright/entity-table.js +129 -0
- package/dist/playwright/entity-table.js.map +1 -0
- package/dist/playwright/index.d.ts +6 -0
- package/dist/playwright/index.d.ts.map +1 -0
- package/dist/playwright/index.js +7 -0
- package/dist/playwright/index.js.map +1 -0
- package/dist/playwright/session.d.ts +85 -0
- package/dist/playwright/session.d.ts.map +1 -0
- package/dist/playwright/session.js +250 -0
- package/dist/playwright/session.js.map +1 -0
- package/dist/playwright/test.d.ts +51 -0
- package/dist/playwright/test.d.ts.map +1 -0
- package/dist/playwright/test.js +79 -0
- package/dist/playwright/test.js.map +1 -0
- package/dist/profile/profile.d.ts +43 -0
- package/dist/profile/profile.d.ts.map +1 -0
- package/dist/profile/profile.js +51 -0
- package/dist/profile/profile.js.map +1 -0
- package/dist/service/define-service-e2e.d.ts +54 -0
- package/dist/service/define-service-e2e.d.ts.map +1 -0
- package/dist/service/define-service-e2e.js +57 -0
- package/dist/service/define-service-e2e.js.map +1 -0
- package/dist/service/index.d.ts +2 -0
- package/dist/service/index.d.ts.map +1 -0
- package/dist/service/index.js +3 -0
- package/dist/service/index.js.map +1 -0
- package/package.json +131 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 r10c Technologies
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# @entifix/testing-e2e
|
|
2
|
+
|
|
3
|
+
The e2e layer: one set of journeys, two profiles. Test-only — private, no build
|
|
4
|
+
target, resolves straight to source through the `@r10c/source` condition. Depend
|
|
5
|
+
on it as a `devDependency`.
|
|
6
|
+
|
|
7
|
+
## `E2E_PROFILE`
|
|
8
|
+
|
|
9
|
+
| Profile | What is real | Needs infrastructure | Where it runs |
|
|
10
|
+
| ---------------- | -------------------------------------- | -------------------- | ------------------ |
|
|
11
|
+
| `mock` (default) | the app / the service under test | no | every pull request |
|
|
12
|
+
| `live` | everything, down to Mongo and RabbitMQ | yes | locally, on demand |
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
pnpm nx e2e marketplace-admin-app-e2e # mock, the default
|
|
16
|
+
E2E_PROFILE=live MARKETPLACE_ADMIN_SERVICE_URL=http://localhost:3101 \
|
|
17
|
+
pnpm nx e2e marketplace-admin-app-e2e
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`mock` is the default because the default has to run anywhere. `live` never
|
|
21
|
+
falls back: a missing target URL **throws**, because a suite that skips itself
|
|
22
|
+
reports green for a run that tested nothing.
|
|
23
|
+
|
|
24
|
+
## What `mock` is not
|
|
25
|
+
|
|
26
|
+
It is not a stub that answers every filter with all the rows. `makeEntityBackend`
|
|
27
|
+
is assembled from the **production query pipeline**:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
parseLoadRequestParams → loadUCFactory → makeMongoRepository → fake mongo driver
|
|
31
|
+
(core: RSQL + the (business) (mongo-client: (testing-unit/drivers)
|
|
32
|
+
filterable/sortable filter translation)
|
|
33
|
+
allowlist)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
So RSQL, sorting, paging and the `400 invalid query` the metadata allowlist
|
|
37
|
+
produces behave identically in both profiles — which is what lets one spec suite
|
|
38
|
+
serve both. A mock backend with its _own_ query semantics would disagree with
|
|
39
|
+
the service the first time an operator was added.
|
|
40
|
+
|
|
41
|
+
## Spec layout
|
|
42
|
+
|
|
43
|
+
Selection is by filename, enforced by the config presets (`testIgnore` for
|
|
44
|
+
Playwright, `exclude` for Vitest) — never by an in-spec `test.skip`, so a run
|
|
45
|
+
with the wrong environment fails loudly.
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
src/
|
|
49
|
+
<journey>.spec.ts profile-agnostic — runs in BOTH profiles
|
|
50
|
+
<journey>.mock.spec.ts mock-only: wire assertions, injected failures
|
|
51
|
+
<journey>.live.spec.ts live-only: real infra, seeded data
|
|
52
|
+
support/ per-project fixtures and seed
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Put a journey in `*.spec.ts` unless it _cannot_ run in both. Wire-shape
|
|
56
|
+
assertions (the exact `rsql=` string) and injected transport failures are
|
|
57
|
+
mock-only by nature; assertions about real seeded data are live-only.
|
|
58
|
+
|
|
59
|
+
## Entry points
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { resolveE2eProfile, isMockProfile, requireLiveUrl } from '@entifix/testing-e2e';
|
|
63
|
+
import { entityBackendHandlers, configurationHandler } from '@entifix/testing-e2e/fixtures';
|
|
64
|
+
import { defineServiceE2e } from '@entifix/testing-e2e/service';
|
|
65
|
+
import { defineEntifixE2eConfig, defineEntifixE2eTest, EntityTablePage } from '@entifix/testing-e2e/playwright';
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`.` carries no Playwright, no msw and no vitest, so a consumer pulls only what
|
|
69
|
+
its transport needs.
|
|
70
|
+
|
|
71
|
+
### App e2e (Playwright)
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
// playwright.config.ts
|
|
75
|
+
export default defineEntifixE2eConfig({
|
|
76
|
+
configFile: __filename,
|
|
77
|
+
appDir: 'apps/marketplace-admin-app',
|
|
78
|
+
port: 3001,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// src/support/fixtures.ts
|
|
82
|
+
const { handlers, backend } = entityBackendHandlers(ProductBrand, { baseUrl, seed });
|
|
83
|
+
export const test = defineEntifixE2eTest({ handlers: [...handlers, configurationHandler(...)] });
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Playwright starts the app itself (`next start`), never `nx run <app>:start`: an
|
|
87
|
+
nx invocation in `webServer.command` is inferred by `@nx/playwright/plugin` as a
|
|
88
|
+
_continuous task dependency_ of `e2e`, and the run then waits forever on a
|
|
89
|
+
server that never completes. The build is expressed as a plain `dependsOn` on
|
|
90
|
+
the e2e target instead.
|
|
91
|
+
|
|
92
|
+
Interception is `@msw/playwright` over Playwright's `page.route()`. The
|
|
93
|
+
application is untouched: no worker script, no `NEXT_PUBLIC_*` switch, no
|
|
94
|
+
mocking code in the shipped bundle. Unhandled requests **error** rather than
|
|
95
|
+
escaping to the network.
|
|
96
|
+
|
|
97
|
+
Two stubs are always needed, not one: the entity endpoint _and_ the app's
|
|
98
|
+
`/api/config`, because the REST adapters resolve their base URL through the
|
|
99
|
+
latter before they can issue the former. `configurationHandler` exists so that
|
|
100
|
+
is not something each suite has to remember.
|
|
101
|
+
|
|
102
|
+
### Service e2e (Vitest)
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
const service = defineServiceE2e({
|
|
106
|
+
liveUrlEnvVar: 'MARKETPLACE_ADMIN_SERVICE_URL',
|
|
107
|
+
startMock: () => serveTestService({ name, port: 0, router, appLayer: MockAppLayer }),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('lists the catalog', async () => {
|
|
111
|
+
const response = await service.client.get('/api/product-brand');
|
|
112
|
+
});
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
In `mock`, `serveTestService` (from `@entifix/service-shell`) boots the
|
|
116
|
+
service's **real router** through the **real `makeServerLayer`** on an ephemeral
|
|
117
|
+
port, with only the `appLayer` swapped for one built from the driver fakes. The
|
|
118
|
+
routes, the use-cases, the repository adapter and the query translation all
|
|
119
|
+
execute — what is absent is the infrastructure, not the service.
|
|
120
|
+
|
|
121
|
+
## Why the e2e targets set `NODE_OPTIONS`
|
|
122
|
+
|
|
123
|
+
`nx.json` gives every `e2e` target `NODE_OPTIONS=--conditions=@r10c/source`.
|
|
124
|
+
|
|
125
|
+
Vitest applies the workspace's `@r10c/source` condition through its own
|
|
126
|
+
`resolve.conditions` (see `vitest.shared.mts`), but Playwright resolves specs
|
|
127
|
+
with plain Node, which knows nothing about it — so a spec importing a workspace
|
|
128
|
+
package would resolve to its `dist/` and fail with `Cannot find module …/dist/index.js`
|
|
129
|
+
on any machine that has not built that package. Passing the condition to Node
|
|
130
|
+
keeps Playwright on the same source-first resolution as everything else, so the
|
|
131
|
+
e2e suites need no prior build.
|
|
132
|
+
|
|
133
|
+
This is easy to miss locally: a stale `dist/` from an earlier build makes the
|
|
134
|
+
broken resolution look like it works, and it only fails on a clean checkout —
|
|
135
|
+
which is exactly what CI is.
|
|
136
|
+
|
|
137
|
+
## Layering note
|
|
138
|
+
|
|
139
|
+
This package sits under `entifix`, so it must not import from `shells` or
|
|
140
|
+
`apps`. That is why `defineServiceE2e` takes a `startMock` callback instead of
|
|
141
|
+
booting the service itself: the e2e project — which may depend on both — is what
|
|
142
|
+
puts `serveTestService` and its service's `router` together.
|
|
143
|
+
|
|
144
|
+
## Relationship to `@entifix/testing-unit`
|
|
145
|
+
|
|
146
|
+
`testing-unit` owns doubles, port contract suites and msw handlers for **unit**
|
|
147
|
+
specs; this package owns **profiles, fixtures and drivers** for e2e. It depends
|
|
148
|
+
on `testing-unit` (the fake Mongo driver is the same one), never the reverse.
|
|
149
|
+
`testing-unit/http`'s `entityRestHandlers` stays the naive in-array fixture for
|
|
150
|
+
unit specs — the faithful pipeline-backed one lives here.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type Entity, type EntityConstructor, type EntityId } from '@entifix/core';
|
|
2
|
+
import { type FakeMongoDb } from '@entifix/testing-unit/drivers';
|
|
3
|
+
/**
|
|
4
|
+
* A response as an HTTP transport would send it. `body` is an entifix envelope
|
|
5
|
+
* or an error record; it stays `unknown` here so the backend is not coupled to
|
|
6
|
+
* one transport's body type.
|
|
7
|
+
*/
|
|
8
|
+
export interface BackendResponse {
|
|
9
|
+
readonly status: number;
|
|
10
|
+
readonly body: unknown;
|
|
11
|
+
}
|
|
12
|
+
/** Rows as they are stored — the entity wire shape, exactly like the seed. */
|
|
13
|
+
export type BackendRow = Record<string, unknown>;
|
|
14
|
+
export interface EntityBackendOptions {
|
|
15
|
+
/** Initial documents, in the entity wire shape. */
|
|
16
|
+
seed?: ReadonlyArray<BackendRow>;
|
|
17
|
+
}
|
|
18
|
+
export interface EntityBackend {
|
|
19
|
+
/** The entity `key` — collection name and REST path segment alike. */
|
|
20
|
+
readonly key: string;
|
|
21
|
+
/** `GET /api/<key>?<search>` — filtering, sorting and paging included. */
|
|
22
|
+
list(search: URLSearchParams): Promise<BackendResponse>;
|
|
23
|
+
/** `GET /api/<key>/:id`. */
|
|
24
|
+
get(id: EntityId): Promise<BackendResponse>;
|
|
25
|
+
/** Replaces the stored rows, so one fixture can serve several journeys. */
|
|
26
|
+
seed(rows: ReadonlyArray<BackendRow>): void;
|
|
27
|
+
/** The rows currently stored, for assertions about what a write did. */
|
|
28
|
+
rows(): BackendRow[];
|
|
29
|
+
/** The underlying driver fake, for failure injection. */
|
|
30
|
+
readonly db: FakeMongoDb;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A read backend for one entity, assembled from the PRODUCTION query pipeline:
|
|
34
|
+
*
|
|
35
|
+
* ```
|
|
36
|
+
* parseLoadRequestParams → loadUCFactory → makeMongoRepository → fake driver
|
|
37
|
+
* (core: RSQL + the (business: (mongo-client: (testing-unit)
|
|
38
|
+
* filterable/sortable the use-case) filter translation)
|
|
39
|
+
* allowlist)
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* Nothing here re-implements query semantics. That is the whole point: a mock
|
|
43
|
+
* backend that answers a filter with "all the rows" makes a green `mock` run
|
|
44
|
+
* meaningless, and a mock backend that implements its *own* RSQL makes the two
|
|
45
|
+
* profiles disagree the first time an operator is added. Because the real
|
|
46
|
+
* pipeline runs, `mock` and `live` agree on filtering, sorting, paging, and on
|
|
47
|
+
* the `400 invalid query` the metadata allowlist produces.
|
|
48
|
+
*
|
|
49
|
+
* Writes are deliberately absent: a `POST` in the admin service is a
|
|
50
|
+
* *transaction* answering `202`, and a fixture that answered `201` instead
|
|
51
|
+
* would be a lie about the protocol rather than a stand-in for it.
|
|
52
|
+
*/
|
|
53
|
+
export declare const makeEntityBackend: <TEntity extends Entity>(entityConstructor: EntityConstructor<TEntity>, { seed }?: EntityBackendOptions) => EntityBackend;
|
|
54
|
+
//# sourceMappingURL=entity-backend.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entity-backend.d.ts","sourceRoot":"","sources":["../../src/fixtures/entity-backend.ts"],"names":[],"mappings":"AAOA,OAAO,EAEL,KAAK,MAAM,EACX,KAAK,iBAAiB,EACtB,KAAK,QAAQ,EAMd,MAAM,eAAe,CAAC;AAGvB,OAAO,EACL,KAAK,WAAW,EAEjB,MAAM,+BAA+B,CAAC;AAGvC;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;CACxB;AAED,8EAA8E;AAC9E,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEjD,MAAM,WAAW,oBAAoB;IACnC,mDAAmD;IACnD,IAAI,CAAC,EAAE,aAAa,CAAC,UAAU,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,aAAa;IAC5B,sEAAsE;IACtE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,0EAA0E;IAC1E,IAAI,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;IACxD,4BAA4B;IAC5B,GAAG,CAAC,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;IAC5C,2EAA2E;IAC3E,IAAI,CAAC,IAAI,EAAE,aAAa,CAAC,UAAU,CAAC,GAAG,IAAI,CAAC;IAC5C,wEAAwE;IACxE,IAAI,IAAI,UAAU,EAAE,CAAC;IACrB,yDAAyD;IACzD,QAAQ,CAAC,EAAE,EAAE,WAAW,CAAC;CAC1B;AAcD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,iBAAiB,GAAI,OAAO,SAAS,MAAM,EACtD,mBAAmB,iBAAiB,CAAC,OAAO,CAAC,EAC7C,WAAe,oBAAyB,KACvC,aAsGF,CAAC"}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { EntityIdTag, EntityLoadRequestTag, EntityRepositoryTag, getUCFactory, loadUCFactory } from '@entifix/business';
|
|
3
|
+
import { envelopeEntityName, makeEntityEnvelope, makeEntityPageEnvelope, parseLoadRequestParams } from '@entifix/core';
|
|
4
|
+
import { makeMongoRepository } from '@entifix/mongo';
|
|
5
|
+
import { stubConfigurationLayer } from '@entifix/testing-unit';
|
|
6
|
+
import { makeFakeMongoDb } from '@entifix/testing-unit/drivers';
|
|
7
|
+
import { Effect } from 'effect';
|
|
8
|
+
const collectionLinks = (key)=>[
|
|
9
|
+
{
|
|
10
|
+
rel: 'self',
|
|
11
|
+
href: `/api/${key}`,
|
|
12
|
+
method: 'GET'
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
rel: 'create',
|
|
16
|
+
href: `/api/${key}`,
|
|
17
|
+
method: 'POST'
|
|
18
|
+
}
|
|
19
|
+
];
|
|
20
|
+
const entityLinks = (key, id)=>[
|
|
21
|
+
{
|
|
22
|
+
rel: 'self',
|
|
23
|
+
href: `/api/${key}/${String(id)}`,
|
|
24
|
+
method: 'GET'
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
rel: 'list',
|
|
28
|
+
href: `/api/${key}`,
|
|
29
|
+
method: 'GET'
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
rel: 'update',
|
|
33
|
+
href: `/api/${key}/${String(id)}`,
|
|
34
|
+
method: 'PUT'
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
rel: 'delete',
|
|
38
|
+
href: `/api/${key}/${String(id)}`,
|
|
39
|
+
method: 'DELETE'
|
|
40
|
+
}
|
|
41
|
+
];
|
|
42
|
+
/**
|
|
43
|
+
* A read backend for one entity, assembled from the PRODUCTION query pipeline:
|
|
44
|
+
*
|
|
45
|
+
* ```
|
|
46
|
+
* parseLoadRequestParams → loadUCFactory → makeMongoRepository → fake driver
|
|
47
|
+
* (core: RSQL + the (business: (mongo-client: (testing-unit)
|
|
48
|
+
* filterable/sortable the use-case) filter translation)
|
|
49
|
+
* allowlist)
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* Nothing here re-implements query semantics. That is the whole point: a mock
|
|
53
|
+
* backend that answers a filter with "all the rows" makes a green `mock` run
|
|
54
|
+
* meaningless, and a mock backend that implements its *own* RSQL makes the two
|
|
55
|
+
* profiles disagree the first time an operator is added. Because the real
|
|
56
|
+
* pipeline runs, `mock` and `live` agree on filtering, sorting, paging, and on
|
|
57
|
+
* the `400 invalid query` the metadata allowlist produces.
|
|
58
|
+
*
|
|
59
|
+
* Writes are deliberately absent: a `POST` in the admin service is a
|
|
60
|
+
* *transaction* answering `202`, and a fixture that answered `201` instead
|
|
61
|
+
* would be a lie about the protocol rather than a stand-in for it.
|
|
62
|
+
*/ export const makeEntityBackend = (entityConstructor, { seed = [] } = {})=>{
|
|
63
|
+
const key = envelopeEntityName(entityConstructor);
|
|
64
|
+
const db = makeFakeMongoDb({
|
|
65
|
+
[key]: seed.map((row)=>_extends({}, row))
|
|
66
|
+
});
|
|
67
|
+
const repository = makeMongoRepository(// The driver fake is typed `unknown` so `testing-unit` need not depend on
|
|
68
|
+
// the mongodb types; the cast is the documented way to hand it over.
|
|
69
|
+
db.db, entityConstructor);
|
|
70
|
+
/** Both use-cases need a configuration store in their requirement channel. */ const run = (effect)=>Effect.runPromise(effect);
|
|
71
|
+
const provided = (effect)=>Effect.provide(effect, stubConfigurationLayer());
|
|
72
|
+
return {
|
|
73
|
+
key,
|
|
74
|
+
list: async (search)=>{
|
|
75
|
+
let request;
|
|
76
|
+
try {
|
|
77
|
+
request = parseLoadRequestParams(entityConstructor, search);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// The same 400 the service answers: an unparseable expression, or a
|
|
80
|
+
// member the entity never declared filterable/sortable. The codec only
|
|
81
|
+
// ever throws `EntifixBuildError` here, so there is no second case to
|
|
82
|
+
// discriminate — anything else would be a bug in the codec, and
|
|
83
|
+
// reporting it verbatim is more useful than hiding it behind a 500.
|
|
84
|
+
return {
|
|
85
|
+
status: 400,
|
|
86
|
+
body: {
|
|
87
|
+
error: 'invalid query',
|
|
88
|
+
detail: String(error)
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const page = await run(provided(loadUCFactory().pipe(Effect.provideService(EntityRepositoryTag, repository), Effect.provideService(EntityLoadRequestTag, request))));
|
|
94
|
+
return {
|
|
95
|
+
status: 200,
|
|
96
|
+
body: makeEntityPageEnvelope(entityConstructor, page, collectionLinks(key))
|
|
97
|
+
};
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// A repository that fails is a 500, exactly as in the service: the
|
|
100
|
+
// request was valid, the store was not reachable.
|
|
101
|
+
return {
|
|
102
|
+
status: 500,
|
|
103
|
+
body: {
|
|
104
|
+
error: 'request failed',
|
|
105
|
+
detail: String(error)
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
get: async (id)=>{
|
|
111
|
+
const entity = await run(provided(getUCFactory().pipe(Effect.provideService(EntityRepositoryTag, repository), Effect.provideService(EntityIdTag, id))).pipe(Effect.option));
|
|
112
|
+
return entity._tag === 'None' ? {
|
|
113
|
+
status: 404,
|
|
114
|
+
body: {
|
|
115
|
+
message: `${key} not found`
|
|
116
|
+
}
|
|
117
|
+
} : {
|
|
118
|
+
status: 200,
|
|
119
|
+
body: makeEntityEnvelope(entityConstructor, entity.value, entityLinks(key, id))
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
seed: (rows)=>{
|
|
123
|
+
db.seed(key, rows.map((row)=>_extends({}, row)));
|
|
124
|
+
},
|
|
125
|
+
rows: ()=>db.read(key),
|
|
126
|
+
db
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
//# sourceMappingURL=entity-backend.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/fixtures/entity-backend.ts"],"sourcesContent":["import {\n EntityIdTag,\n EntityLoadRequestTag,\n EntityRepositoryTag,\n getUCFactory,\n loadUCFactory,\n} from '@entifix/business';\nimport {\n type EntifixEnvelopeLink,\n type Entity,\n type EntityConstructor,\n type EntityId,\n type EntityLoadRequest,\n envelopeEntityName,\n makeEntityEnvelope,\n makeEntityPageEnvelope,\n parseLoadRequestParams,\n} from '@entifix/core';\nimport { makeMongoRepository } from '@entifix/mongo';\nimport { stubConfigurationLayer } from '@entifix/testing-unit';\nimport {\n type FakeMongoDb,\n makeFakeMongoDb,\n} from '@entifix/testing-unit/drivers';\nimport { Effect } from 'effect';\n\n/**\n * A response as an HTTP transport would send it. `body` is an entifix envelope\n * or an error record; it stays `unknown` here so the backend is not coupled to\n * one transport's body type.\n */\nexport interface BackendResponse {\n readonly status: number;\n readonly body: unknown;\n}\n\n/** Rows as they are stored — the entity wire shape, exactly like the seed. */\nexport type BackendRow = Record<string, unknown>;\n\nexport interface EntityBackendOptions {\n /** Initial documents, in the entity wire shape. */\n seed?: ReadonlyArray<BackendRow>;\n}\n\nexport interface EntityBackend {\n /** The entity `key` — collection name and REST path segment alike. */\n readonly key: string;\n /** `GET /api/<key>?<search>` — filtering, sorting and paging included. */\n list(search: URLSearchParams): Promise<BackendResponse>;\n /** `GET /api/<key>/:id`. */\n get(id: EntityId): Promise<BackendResponse>;\n /** Replaces the stored rows, so one fixture can serve several journeys. */\n seed(rows: ReadonlyArray<BackendRow>): void;\n /** The rows currently stored, for assertions about what a write did. */\n rows(): BackendRow[];\n /** The underlying driver fake, for failure injection. */\n readonly db: FakeMongoDb;\n}\n\nconst collectionLinks = (key: string): EntifixEnvelopeLink[] => [\n { rel: 'self', href: `/api/${key}`, method: 'GET' },\n { rel: 'create', href: `/api/${key}`, method: 'POST' },\n];\n\nconst entityLinks = (key: string, id: EntityId): EntifixEnvelopeLink[] => [\n { rel: 'self', href: `/api/${key}/${String(id)}`, method: 'GET' },\n { rel: 'list', href: `/api/${key}`, method: 'GET' },\n { rel: 'update', href: `/api/${key}/${String(id)}`, method: 'PUT' },\n { rel: 'delete', href: `/api/${key}/${String(id)}`, method: 'DELETE' },\n];\n\n/**\n * A read backend for one entity, assembled from the PRODUCTION query pipeline:\n *\n * ```\n * parseLoadRequestParams → loadUCFactory → makeMongoRepository → fake driver\n * (core: RSQL + the (business: (mongo-client: (testing-unit)\n * filterable/sortable the use-case) filter translation)\n * allowlist)\n * ```\n *\n * Nothing here re-implements query semantics. That is the whole point: a mock\n * backend that answers a filter with \"all the rows\" makes a green `mock` run\n * meaningless, and a mock backend that implements its *own* RSQL makes the two\n * profiles disagree the first time an operator is added. Because the real\n * pipeline runs, `mock` and `live` agree on filtering, sorting, paging, and on\n * the `400 invalid query` the metadata allowlist produces.\n *\n * Writes are deliberately absent: a `POST` in the admin service is a\n * *transaction* answering `202`, and a fixture that answered `201` instead\n * would be a lie about the protocol rather than a stand-in for it.\n */\nexport const makeEntityBackend = <TEntity extends Entity>(\n entityConstructor: EntityConstructor<TEntity>,\n { seed = [] }: EntityBackendOptions = {},\n): EntityBackend => {\n const key = envelopeEntityName(entityConstructor);\n const db = makeFakeMongoDb({ [key]: seed.map(row => ({ ...row })) });\n const repository = makeMongoRepository(\n // The driver fake is typed `unknown` so `testing-unit` need not depend on\n // the mongodb types; the cast is the documented way to hand it over.\n db.db as Parameters<typeof makeMongoRepository>[0],\n entityConstructor,\n );\n\n /** Both use-cases need a configuration store in their requirement channel. */\n const run = <TValue, TError>(\n effect: Effect.Effect<TValue, TError, never>,\n ): Promise<TValue> => Effect.runPromise(effect);\n\n const provided = <TValue, TError, TContext>(\n effect: Effect.Effect<TValue, TError, TContext>,\n ) => Effect.provide(effect, stubConfigurationLayer());\n\n return {\n key,\n\n list: async search => {\n let request: EntityLoadRequest;\n try {\n request = parseLoadRequestParams(\n entityConstructor,\n search,\n ) as unknown as EntityLoadRequest;\n } catch (error) {\n // The same 400 the service answers: an unparseable expression, or a\n // member the entity never declared filterable/sortable. The codec only\n // ever throws `EntifixBuildError` here, so there is no second case to\n // discriminate — anything else would be a bug in the codec, and\n // reporting it verbatim is more useful than hiding it behind a 500.\n return {\n status: 400,\n body: { error: 'invalid query', detail: String(error) },\n };\n }\n\n try {\n const page = await run(\n provided(\n loadUCFactory<TEntity>().pipe(\n Effect.provideService(EntityRepositoryTag, repository),\n Effect.provideService(EntityLoadRequestTag, request),\n ),\n ),\n );\n\n return {\n status: 200,\n body: makeEntityPageEnvelope(\n entityConstructor,\n page,\n collectionLinks(key),\n ),\n };\n } catch (error) {\n // A repository that fails is a 500, exactly as in the service: the\n // request was valid, the store was not reachable.\n return {\n status: 500,\n body: { error: 'request failed', detail: String(error) },\n };\n }\n },\n\n get: async id => {\n const entity = await run(\n provided(\n getUCFactory<TEntity>().pipe(\n Effect.provideService(EntityRepositoryTag, repository),\n Effect.provideService(EntityIdTag, id),\n ),\n ).pipe(Effect.option),\n );\n\n return entity._tag === 'None'\n ? { status: 404, body: { message: `${key} not found` } }\n : {\n status: 200,\n body: makeEntityEnvelope(\n entityConstructor,\n entity.value,\n entityLinks(key, id),\n ),\n };\n },\n\n seed: rows => {\n db.seed(\n key,\n rows.map(row => ({ ...row })),\n );\n },\n\n rows: () => db.read(key),\n\n db,\n };\n};\n"],"names":["EntityIdTag","EntityLoadRequestTag","EntityRepositoryTag","getUCFactory","loadUCFactory","envelopeEntityName","makeEntityEnvelope","makeEntityPageEnvelope","parseLoadRequestParams","makeMongoRepository","stubConfigurationLayer","makeFakeMongoDb","Effect","collectionLinks","key","rel","href","method","entityLinks","id","String","makeEntityBackend","entityConstructor","seed","db","map","row","repository","run","effect","runPromise","provided","provide","list","search","request","error","status","body","detail","page","pipe","provideService","get","entity","option","_tag","message","value","rows","read"],"mappings":"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"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ConfigurationPlain } from '@entifix/core';
|
|
2
|
+
import type { Entity, EntityConstructor } from '@entifix/core';
|
|
3
|
+
import { type RequestHandler } from 'msw';
|
|
4
|
+
import { type EntityBackend, type EntityBackendOptions } from './entity-backend.js';
|
|
5
|
+
export interface EntityBackendHandlerOptions extends EntityBackendOptions {
|
|
6
|
+
/** The collection endpoint, e.g. `http://localhost:3101/api/product-brand`. */
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Puts an {@link makeEntityBackend} behind msw handlers, so a browser under
|
|
11
|
+
* Playwright talks to the real query pipeline over the real wire format.
|
|
12
|
+
*
|
|
13
|
+
* The backend is returned alongside the handlers because a spec usually wants
|
|
14
|
+
* both: the handlers to install, and the backend to reseed or to break with
|
|
15
|
+
* `backend.db.failWith(...)`.
|
|
16
|
+
*/
|
|
17
|
+
export declare const entityBackendHandlers: <TEntity extends Entity>(entityConstructor: EntityConstructor<TEntity>, { baseUrl, ...options }: EntityBackendHandlerOptions) => {
|
|
18
|
+
handlers: RequestHandler[];
|
|
19
|
+
backend: EntityBackend;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The configuration lookup every entifix frontend makes before it can issue a
|
|
23
|
+
* single entity request: the REST adapters resolve their base URL through it.
|
|
24
|
+
*
|
|
25
|
+
* Stubbing the entity endpoint without this one leaves the page waiting
|
|
26
|
+
* forever and the spec passing vacuously, so it is a first-class fixture rather
|
|
27
|
+
* than something each suite remembers to add.
|
|
28
|
+
*/
|
|
29
|
+
export declare const configurationHandler: (url: string, configuration: ConfigurationPlain) => RequestHandler;
|
|
30
|
+
export type { RequestHandler } from 'msw';
|
|
31
|
+
export { http, HttpResponse } from 'msw';
|
|
32
|
+
//# sourceMappingURL=entity-handlers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entity-handlers.d.ts","sourceRoot":"","sources":["../../src/fixtures/entity-handlers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AACxD,OAAO,KAAK,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAC/D,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,KAAK,CAAC;AAE9D,OAAO,EAEL,KAAK,aAAa,EAClB,KAAK,oBAAoB,EAE1B,MAAM,qBAAqB,CAAC;AAE7B,MAAM,WAAW,2BAA4B,SAAQ,oBAAoB;IACvE,+EAA+E;IAC/E,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,GAAI,OAAO,SAAS,MAAM,EAC1D,mBAAmB,iBAAiB,CAAC,OAAO,CAAC,EAC7C,yBAAyB,2BAA2B,KACnD;IAAE,QAAQ,EAAE,cAAc,EAAE,CAAC;IAAC,OAAO,EAAE,aAAa,CAAA;CAmBtD,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAC/B,KAAK,MAAM,EACX,eAAe,kBAAkB,KAChC,cAAuE,CAAC;AAE3E,YAAY,EAAE,cAAc,EAAE,MAAM,KAAK,CAAC;AAC1C,OAAO,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,KAAK,CAAC"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
|
|
2
|
+
import { http, HttpResponse } from 'msw';
|
|
3
|
+
import { makeEntityBackend } from './entity-backend.js';
|
|
4
|
+
/**
|
|
5
|
+
* Puts an {@link makeEntityBackend} behind msw handlers, so a browser under
|
|
6
|
+
* Playwright talks to the real query pipeline over the real wire format.
|
|
7
|
+
*
|
|
8
|
+
* The backend is returned alongside the handlers because a spec usually wants
|
|
9
|
+
* both: the handlers to install, and the backend to reseed or to break with
|
|
10
|
+
* `backend.db.failWith(...)`.
|
|
11
|
+
*/ export const entityBackendHandlers = (entityConstructor, _1)=>{
|
|
12
|
+
let { baseUrl } = _1, options = _object_without_properties_loose(_1, [
|
|
13
|
+
"baseUrl"
|
|
14
|
+
]);
|
|
15
|
+
const backend = makeEntityBackend(entityConstructor, options);
|
|
16
|
+
// Every body is an envelope or an error record — JSON either way. The cast is
|
|
17
|
+
// the one place the backend's transport-free `unknown` meets msw's body type.
|
|
18
|
+
const respond = ({ status, body })=>HttpResponse.json(body, {
|
|
19
|
+
status
|
|
20
|
+
});
|
|
21
|
+
return {
|
|
22
|
+
backend,
|
|
23
|
+
handlers: [
|
|
24
|
+
http.get(baseUrl, async ({ request })=>respond(await backend.list(new URL(request.url).searchParams))),
|
|
25
|
+
http.get(`${baseUrl}/:id`, async ({ params })=>respond(await backend.get(String(params['id']))))
|
|
26
|
+
]
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The configuration lookup every entifix frontend makes before it can issue a
|
|
31
|
+
* single entity request: the REST adapters resolve their base URL through it.
|
|
32
|
+
*
|
|
33
|
+
* Stubbing the entity endpoint without this one leaves the page waiting
|
|
34
|
+
* forever and the spec passing vacuously, so it is a first-class fixture rather
|
|
35
|
+
* than something each suite remembers to add.
|
|
36
|
+
*/ export const configurationHandler = (url, configuration)=>http.get(url, ()=>HttpResponse.json(configuration));
|
|
37
|
+
export { http, HttpResponse } from 'msw';
|
|
38
|
+
|
|
39
|
+
//# sourceMappingURL=entity-handlers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/fixtures/entity-handlers.ts"],"sourcesContent":["import type { ConfigurationPlain } from '@entifix/core';\nimport type { Entity, EntityConstructor } from '@entifix/core';\nimport { http, HttpResponse, type RequestHandler } from 'msw';\n\nimport {\n type BackendResponse,\n type EntityBackend,\n type EntityBackendOptions,\n makeEntityBackend,\n} from './entity-backend.js';\n\nexport interface EntityBackendHandlerOptions extends EntityBackendOptions {\n /** The collection endpoint, e.g. `http://localhost:3101/api/product-brand`. */\n baseUrl: string;\n}\n\n/**\n * Puts an {@link makeEntityBackend} behind msw handlers, so a browser under\n * Playwright talks to the real query pipeline over the real wire format.\n *\n * The backend is returned alongside the handlers because a spec usually wants\n * both: the handlers to install, and the backend to reseed or to break with\n * `backend.db.failWith(...)`.\n */\nexport const entityBackendHandlers = <TEntity extends Entity>(\n entityConstructor: EntityConstructor<TEntity>,\n { baseUrl, ...options }: EntityBackendHandlerOptions,\n): { handlers: RequestHandler[]; backend: EntityBackend } => {\n const backend = makeEntityBackend(entityConstructor, options);\n\n // Every body is an envelope or an error record — JSON either way. The cast is\n // the one place the backend's transport-free `unknown` meets msw's body type.\n const respond = ({ status, body }: BackendResponse) =>\n HttpResponse.json(body as Record<string, unknown>, { status });\n\n return {\n backend,\n handlers: [\n http.get(baseUrl, async ({ request }) =>\n respond(await backend.list(new URL(request.url).searchParams)),\n ),\n http.get(`${baseUrl}/:id`, async ({ params }) =>\n respond(await backend.get(String(params['id']))),\n ),\n ],\n };\n};\n\n/**\n * The configuration lookup every entifix frontend makes before it can issue a\n * single entity request: the REST adapters resolve their base URL through it.\n *\n * Stubbing the entity endpoint without this one leaves the page waiting\n * forever and the spec passing vacuously, so it is a first-class fixture rather\n * than something each suite remembers to add.\n */\nexport const configurationHandler = (\n url: string,\n configuration: ConfigurationPlain,\n): RequestHandler => http.get(url, () => HttpResponse.json(configuration));\n\nexport type { RequestHandler } from 'msw';\nexport { http, HttpResponse } from 'msw';\n"],"names":["http","HttpResponse","makeEntityBackend","entityBackendHandlers","entityConstructor","baseUrl","options","backend","respond","status","body","json","handlers","get","request","list","URL","url","searchParams","params","String","configurationHandler","configuration"],"mappings":";AAEA,SAASA,IAAI,EAAEC,YAAY,QAA6B,MAAM;AAE9D,SAIEC,iBAAiB,QACZ,sBAAsB;AAO7B;;;;;;;CAOC,GACD,OAAO,MAAMC,wBAAwB,CACnCC;QACA,EAAEC,OAAO,EAA2C,OAAtCC;;;IAEd,MAAMC,UAAUL,kBAAkBE,mBAAmBE;IAErD,8EAA8E;IAC9E,8EAA8E;IAC9E,MAAME,UAAU,CAAC,EAAEC,MAAM,EAAEC,IAAI,EAAmB,GAChDT,aAAaU,IAAI,CAACD,MAAiC;YAAED;QAAO;IAE9D,OAAO;QACLF;QACAK,UAAU;YACRZ,KAAKa,GAAG,CAACR,SAAS,OAAO,EAAES,OAAO,EAAE,GAClCN,QAAQ,MAAMD,QAAQQ,IAAI,CAAC,IAAIC,IAAIF,QAAQG,GAAG,EAAEC,YAAY;YAE9DlB,KAAKa,GAAG,CAAC,GAAGR,QAAQ,IAAI,CAAC,EAAE,OAAO,EAAEc,MAAM,EAAE,GAC1CX,QAAQ,MAAMD,QAAQM,GAAG,CAACO,OAAOD,MAAM,CAAC,KAAK;SAEhD;IACH;AACF,EAAE;AAEF;;;;;;;CAOC,GACD,OAAO,MAAME,uBAAuB,CAClCJ,KACAK,gBACmBtB,KAAKa,GAAG,CAACI,KAAK,IAAMhB,aAAaU,IAAI,CAACW,gBAAgB;AAG3E,SAAStB,IAAI,EAAEC,YAAY,QAAQ,MAAM"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { AmqpChannelTag } from '@entifix/amqp';
|
|
2
|
+
import { ConfigurationRepositoryTag, TenantDatabaseResolverTag } from '@entifix/business';
|
|
3
|
+
import { type ConfigurationPlain } from '@entifix/core';
|
|
4
|
+
import { MongoClientTag, MongoDatabaseTag } from '@entifix/mongo';
|
|
5
|
+
import { RedisTag } from '@entifix/redis';
|
|
6
|
+
import { type FakeAmqpChannel, type FakeMongoDb, type FakeRedis } from '@entifix/testing-unit/drivers';
|
|
7
|
+
import { Layer } from 'effect';
|
|
8
|
+
import type { BackendRow } from './entity-backend.js';
|
|
9
|
+
/**
|
|
10
|
+
* The driver fakes, wired as the Layers a service's composition root provides.
|
|
11
|
+
*
|
|
12
|
+
* These stand in for the *connections*, one level below the adapters — so the
|
|
13
|
+
* Mongo repository, the Redis lock and sequence services and the AMQP event bus
|
|
14
|
+
* all still run their own code. Substituting at the port level instead would
|
|
15
|
+
* leave the filter translation, the `SET NX PX` and the envelope framing
|
|
16
|
+
* unexecuted while the suite reported the service as covered.
|
|
17
|
+
*
|
|
18
|
+
* Each helper returns the fake alongside its Layer, because a spec usually
|
|
19
|
+
* wants both: the Layer to boot with, the fake to seed, inspect or break.
|
|
20
|
+
*
|
|
21
|
+
* The casts are `never` on purpose: it keeps this package free of the mongodb,
|
|
22
|
+
* ioredis and amqplib type packages while staying honest that these are not
|
|
23
|
+
* real drivers.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* A driver fake and the Layer that provides it. `TService` is the tag the Layer
|
|
27
|
+
* satisfies, so `Effect.provide` actually discharges the requirement — typing
|
|
28
|
+
* the Layer's output as `never` would leave every consumer still asking for it.
|
|
29
|
+
*/
|
|
30
|
+
export interface FakeInfrastructure<TDriver, TService> {
|
|
31
|
+
readonly driver: TDriver;
|
|
32
|
+
readonly layer: Layer.Layer<TService>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The Mongo fake, provided under all three tags a service may ask for: the
|
|
36
|
+
* shared `Db`, the `MongoClient` (the pool), and the tenant resolver.
|
|
37
|
+
*
|
|
38
|
+
* Every organization resolves to the **same** in-memory store, which is the
|
|
39
|
+
* honest mock of db-per-organization: there is one fake, so isolation is not
|
|
40
|
+
* what this profile tests. That is deliberate — a `mock` run proves routing,
|
|
41
|
+
* guards and query translation; only a live run against real Mongo can prove
|
|
42
|
+
* that two organizations' collections actually live apart, and that is what the
|
|
43
|
+
* `mongosh` check in the tenancy verification does.
|
|
44
|
+
*/
|
|
45
|
+
export declare const fakeMongoLayer: (seed?: Record<string, ReadonlyArray<BackendRow>>) => FakeInfrastructure<FakeMongoDb, MongoClientTag | MongoDatabaseTag | TenantDatabaseResolverTag>;
|
|
46
|
+
export declare const fakeRedisLayer: () => FakeInfrastructure<FakeRedis, RedisTag>;
|
|
47
|
+
/**
|
|
48
|
+
* The tag carries an `AmqpConnector`, not a channel — a held channel does not
|
|
49
|
+
* survive the broker restarting, so the real adapter asks for one per call. The
|
|
50
|
+
* fixture therefore has to supply that indirection rather than handing the fake
|
|
51
|
+
* channel over directly: a layer that provided the channel would satisfy the
|
|
52
|
+
* type through a cast and then fail at the first `withChannel` call.
|
|
53
|
+
*/
|
|
54
|
+
export declare const fakeAmqpLayer: () => FakeInfrastructure<FakeAmqpChannel, AmqpChannelTag>;
|
|
55
|
+
/**
|
|
56
|
+
* The configuration a service would otherwise fetch from config-service at
|
|
57
|
+
* boot, provided directly. The `mock` profile has no config-service, and going
|
|
58
|
+
* through one would test the platform rather than the service under test.
|
|
59
|
+
*/
|
|
60
|
+
export declare const fakeConfigurationLayer: (plain?: ConfigurationPlain) => Layer.Layer<ConfigurationRepositoryTag>;
|
|
61
|
+
//# sourceMappingURL=fake-infrastructure.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fake-infrastructure.d.ts","sourceRoot":"","sources":["../../src/fixtures/fake-infrastructure.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAsB,MAAM,eAAe,CAAC;AACnE,OAAO,EACL,0BAA0B,EAC1B,yBAAyB,EAC1B,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClE,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,WAAW,EAChB,KAAK,SAAS,EAIf,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AAEvC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAEtD;;;;;;;;;;;;;;;GAeG;AAEH;;;;GAIG;AACH,MAAM,WAAW,kBAAkB,CAAC,OAAO,EAAE,QAAQ;IACnD,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;CACvC;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,cAAc,GACzB,OAAM,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,UAAU,CAAC,CAAM,KACnD,kBAAkB,CACnB,WAAW,EACX,cAAc,GAAG,gBAAgB,GAAG,yBAAyB,CAgC9D,CAAC;AAEF,eAAO,MAAM,cAAc,QAAO,kBAAkB,CAAC,SAAS,EAAE,QAAQ,CAMvE,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,aAAa,QAAO,kBAAkB,CACjD,eAAe,EACf,cAAc,CAgBf,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,GACjC,QAAO,kBAAuB,KAC7B,KAAK,CAAC,KAAK,CAAC,0BAA0B,CAItC,CAAC"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { AmqpChannelTag } from '@entifix/amqp';
|
|
3
|
+
import { ConfigurationRepositoryTag, TenantDatabaseResolverTag } from '@entifix/business';
|
|
4
|
+
import { ConfigurationClientInMemory } from '@entifix/core';
|
|
5
|
+
import { MongoClientTag, MongoDatabaseTag } from '@entifix/mongo';
|
|
6
|
+
import { RedisTag } from '@entifix/redis';
|
|
7
|
+
import { makeFakeAmqpChannel, makeFakeMongoDb, makeFakeRedis } from '@entifix/testing-unit/drivers';
|
|
8
|
+
import { Effect, Layer } from 'effect';
|
|
9
|
+
/**
|
|
10
|
+
* The Mongo fake, provided under all three tags a service may ask for: the
|
|
11
|
+
* shared `Db`, the `MongoClient` (the pool), and the tenant resolver.
|
|
12
|
+
*
|
|
13
|
+
* Every organization resolves to the **same** in-memory store, which is the
|
|
14
|
+
* honest mock of db-per-organization: there is one fake, so isolation is not
|
|
15
|
+
* what this profile tests. That is deliberate — a `mock` run proves routing,
|
|
16
|
+
* guards and query translation; only a live run against real Mongo can prove
|
|
17
|
+
* that two organizations' collections actually live apart, and that is what the
|
|
18
|
+
* `mongosh` check in the tenancy verification does.
|
|
19
|
+
*/ export const fakeMongoLayer = (seed = {})=>{
|
|
20
|
+
const db = makeFakeMongoDb(Object.fromEntries(Object.entries(seed).map(([collection, rows])=>[
|
|
21
|
+
collection,
|
|
22
|
+
rows.map((row)=>_extends({}, row))
|
|
23
|
+
])));
|
|
24
|
+
return {
|
|
25
|
+
driver: db,
|
|
26
|
+
layer: Layer.mergeAll(Layer.succeed(MongoDatabaseTag, db.db), // `client.db(name)` ignores the name for the same reason.
|
|
27
|
+
//
|
|
28
|
+
// `startSession` is forwarded rather than stubbed: a service that writes
|
|
29
|
+
// a row and folds it into a counter does both inside one transaction, so
|
|
30
|
+
// a client without it fails at the first write with a `TypeError` — and
|
|
31
|
+
// a stub that returned a no-op session would commit the two halves
|
|
32
|
+
// independently, which is precisely the crash-between-them state the
|
|
33
|
+
// transaction exists to rule out.
|
|
34
|
+
Layer.succeed(MongoClientTag, {
|
|
35
|
+
db: ()=>db.db,
|
|
36
|
+
startSession: ()=>db.startSession()
|
|
37
|
+
}), Layer.succeed(TenantDatabaseResolverTag, {
|
|
38
|
+
forOrganization: ()=>Effect.succeed(db.db)
|
|
39
|
+
}))
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
export const fakeRedisLayer = ()=>{
|
|
43
|
+
const redis = makeFakeRedis();
|
|
44
|
+
return {
|
|
45
|
+
driver: redis,
|
|
46
|
+
layer: Layer.succeed(RedisTag, redis.redis)
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* The tag carries an `AmqpConnector`, not a channel — a held channel does not
|
|
51
|
+
* survive the broker restarting, so the real adapter asks for one per call. The
|
|
52
|
+
* fixture therefore has to supply that indirection rather than handing the fake
|
|
53
|
+
* channel over directly: a layer that provided the channel would satisfy the
|
|
54
|
+
* type through a cast and then fail at the first `withChannel` call.
|
|
55
|
+
*/ export const fakeAmqpLayer = ()=>{
|
|
56
|
+
const channel = makeFakeAmqpChannel();
|
|
57
|
+
const connector = {
|
|
58
|
+
withChannel: (use)=>use(channel.channel),
|
|
59
|
+
addConsumer: async (setup)=>{
|
|
60
|
+
await setup(channel.channel);
|
|
61
|
+
},
|
|
62
|
+
// The mock profile boots and closes services in-process, so the drain runs
|
|
63
|
+
// for real against this — it simply has one channel and no broker to tell.
|
|
64
|
+
cancelConsumers: async ()=>undefined
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
driver: channel,
|
|
68
|
+
layer: Layer.succeed(AmqpChannelTag, connector)
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* The configuration a service would otherwise fetch from config-service at
|
|
73
|
+
* boot, provided directly. The `mock` profile has no config-service, and going
|
|
74
|
+
* through one would test the platform rather than the service under test.
|
|
75
|
+
*/ export const fakeConfigurationLayer = (plain = {})=>Layer.succeed(ConfigurationRepositoryTag, new ConfigurationClientInMemory(plain));
|
|
76
|
+
|
|
77
|
+
//# sourceMappingURL=fake-infrastructure.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/fixtures/fake-infrastructure.ts"],"sourcesContent":["import { AmqpChannelTag, type AmqpConnector } from '@entifix/amqp';\nimport {\n ConfigurationRepositoryTag,\n TenantDatabaseResolverTag,\n} from '@entifix/business';\nimport {\n ConfigurationClientInMemory,\n type ConfigurationPlain,\n} from '@entifix/core';\nimport { MongoClientTag, MongoDatabaseTag } from '@entifix/mongo';\nimport { RedisTag } from '@entifix/redis';\nimport {\n type FakeAmqpChannel,\n type FakeMongoDb,\n type FakeRedis,\n makeFakeAmqpChannel,\n makeFakeMongoDb,\n makeFakeRedis,\n} from '@entifix/testing-unit/drivers';\nimport { Effect, Layer } from 'effect';\n\nimport type { BackendRow } from './entity-backend.js';\n\n/**\n * The driver fakes, wired as the Layers a service's composition root provides.\n *\n * These stand in for the *connections*, one level below the adapters — so the\n * Mongo repository, the Redis lock and sequence services and the AMQP event bus\n * all still run their own code. Substituting at the port level instead would\n * leave the filter translation, the `SET NX PX` and the envelope framing\n * unexecuted while the suite reported the service as covered.\n *\n * Each helper returns the fake alongside its Layer, because a spec usually\n * wants both: the Layer to boot with, the fake to seed, inspect or break.\n *\n * The casts are `never` on purpose: it keeps this package free of the mongodb,\n * ioredis and amqplib type packages while staying honest that these are not\n * real drivers.\n */\n\n/**\n * A driver fake and the Layer that provides it. `TService` is the tag the Layer\n * satisfies, so `Effect.provide` actually discharges the requirement — typing\n * the Layer's output as `never` would leave every consumer still asking for it.\n */\nexport interface FakeInfrastructure<TDriver, TService> {\n readonly driver: TDriver;\n readonly layer: Layer.Layer<TService>;\n}\n\n/**\n * The Mongo fake, provided under all three tags a service may ask for: the\n * shared `Db`, the `MongoClient` (the pool), and the tenant resolver.\n *\n * Every organization resolves to the **same** in-memory store, which is the\n * honest mock of db-per-organization: there is one fake, so isolation is not\n * what this profile tests. That is deliberate — a `mock` run proves routing,\n * guards and query translation; only a live run against real Mongo can prove\n * that two organizations' collections actually live apart, and that is what the\n * `mongosh` check in the tenancy verification does.\n */\nexport const fakeMongoLayer = (\n seed: Record<string, ReadonlyArray<BackendRow>> = {},\n): FakeInfrastructure<\n FakeMongoDb,\n MongoClientTag | MongoDatabaseTag | TenantDatabaseResolverTag\n> => {\n const db = makeFakeMongoDb(\n Object.fromEntries(\n Object.entries(seed).map(([collection, rows]) => [\n collection,\n rows.map(row => ({ ...row })),\n ]),\n ),\n );\n\n return {\n driver: db,\n layer: Layer.mergeAll(\n Layer.succeed(MongoDatabaseTag, db.db as never),\n // `client.db(name)` ignores the name for the same reason.\n //\n // `startSession` is forwarded rather than stubbed: a service that writes\n // a row and folds it into a counter does both inside one transaction, so\n // a client without it fails at the first write with a `TypeError` — and\n // a stub that returned a no-op session would commit the two halves\n // independently, which is precisely the crash-between-them state the\n // transaction exists to rule out.\n Layer.succeed(MongoClientTag, {\n db: () => db.db,\n startSession: () => db.startSession(),\n } as never),\n Layer.succeed(TenantDatabaseResolverTag, {\n forOrganization: () => Effect.succeed(db.db),\n }),\n ),\n };\n};\n\nexport const fakeRedisLayer = (): FakeInfrastructure<FakeRedis, RedisTag> => {\n const redis = makeFakeRedis();\n return {\n driver: redis,\n layer: Layer.succeed(RedisTag, redis.redis as never),\n };\n};\n\n/**\n * The tag carries an `AmqpConnector`, not a channel — a held channel does not\n * survive the broker restarting, so the real adapter asks for one per call. The\n * fixture therefore has to supply that indirection rather than handing the fake\n * channel over directly: a layer that provided the channel would satisfy the\n * type through a cast and then fail at the first `withChannel` call.\n */\nexport const fakeAmqpLayer = (): FakeInfrastructure<\n FakeAmqpChannel,\n AmqpChannelTag\n> => {\n const channel = makeFakeAmqpChannel();\n const connector: AmqpConnector = {\n withChannel: use => use(channel.channel as never),\n addConsumer: async setup => {\n await setup(channel.channel as never);\n },\n // The mock profile boots and closes services in-process, so the drain runs\n // for real against this — it simply has one channel and no broker to tell.\n cancelConsumers: async () => undefined,\n };\n return {\n driver: channel,\n layer: Layer.succeed(AmqpChannelTag, connector),\n };\n};\n\n/**\n * The configuration a service would otherwise fetch from config-service at\n * boot, provided directly. The `mock` profile has no config-service, and going\n * through one would test the platform rather than the service under test.\n */\nexport const fakeConfigurationLayer = (\n plain: ConfigurationPlain = {},\n): Layer.Layer<ConfigurationRepositoryTag> =>\n Layer.succeed(\n ConfigurationRepositoryTag,\n new ConfigurationClientInMemory(plain),\n );\n"],"names":["AmqpChannelTag","ConfigurationRepositoryTag","TenantDatabaseResolverTag","ConfigurationClientInMemory","MongoClientTag","MongoDatabaseTag","RedisTag","makeFakeAmqpChannel","makeFakeMongoDb","makeFakeRedis","Effect","Layer","fakeMongoLayer","seed","db","Object","fromEntries","entries","map","collection","rows","row","driver","layer","mergeAll","succeed","startSession","forOrganization","fakeRedisLayer","redis","fakeAmqpLayer","channel","connector","withChannel","use","addConsumer","setup","cancelConsumers","undefined","fakeConfigurationLayer","plain"],"mappings":";AAAA,SAASA,cAAc,QAA4B,gBAAgB;AACnE,SACEC,0BAA0B,EAC1BC,yBAAyB,QACpB,oBAAoB;AAC3B,SACEC,2BAA2B,QAEtB,gBAAgB;AACvB,SAASC,cAAc,EAAEC,gBAAgB,QAAQ,iBAAiB;AAClE,SAASC,QAAQ,QAAQ,iBAAiB;AAC1C,SAIEC,mBAAmB,EACnBC,eAAe,EACfC,aAAa,QACR,gCAAgC;AACvC,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AA+BvC;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,iBAAiB,CAC5BC,OAAkD,CAAC,CAAC;IAKpD,MAAMC,KAAKN,gBACTO,OAAOC,WAAW,CAChBD,OAAOE,OAAO,CAACJ,MAAMK,GAAG,CAAC,CAAC,CAACC,YAAYC,KAAK,GAAK;YAC/CD;YACAC,KAAKF,GAAG,CAACG,CAAAA,MAAQ,aAAKA;SACvB;IAIL,OAAO;QACLC,QAAQR;QACRS,OAAOZ,MAAMa,QAAQ,CACnBb,MAAMc,OAAO,CAACpB,kBAAkBS,GAAGA,EAAE,GACrC,0DAA0D;QAC1D,EAAE;QACF,yEAAyE;QACzE,yEAAyE;QACzE,wEAAwE;QACxE,mEAAmE;QACnE,qEAAqE;QACrE,kCAAkC;QAClCH,MAAMc,OAAO,CAACrB,gBAAgB;YAC5BU,IAAI,IAAMA,GAAGA,EAAE;YACfY,cAAc,IAAMZ,GAAGY,YAAY;QACrC,IACAf,MAAMc,OAAO,CAACvB,2BAA2B;YACvCyB,iBAAiB,IAAMjB,OAAOe,OAAO,CAACX,GAAGA,EAAE;QAC7C;IAEJ;AACF,EAAE;AAEF,OAAO,MAAMc,iBAAiB;IAC5B,MAAMC,QAAQpB;IACd,OAAO;QACLa,QAAQO;QACRN,OAAOZ,MAAMc,OAAO,CAACnB,UAAUuB,MAAMA,KAAK;IAC5C;AACF,EAAE;AAEF;;;;;;CAMC,GACD,OAAO,MAAMC,gBAAgB;IAI3B,MAAMC,UAAUxB;IAChB,MAAMyB,YAA2B;QAC/BC,aAAaC,CAAAA,MAAOA,IAAIH,QAAQA,OAAO;QACvCI,aAAa,OAAMC;YACjB,MAAMA,MAAML,QAAQA,OAAO;QAC7B;QACA,2EAA2E;QAC3E,2EAA2E;QAC3EM,iBAAiB,UAAYC;IAC/B;IACA,OAAO;QACLhB,QAAQS;QACRR,OAAOZ,MAAMc,OAAO,CAACzB,gBAAgBgC;IACvC;AACF,EAAE;AAEF;;;;CAIC,GACD,OAAO,MAAMO,yBAAyB,CACpCC,QAA4B,CAAC,CAAC,GAE9B7B,MAAMc,OAAO,CACXxB,4BACA,IAAIE,4BAA4BqC,QAChC"}
|