@ingenioz-it/caits 0.0.0-stage → 0.5.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 +5 -0
- package/README.md +36 -2
- package/bin/caits.js +184 -0
- package/bin/init.js +359 -0
- package/package.json +114 -4
- package/quality/architecture/adapters/marko.mjs +23 -0
- package/quality/architecture/adapters/scss.mjs +52 -0
- package/quality/architecture/adapters/typescript.mjs +13 -0
- package/quality/architecture/facts.mjs +159 -0
- package/quality/architecture/project.mjs +148 -0
- package/quality/architecture.mjs +116 -0
- package/quality/check.mjs +104 -0
- package/quality/dev.mjs +110 -0
- package/quality/e2e.mjs +125 -0
- package/quality/mutation-suite.mjs +31 -0
- package/quality/mutation-summary.mjs +41 -0
- package/quality/route-types.mjs +40 -0
- package/quality/test-docs/page.mjs +301 -0
- package/quality/test-docs/playwright-reporter.mjs +55 -0
- package/quality/test-docs/test-docs.mjs +180 -0
- package/quality/test-docs/vitest-reporter.mjs +38 -0
- package/src/DomainEvent.ts +4 -0
- package/src/Result.ts +6 -0
- package/src/Token.ts +8 -0
- package/src/di/Container.ts +123 -0
- package/src/di/DataDirectory.ts +6 -0
- package/src/di/Fetcher.ts +6 -0
- package/src/di/RequestContext.ts +6 -0
- package/src/di/backend.ts +19 -0
- package/src/di/createBackend.ts +106 -0
- package/src/di/createFrontend.ts +53 -0
- package/src/di/declareDependencies.js +93 -0
- package/src/di/discover.ts +39 -0
- package/src/di/discoverFrontends.ts +36 -0
- package/src/di/discovered.ts +6 -0
- package/src/di/discoveredFrontends.ts +6 -0
- package/src/di/frontend.ts +33 -0
- package/src/http/failureOf.ts +10 -0
- package/src/messaging/BackendEventBus.ts +60 -0
- package/src/messaging/ContextMap.ts +22 -0
- package/src/messaging/Controller.ts +109 -0
- package/src/messaging/EventPublisher.ts +12 -0
- package/src/messaging/EventSubscriber.ts +4 -0
- package/src/messaging/FrontendEventBus.ts +38 -0
- package/src/messaging/InProcessMessageBus.ts +35 -0
- package/src/messaging/Input.ts +4 -0
- package/src/messaging/MessageBus.ts +12 -0
- package/src/messaging/ReportError.ts +9 -0
- package/src/state/Loadable.ts +13 -0
- package/src/state/createStore.ts +45 -0
- package/src/state/follow.ts +7 -0
- package/src/state/request.ts +21 -0
- package/src/testing/backend/application.ts +12 -0
- package/src/testing/backend/applicationScenario.ts +60 -0
- package/src/testing/backend/applications.ts +62 -0
- package/src/testing/backend/context-map.ts +12 -0
- package/src/testing/backend/infra.ts +6 -0
- package/src/testing/backend/integration.ts +8 -0
- package/src/testing/backend/presentation.ts +14 -0
- package/src/testing/backend/presentationApplication.ts +25 -0
- package/src/testing/backend/presentationScenario.ts +47 -0
- package/src/testing/frontend/application.ts +12 -0
- package/src/testing/frontend/applicationScenario.ts +36 -0
- package/src/testing/frontend/applications.ts +70 -0
- package/src/testing/frontend/frontends.ts +28 -0
- package/src/testing/frontend/infra.ts +2 -0
- package/src/testing/frontend/marko.ts +12 -0
- package/src/testing/frontend/presentation.ts +7 -0
- package/src/testing/shared/FakeMessageBus.ts +58 -0
- package/src/testing/shared/tempDirectories.ts +15 -0
- package/src/virtual-modules.d.ts +33 -0
- package/src/vite.js +162 -0
- package/src/vite.types.d.ts +14 -0
- package/template/caits/architecture.mjs +467 -0
- package/template/caits/eslint.js +32 -0
- package/template/caits/marko-modules.d.ts +4 -0
- package/template/caits/playwright.ts +35 -0
- package/template/caits/stryker.mjs +20 -0
- package/template/caits/tsconfig.json +21 -0
- package/template/caits/tsconfig.mutation.json +5 -0
- package/template/caits/vite.ts +34 -0
- package/template/empty/src/routes/_frontend/+page.marko +9 -0
- package/template/empty/test/e2e/home.spec.ts +6 -0
- package/template/project/AGENTS +28 -0
- package/template/project/gitignore +16 -0
- package/template/project/nvmrc +1 -0
- package/template/project/quality/architecture.mjs +9 -0
- package/template/project/quality/eslint.config.js +6 -0
- package/template/project/quality/playwright.config.ts +10 -0
- package/template/project/quality/stryker.config.mjs +6 -0
- package/template/project/quality/vite.config.ts +5 -0
- package/template/project/tsconfig.json +4 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { Input } from "./Input.js";
|
|
2
|
+
import type { MessageBus } from "./MessageBus.js";
|
|
3
|
+
import { reportToConsole, type ReportError } from "./ReportError.js";
|
|
4
|
+
|
|
5
|
+
export class InvalidInputError extends Error {
|
|
6
|
+
constructor(message: string, readonly details?: unknown) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "InvalidInputError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class MalformedBodyError extends InvalidInputError {}
|
|
13
|
+
|
|
14
|
+
export class UnsupportedMediaTypeError extends InvalidInputError {}
|
|
15
|
+
|
|
16
|
+
export class BodyTooLargeError extends InvalidInputError {}
|
|
17
|
+
|
|
18
|
+
/** The largest body that readBody reads by default, in bytes: 1 MB. */
|
|
19
|
+
export const MAX_BODY_SIZE = 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A JSON media type, in any case: application/json, or a type with the suffix +json (application/merge-patch+json). Parameters can
|
|
23
|
+
* follow, after a ";". A parameter is not the type: a browser sends `text/plain;x+json` from any site, as text/plain.
|
|
24
|
+
*/
|
|
25
|
+
const JSON_MEDIA_TYPE = /^(?:application\/json|[^\s/]+\/[^\s/;]+\+json)\s*(?:;|$)/i;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The JSON of the body. The request must say that its body is JSON: content-type application/json, or a type with the suffix +json.
|
|
29
|
+
* A browser sends a body of another type (text/plain, a form) from any site, without asking the server first: such a request cannot
|
|
30
|
+
* change the data of the app. The body must not be larger than `maxSize` bytes (MAX_BODY_SIZE, 1 MB, by default): the rest of a
|
|
31
|
+
* larger body is not read. The request stays readable.
|
|
32
|
+
* @throws {UnsupportedMediaTypeError} when the request does not say that its body is JSON: the Controller answers 415.
|
|
33
|
+
* @throws {BodyTooLargeError} when the body is larger than `maxSize`: the Controller answers 413.
|
|
34
|
+
* @throws {MalformedBodyError} when the body is not JSON: the Controller answers 400.
|
|
35
|
+
*/
|
|
36
|
+
export async function readBody(request: Request, maxSize = MAX_BODY_SIZE): Promise<unknown> {
|
|
37
|
+
const contentType = request.headers.get("content-type");
|
|
38
|
+
if (!contentType?.match(JSON_MEDIA_TYPE)) throw new UnsupportedMediaTypeError("The content type must be application/json");
|
|
39
|
+
const text = await textOf(request.clone(), maxSize);
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(text);
|
|
42
|
+
} catch {
|
|
43
|
+
throw new MalformedBodyError("Invalid JSON");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The text of the body, read chunk by chunk: the reading stops at the first chunk after `maxSize` bytes. */
|
|
48
|
+
async function textOf(request: Request, maxSize: number): Promise<string> {
|
|
49
|
+
const decoder = new TextDecoder();
|
|
50
|
+
let text = "";
|
|
51
|
+
let size = 0;
|
|
52
|
+
const reader = request.body?.getReader();
|
|
53
|
+
for (let chunk = await reader?.read(); chunk && !chunk.done; chunk = await reader?.read()) {
|
|
54
|
+
size += chunk.value.byteLength;
|
|
55
|
+
if (size > maxSize) throw new BodyTooLargeError(`The body is larger than ${maxSize} bytes`);
|
|
56
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
57
|
+
}
|
|
58
|
+
return text + decoder.decode();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const problemType = (slug: string) => `urn:caits:problem:${slug}`;
|
|
62
|
+
|
|
63
|
+
export function problem(status: number, slug: string, title: string, detail?: string, extensions: Record<string, unknown> = {}): Response {
|
|
64
|
+
return new Response(JSON.stringify({ type: problemType(slug), title, status, detail, ...extensions }), {
|
|
65
|
+
status,
|
|
66
|
+
headers: { "content-type": "application/problem+json" }
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Turns a request into an input DTO, sends it to the message bus of the request, and turns the output or the error into a response. */
|
|
71
|
+
export abstract class Controller<Dto extends Input<Output>, Output> {
|
|
72
|
+
constructor(private bus: MessageBus, private reportError: ReportError = reportToConsole) {}
|
|
73
|
+
|
|
74
|
+
protected abstract toDto(request: Request): Dto | Promise<Dto>;
|
|
75
|
+
|
|
76
|
+
protected abstract toResponse(output: Output, request: Request): Response | Promise<Response>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The response to an error of the request or of the application. A body that does not say it is JSON is a 415, a body too large a 413,
|
|
80
|
+
* a body that is not JSON a 400, a body that toDto refuses a 422 with its issues, and any other error is an internal server error. A
|
|
81
|
+
* controller answers its own errors first, then calls super.
|
|
82
|
+
*/
|
|
83
|
+
protected toErrorResponse(error: unknown, _request: Request): Response | Promise<Response> {
|
|
84
|
+
if (error instanceof UnsupportedMediaTypeError) return problem(415, "unsupported-media-type", "Unsupported media type", error.message);
|
|
85
|
+
if (error instanceof BodyTooLargeError) return problem(413, "request-body-too-large", "Request body too large", error.message);
|
|
86
|
+
if (error instanceof MalformedBodyError) return problem(400, "invalid-request-body", "Invalid request body", error.message);
|
|
87
|
+
if (error instanceof InvalidInputError) return problem(422, "invalid-request-body", "Invalid request body", error.message, { issues: error.details });
|
|
88
|
+
return this.internalServerError(error);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async handle(request: Request): Promise<Response> {
|
|
92
|
+
try {
|
|
93
|
+
let output: Output;
|
|
94
|
+
try {
|
|
95
|
+
output = await this.bus.send(await this.toDto(request));
|
|
96
|
+
} catch (error) {
|
|
97
|
+
return await this.toErrorResponse(error, request);
|
|
98
|
+
}
|
|
99
|
+
return await this.toResponse(output, request);
|
|
100
|
+
} catch (failure) {
|
|
101
|
+
return this.internalServerError(failure);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private internalServerError(error: unknown): Response {
|
|
106
|
+
this.reportError(error);
|
|
107
|
+
return problem(500, "internal-server-error", "Internal server error");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Token } from "../Token.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Publishes the events of a context: the handlers get it, and the views of the frontend. It cannot subscribe, and it gives nothing back.
|
|
5
|
+
* In the backend, the events go later to the other contexts, through the context map. In the frontend, they go at once to the store of
|
|
6
|
+
* the same context.
|
|
7
|
+
*/
|
|
8
|
+
export interface EventPublisher<E> {
|
|
9
|
+
publish(event: E): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const EventPublisher = new Token<EventPublisher<unknown>>();
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { EventPublisher } from "./EventPublisher.js";
|
|
2
|
+
import type { EventSubscriber } from "./EventSubscriber.js";
|
|
3
|
+
import { reportToConsole, type ReportError } from "./ReportError.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The event bus of a frontend context. Delivers each event to every subscriber. An event published during a delivery waits for its turn:
|
|
7
|
+
* every subscriber receives the events in the order they were published. Only the kernel builds it: the others get it as an
|
|
8
|
+
* EventPublisher or as an EventSubscriber.
|
|
9
|
+
*/
|
|
10
|
+
export class FrontendEventBus<E> implements EventPublisher<E>, EventSubscriber<E> {
|
|
11
|
+
private listeners = new Set<(event: E) => void>();
|
|
12
|
+
private waiting: E[] = [];
|
|
13
|
+
private delivering = false;
|
|
14
|
+
|
|
15
|
+
constructor(private reportError: ReportError = reportToConsole) {}
|
|
16
|
+
|
|
17
|
+
publish(event: E): void {
|
|
18
|
+
this.waiting.push(event);
|
|
19
|
+
if (this.delivering) return;
|
|
20
|
+
this.delivering = true;
|
|
21
|
+
try {
|
|
22
|
+
while (this.waiting.length > 0) this.deliver(this.waiting.shift() as E);
|
|
23
|
+
} finally {
|
|
24
|
+
this.delivering = false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
subscribe(listener: (event: E) => void): () => void {
|
|
29
|
+
this.listeners.add(listener);
|
|
30
|
+
return () => { this.listeners.delete(listener); };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private deliver(event: E): void {
|
|
34
|
+
for (const listener of [...this.listeners]) {
|
|
35
|
+
try { listener(event); } catch (error) { this.reportError(error); }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Input } from "./Input.js";
|
|
2
|
+
import type { MessageBus } from "./MessageBus.js";
|
|
3
|
+
|
|
4
|
+
export type OutputOf<I> = I extends Input<infer Output> ? Output : never;
|
|
5
|
+
type InputClass<I extends Input<unknown>> = abstract new (...args: never[]) => I;
|
|
6
|
+
|
|
7
|
+
export class UnknownInputError extends Error {
|
|
8
|
+
constructor(readonly inputName: string) {
|
|
9
|
+
super(`No handler subscribes to ${inputName}`);
|
|
10
|
+
this.name = "UnknownInputError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class DuplicateHandlerError extends Error {
|
|
15
|
+
constructor(readonly inputName: string) {
|
|
16
|
+
super(`A handler already subscribes to ${inputName}`);
|
|
17
|
+
this.name = "DuplicateHandlerError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The message bus of one context, in the process: each handler subscribes to the class of the input DTO it handles. */
|
|
22
|
+
export class InProcessMessageBus implements MessageBus {
|
|
23
|
+
private handlers = new Map<unknown, (input: never) => unknown>();
|
|
24
|
+
|
|
25
|
+
subscribe<I extends Input<unknown>>(input: InputClass<I>, handler: (input: I) => OutputOf<I> | Promise<OutputOf<I>>): void {
|
|
26
|
+
if (this.handlers.has(input)) throw new DuplicateHandlerError(input.name);
|
|
27
|
+
this.handlers.set(input, handler);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async send<Output>(input: Input<Output>): Promise<Output> {
|
|
31
|
+
const handler = this.handlers.get(input.constructor);
|
|
32
|
+
if (!handler) throw new UnknownInputError(input.constructor.name);
|
|
33
|
+
return (await handler(input as never)) as Output;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Token } from "../Token.js";
|
|
2
|
+
import type { Input } from "./Input.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where a controller or a store sends an input DTO: the message bus gives it to the handler of its class, and gives back the output of
|
|
6
|
+
* the handler.
|
|
7
|
+
*/
|
|
8
|
+
export interface MessageBus {
|
|
9
|
+
send<Output>(input: Input<Output>): Promise<Output>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const MessageBus = new Token<MessageBus>();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Token } from "../Token.js";
|
|
2
|
+
|
|
3
|
+
/** Reports a failure that nobody waits for: a reaction to an event, or an error that a controller hides from its client. */
|
|
4
|
+
export type ReportError = (error: unknown) => void;
|
|
5
|
+
|
|
6
|
+
export const ReportError = new Token<ReportError>();
|
|
7
|
+
|
|
8
|
+
/** Where a failure goes when nobody says where: the console. */
|
|
9
|
+
export const reportToConsole: ReportError = error => { console.error(error); };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Data that a view asked for: nobody asked yet, the answer is on its way, or it came back loaded or failed. */
|
|
2
|
+
export type Loadable<T> =
|
|
3
|
+
| { status: "idle" }
|
|
4
|
+
| { status: "loading" }
|
|
5
|
+
| { status: "loaded"; data: T }
|
|
6
|
+
| { status: "failed"; message: string };
|
|
7
|
+
|
|
8
|
+
export type Settled<T> = Extract<Loadable<T>, { status: "loaded" | "failed" }>;
|
|
9
|
+
|
|
10
|
+
/** The answer, loaded or failed; undefined while nobody asked or the answer is on its way. */
|
|
11
|
+
export function settled<T>(loadable: Loadable<T>): Settled<T> | undefined {
|
|
12
|
+
return loadable.status === "loaded" || loadable.status === "failed" ? loadable : undefined;
|
|
13
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { EventSubscriber } from "../messaging/EventSubscriber.js";
|
|
2
|
+
import type { Input } from "../messaging/Input.js";
|
|
3
|
+
import type { MessageBus } from "../messaging/MessageBus.js";
|
|
4
|
+
import { reportToConsole, type ReportError } from "../messaging/ReportError.js";
|
|
5
|
+
|
|
6
|
+
/** How the store of a frontend context reacts to each event of its event bus. */
|
|
7
|
+
export interface StoreDefinition<S, E> {
|
|
8
|
+
initialState: S;
|
|
9
|
+
/** The state after the event. */
|
|
10
|
+
reduce(state: S, event: E): S;
|
|
11
|
+
/** The input DTO that the event asks the store to send to the message bus, or undefined. It reads the state before the event. */
|
|
12
|
+
send(state: S, event: E): Input<unknown> | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Store<S> {
|
|
16
|
+
getState(): S;
|
|
17
|
+
subscribe(listener: (state: S) => void): () => void;
|
|
18
|
+
subscriberCount(): number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type StoreLinks<E> = {
|
|
22
|
+
events: EventSubscriber<E>;
|
|
23
|
+
bus: MessageBus;
|
|
24
|
+
reportError?: ReportError;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** The store receives every event of the event bus: it keeps the state that the views subscribe to, and sends the input DTOs that the events ask for. */
|
|
28
|
+
export function createStore<S, E>(definition: StoreDefinition<S, E>, { events, bus, reportError = reportToConsole }: StoreLinks<E>): Store<S> {
|
|
29
|
+
let state = definition.initialState;
|
|
30
|
+
const listeners = new Set<(state: S) => void>();
|
|
31
|
+
events.subscribe(event => {
|
|
32
|
+
const input = definition.send(state, event);
|
|
33
|
+
state = definition.reduce(state, event);
|
|
34
|
+
for (const listener of [...listeners]) listener(state);
|
|
35
|
+
if (input) bus.send(input).catch(reportError);
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
getState: () => state,
|
|
39
|
+
subscriberCount: () => listeners.size,
|
|
40
|
+
subscribe(listener) {
|
|
41
|
+
listeners.add(listener);
|
|
42
|
+
return () => { listeners.delete(listener); };
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Store } from "./createStore.js";
|
|
2
|
+
|
|
3
|
+
/** A view follows the store: it shows what the current state presents, then what each new state presents, until it calls the returned stop. */
|
|
4
|
+
export function follow<S, V>(store: Store<S>, present: (state: S) => V, show: (view: V) => void): () => void {
|
|
5
|
+
show(present(store.getState()));
|
|
6
|
+
return store.subscribe(state => { show(present(state)); });
|
|
7
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { EventPublisher } from "../messaging/EventPublisher.js";
|
|
2
|
+
import type { Store } from "./createStore.js";
|
|
3
|
+
|
|
4
|
+
/** What a view holds of a frontend context: the EventPublisher it publishes on, and the store it subscribes to. */
|
|
5
|
+
export interface Frontend<S, E> {
|
|
6
|
+
events: EventPublisher<E>;
|
|
7
|
+
store: Store<S>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A view asks for something: it publishes the event, then waits for the first state in which `answer` finds the answer. */
|
|
11
|
+
export function request<S, E, T>({ events, store }: Frontend<S, E>, event: E, answer: (state: S) => T | undefined): Promise<T> {
|
|
12
|
+
return new Promise(resolve => {
|
|
13
|
+
const stop = store.subscribe(state => {
|
|
14
|
+
const found = answer(state);
|
|
15
|
+
if (found === undefined) return;
|
|
16
|
+
stop();
|
|
17
|
+
resolve(found);
|
|
18
|
+
});
|
|
19
|
+
events.publish(event);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { test as base } from "vitest";
|
|
2
|
+
import { createTestApplication, type TestApplication } from "./applications.js";
|
|
3
|
+
|
|
4
|
+
/** Everything an application test of the backend imports: the test of a use case, written as a scenario. */
|
|
5
|
+
export { afterAll, afterEach, beforeAll, beforeEach, describe, expect, vi } from "vitest";
|
|
6
|
+
export { applicationScenario } from "./applicationScenario.js";
|
|
7
|
+
export type { TestApplication } from "./applications.js";
|
|
8
|
+
|
|
9
|
+
/** A test with `app`: every context, each one alone, built from its di.ts with its test.di.ts over it. */
|
|
10
|
+
export const test = base.extend<{ app: TestApplication }>({
|
|
11
|
+
app: async ({}, use) => { await use(createTestApplication()); }
|
|
12
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { expect } from "vitest";
|
|
2
|
+
import type { DomainEvent } from "../../DomainEvent.js";
|
|
3
|
+
import type { Input } from "../../messaging/Input.js";
|
|
4
|
+
import type { TestApplication } from "./applications.js";
|
|
5
|
+
|
|
6
|
+
type Result<Output> = { output: Output } | { error: unknown };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* An application test, written as a presentation test: the input DTO, the events the handler publishes, then the output DTO or the error
|
|
10
|
+
* that the message bus gives. The events are checked only when the test says which ones it expects.
|
|
11
|
+
*/
|
|
12
|
+
export class ApplicationScenario<Output> {
|
|
13
|
+
private readonly expected: DomainEvent[] = [];
|
|
14
|
+
private checksEvents = false;
|
|
15
|
+
|
|
16
|
+
constructor(private readonly app: TestApplication, private readonly dto: Input<Output>) {}
|
|
17
|
+
|
|
18
|
+
expectItToPublish(event: DomainEvent): this {
|
|
19
|
+
this.expected.push(event);
|
|
20
|
+
this.checksEvents = true;
|
|
21
|
+
return this;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
expectItToPublishNothing(): this {
|
|
25
|
+
this.checksEvents = true;
|
|
26
|
+
return this;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async expectTheOutputToBe(output: Output): Promise<void> {
|
|
30
|
+
const from = this.app.published.length;
|
|
31
|
+
const result = await this.run();
|
|
32
|
+
if ("error" in result) throw result.error;
|
|
33
|
+
this.checkTheEventsFrom(from);
|
|
34
|
+
expect(result.output).toStrictEqual(output);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async expectTheErrorToBe(error: Error): Promise<void> {
|
|
38
|
+
const from = this.app.published.length;
|
|
39
|
+
const result = await this.run();
|
|
40
|
+
if (!("error" in result)) throw new Error(`The message bus gave an output instead of an error: ${JSON.stringify(result.output)}`);
|
|
41
|
+
this.checkTheEventsFrom(from);
|
|
42
|
+
expect(result.error).toStrictEqual(error);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private async run(): Promise<Result<Output>> {
|
|
46
|
+
try {
|
|
47
|
+
return { output: await this.app.send(this.dto) };
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return { error };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private checkTheEventsFrom(from: number): void {
|
|
54
|
+
if (this.checksEvents) expect(this.app.published.slice(from), "the published events").toStrictEqual(this.expected);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const applicationScenario = (app: TestApplication) => ({
|
|
59
|
+
givenTheDto: <Output>(dto: Input<Output>) => new ApplicationScenario(app, dto)
|
|
60
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/// <reference path="../../virtual-modules.d.ts" />
|
|
2
|
+
import { bindings as testDi } from "virtual:kernel/backend-test";
|
|
3
|
+
import type { Binding } from "../../di/Container.js";
|
|
4
|
+
import { createBackend } from "../../di/createBackend.js";
|
|
5
|
+
import { discovered } from "../../di/discovered.js";
|
|
6
|
+
import type { DomainEvent } from "../../DomainEvent.js";
|
|
7
|
+
import type { Published } from "../../messaging/BackendEventBus.js";
|
|
8
|
+
|
|
9
|
+
/** The test.di.ts of each context, and of shared: the bindings that replace, in tests, those that reach outside the process. */
|
|
10
|
+
const testBindings: Record<string, readonly Binding[]> = Object.fromEntries(
|
|
11
|
+
Object.entries(testDi)
|
|
12
|
+
.map(([path, bindings]) => [/backend\/([^/]+)\//.exec(path)![1], bindings])
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
type EventType<E extends DomainEvent> = abstract new (...args: never[]) => E;
|
|
16
|
+
|
|
17
|
+
/** The whole backend, built from the bindings of its contexts with their test.di.ts over them, and predictable ids. For the tests of the context map. */
|
|
18
|
+
export function createTestBackendApplication() {
|
|
19
|
+
const reportedErrors: unknown[] = [];
|
|
20
|
+
const published: Published[] = [];
|
|
21
|
+
let eventIds = 0;
|
|
22
|
+
const backend = createBackend(discovered, {
|
|
23
|
+
replace: testBindings,
|
|
24
|
+
reportError: error => { reportedErrors.push(error); },
|
|
25
|
+
nextEventId: () => `evt-${++eventIds}`
|
|
26
|
+
});
|
|
27
|
+
backend.subscribe(event => { published.push(event); });
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
...backend,
|
|
31
|
+
reportedErrors,
|
|
32
|
+
/** Every event the contexts published, in its envelope, in the order they were published. */
|
|
33
|
+
published,
|
|
34
|
+
publishedNames: () => published.map(({ event }) => event.constructor.name),
|
|
35
|
+
publishedOf: <E extends DomainEvent>(type: EventType<E>): E[] => published.flatMap(({ event }) => event instanceof type ? [event] : [])
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every context, built from its di.ts with its test.di.ts over it, but alone: no event reaches another context. A test sends input DTOs
|
|
41
|
+
* and reads the events the handlers published.
|
|
42
|
+
*/
|
|
43
|
+
export function createTestApplication() {
|
|
44
|
+
const published: DomainEvent[] = [];
|
|
45
|
+
const backend = createBackend({ ...discovered, map: [] }, { replace: testBindings, reportError: () => undefined });
|
|
46
|
+
backend.subscribe(({ event }) => { published.push(event); });
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
/** Sends an input DTO to the message bus of the context that handles it. */
|
|
50
|
+
send: backend.send,
|
|
51
|
+
/** The dependency with this token, built now if no handler needed it before. */
|
|
52
|
+
get: backend.get,
|
|
53
|
+
/** Gives the dependency that the app uses from now on: a test builds its own Fake, and keeps it to arrange and check it. */
|
|
54
|
+
use: backend.use,
|
|
55
|
+
/** Every event the handlers published, in the order they published them. */
|
|
56
|
+
published,
|
|
57
|
+
publishedOf: <E extends DomainEvent>(type: EventType<E>): E[] => published.flatMap(event => event instanceof type ? [event] : [])
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type TestBackendApplication = ReturnType<typeof createTestBackendApplication>;
|
|
62
|
+
export type TestApplication = ReturnType<typeof createTestApplication>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { test as base } from "vitest";
|
|
2
|
+
import { createTestBackendApplication, type TestBackendApplication } from "./applications.js";
|
|
3
|
+
|
|
4
|
+
/** Everything a test of the context map imports. `test` checks the Input DTOs that the `ContextMap` gives for an Event. */
|
|
5
|
+
export { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
|
6
|
+
export { ContextMap } from "../../messaging/ContextMap.js";
|
|
7
|
+
export type { TestBackendApplication } from "./applications.js";
|
|
8
|
+
|
|
9
|
+
/** A test of a flow, with `app`: it connects the contexts with the context map, and uses the Fakes of the test.di.ts files. */
|
|
10
|
+
export const backendTest = base.extend<{ app: TestBackendApplication }>({
|
|
11
|
+
app: async ({}, use) => { await use(createTestBackendApplication()); }
|
|
12
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything an infra test of the backend imports: the contract of a port, and the test of each implementation that runs it.
|
|
3
|
+
* `useTempDirectories` gives empty folders to the implementations that write files.
|
|
4
|
+
*/
|
|
5
|
+
export { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
|
6
|
+
export { useTempDirectories } from "../shared/tempDirectories.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createBackend } from "../../di/createBackend.js";
|
|
2
|
+
import { discovered } from "../../di/discovered.js";
|
|
3
|
+
|
|
4
|
+
/** Everything an integration test imports: flows of the context map again, with the real infra implementations. */
|
|
5
|
+
export { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
/** The whole backend, built from the di.ts of the contexts only: the real infra implementations, as in production. */
|
|
8
|
+
export const productionBackend = () => createBackend(discovered);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { test as base } from "vitest";
|
|
2
|
+
import { startPresentationApplication } from "./presentationApplication.js";
|
|
3
|
+
|
|
4
|
+
/** Everything a presentation test of the backend imports: the test of a Controller, through its route. */
|
|
5
|
+
export { afterAll, afterEach, beforeAll, beforeEach, describe, expect, vi } from "vitest";
|
|
6
|
+
export { presentationScenario } from "./presentationScenario.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A test with `app`: the real routes and Controllers, over a Fake MessageBus. `presentationScenario(app)` sends the Request, checks the
|
|
10
|
+
* Input DTO, gives the reply, and checks the Response.
|
|
11
|
+
*/
|
|
12
|
+
export const test = base.extend<{ app: ReturnType<typeof startPresentationApplication> }>({
|
|
13
|
+
app: async ({}, use) => { await use(startPresentationApplication()); }
|
|
14
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/// <reference path="../../virtual-modules.d.ts" />
|
|
2
|
+
import "virtual:marko-run/__marko-run__router.js";
|
|
3
|
+
import { fetch } from "@marko/run/router";
|
|
4
|
+
import { startBackend } from "../../di/backend.js";
|
|
5
|
+
import { bind } from "../../di/Container.js";
|
|
6
|
+
import { MessageBus } from "../../messaging/MessageBus.js";
|
|
7
|
+
import { FakeMessageBus } from "../shared/FakeMessageBus.js";
|
|
8
|
+
|
|
9
|
+
/** The real routes and controllers, over a bus that only records: each test says what the application answers. */
|
|
10
|
+
export function startPresentationApplication() {
|
|
11
|
+
const messageBus = new FakeMessageBus();
|
|
12
|
+
const reportedErrors: unknown[] = [];
|
|
13
|
+
const reportError = (error: unknown) => { reportedErrors.push(error); };
|
|
14
|
+
startBackend({ reportError, replace: { shared: [bind(MessageBus).toValue(messageBus)] } });
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
messageBus,
|
|
18
|
+
reportedErrors,
|
|
19
|
+
async fetch(request: Request): Promise<Response> {
|
|
20
|
+
const response = await fetch(request, {} as never);
|
|
21
|
+
if (!response) throw new Error(`No route answers ${request.method} ${new URL(request.url).pathname}`);
|
|
22
|
+
return response;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { expect } from "vitest";
|
|
2
|
+
import type { startPresentationApplication } from "./presentationApplication.js";
|
|
3
|
+
|
|
4
|
+
type PresentationApplication = ReturnType<typeof startPresentationApplication>;
|
|
5
|
+
|
|
6
|
+
export class PresentationScenario {
|
|
7
|
+
private request: Request | undefined;
|
|
8
|
+
|
|
9
|
+
constructor(private readonly app: PresentationApplication) {}
|
|
10
|
+
|
|
11
|
+
/** A request with a body sends it as JSON: a string is the text of the body as it is (`"{not json"`), any other value its JSON. */
|
|
12
|
+
givenTheRequest(method: string, path: string, body?: unknown): this {
|
|
13
|
+
this.request = new Request(`http://localhost${path}`, {
|
|
14
|
+
method,
|
|
15
|
+
...(body === undefined ? {} : { body: typeof body === "string" ? body : JSON.stringify(body), headers: { "content-type": "application/json" } })
|
|
16
|
+
});
|
|
17
|
+
return this;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
expectItToSend(message: object): this {
|
|
21
|
+
this.app.messageBus.expect(message);
|
|
22
|
+
return this;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
givenTheApplicationReplies(output: unknown): this {
|
|
26
|
+
this.app.messageBus.replyToTheLast(output);
|
|
27
|
+
return this;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
givenTheApplicationFails(error: Error): this {
|
|
31
|
+
this.app.messageBus.failTheLast(error);
|
|
32
|
+
return this;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async expectTheResponseToBe(status: number, body?: unknown, headers: Record<string, string> = {}): Promise<void> {
|
|
36
|
+
if (this.request === undefined) throw new Error("PresentationScenario: call givenTheRequest(...) before expectTheResponseToBe(...).");
|
|
37
|
+
const response = await this.app.fetch(this.request);
|
|
38
|
+
|
|
39
|
+
expect(response.status).toBe(status);
|
|
40
|
+
for (const [name, value] of Object.entries(headers)) expect(response.headers.get(name), `header ${name}`).toBe(value);
|
|
41
|
+
if (body === undefined) expect(await response.text()).toBe("");
|
|
42
|
+
else expect(await response.json()).toEqual(body);
|
|
43
|
+
this.app.messageBus.verify();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const presentationScenario = (app: PresentationApplication): PresentationScenario => new PresentationScenario(app);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { test as base } from "vitest";
|
|
2
|
+
import { createTestApplication, type TestApplication } from "./applications.js";
|
|
3
|
+
|
|
4
|
+
/** Everything an application test of the frontend imports: the test of a use case, written as a scenario. */
|
|
5
|
+
export { afterAll, afterEach, beforeAll, beforeEach, describe, expect, vi } from "vitest";
|
|
6
|
+
export { applicationScenario } from "./applicationScenario.js";
|
|
7
|
+
export type { TestApplication } from "./applications.js";
|
|
8
|
+
|
|
9
|
+
/** A test with `app`: every context, each one alone, built from its di.ts with its test.di.ts over it. */
|
|
10
|
+
export const test = base.extend<{ app: TestApplication }>({
|
|
11
|
+
app: async ({}, use) => { await use(createTestApplication()); }
|
|
12
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { expect } from "vitest";
|
|
2
|
+
import type { Input } from "../../messaging/Input.js";
|
|
3
|
+
import type { TestApplication } from "./applications.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An application test, written as in the backend: the input DTO, then the events that the handler must publish, one by one. Each of
|
|
7
|
+
* them must be among the events that the handler published, in any order: the handler can publish other events too. A handler of the
|
|
8
|
+
* frontend gives no output, thus the scenario runs when the test awaits it.
|
|
9
|
+
*/
|
|
10
|
+
export class ApplicationScenario implements PromiseLike<void> {
|
|
11
|
+
private readonly expected: object[] = [];
|
|
12
|
+
|
|
13
|
+
constructor(private readonly app: TestApplication, private readonly dto: Input<void>) {}
|
|
14
|
+
|
|
15
|
+
expectItToPublish(event: object): this {
|
|
16
|
+
this.expected.push(event);
|
|
17
|
+
return this;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
then<Fulfilled = void, Rejected = never>(
|
|
21
|
+
onFulfilled?: ((value: void) => Fulfilled | PromiseLike<Fulfilled>) | null,
|
|
22
|
+
onRejected?: ((reason: unknown) => Rejected | PromiseLike<Rejected>) | null
|
|
23
|
+
): Promise<Fulfilled | Rejected> {
|
|
24
|
+
return this.run().then(onFulfilled, onRejected);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private async run(): Promise<void> {
|
|
28
|
+
const from = this.app.published.length;
|
|
29
|
+
await this.app.send(this.dto);
|
|
30
|
+
expect(this.app.published.slice(from), "the published events").toEqual(expect.arrayContaining(this.expected));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const applicationScenario = (app: TestApplication) => ({
|
|
35
|
+
givenTheDto: (dto: Input<void>) => new ApplicationScenario(app, dto)
|
|
36
|
+
});
|