@vertz/testing 0.0.2
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/dist/index.d.ts +57 -0
- package/dist/index.js +231 -0
- package/package.json +35 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { NamedMiddlewareDef, NamedModule, NamedServiceDef } from "@vertz/core";
|
|
2
|
+
type DeepPartial<T> = { [P in keyof T]? : T[P] extends object ? DeepPartial<T[P]> : T[P] };
|
|
3
|
+
interface TestResponse {
|
|
4
|
+
status: number;
|
|
5
|
+
body: unknown;
|
|
6
|
+
headers: Record<string, string>;
|
|
7
|
+
ok: boolean;
|
|
8
|
+
}
|
|
9
|
+
interface TestRequestBuilder extends PromiseLike<TestResponse> {
|
|
10
|
+
mock<
|
|
11
|
+
TDeps,
|
|
12
|
+
TState,
|
|
13
|
+
TMethods
|
|
14
|
+
>(service: NamedServiceDef<TDeps, TState, TMethods>, impl: DeepPartial<TMethods>): TestRequestBuilder;
|
|
15
|
+
mockMiddleware<
|
|
16
|
+
TReq extends Record<string, unknown>,
|
|
17
|
+
TProv extends Record<string, unknown>
|
|
18
|
+
>(middleware: NamedMiddlewareDef<TReq, TProv>, result: TProv): TestRequestBuilder;
|
|
19
|
+
}
|
|
20
|
+
interface RequestOptions {
|
|
21
|
+
body?: unknown;
|
|
22
|
+
headers?: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
interface TestApp {
|
|
25
|
+
register(module: NamedModule, options?: Record<string, unknown>): TestApp;
|
|
26
|
+
mock<
|
|
27
|
+
TDeps,
|
|
28
|
+
TState,
|
|
29
|
+
TMethods
|
|
30
|
+
>(service: NamedServiceDef<TDeps, TState, TMethods>, impl: DeepPartial<TMethods>): TestApp;
|
|
31
|
+
mockMiddleware<
|
|
32
|
+
TReq extends Record<string, unknown>,
|
|
33
|
+
TProv extends Record<string, unknown>
|
|
34
|
+
>(middleware: NamedMiddlewareDef<TReq, TProv>, result: TProv): TestApp;
|
|
35
|
+
env(vars: Record<string, unknown>): TestApp;
|
|
36
|
+
get(path: string, options?: RequestOptions): TestRequestBuilder;
|
|
37
|
+
post(path: string, options?: RequestOptions): TestRequestBuilder;
|
|
38
|
+
put(path: string, options?: RequestOptions): TestRequestBuilder;
|
|
39
|
+
patch(path: string, options?: RequestOptions): TestRequestBuilder;
|
|
40
|
+
delete(path: string, options?: RequestOptions): TestRequestBuilder;
|
|
41
|
+
head(path: string, options?: RequestOptions): TestRequestBuilder;
|
|
42
|
+
}
|
|
43
|
+
declare function createTestApp(): TestApp;
|
|
44
|
+
import { NamedServiceDef as NamedServiceDef2 } from "@vertz/core";
|
|
45
|
+
interface TestServiceBuilder<TMethods> extends PromiseLike<TMethods> {
|
|
46
|
+
mock<
|
|
47
|
+
TDep,
|
|
48
|
+
TState,
|
|
49
|
+
TMock
|
|
50
|
+
>(service: NamedServiceDef2<TDep, TState, TMock>, impl: DeepPartial<TMock>): TestServiceBuilder<TMethods>;
|
|
51
|
+
}
|
|
52
|
+
declare function createTestService<
|
|
53
|
+
TDeps,
|
|
54
|
+
TState,
|
|
55
|
+
TMethods
|
|
56
|
+
>(serviceDef: NamedServiceDef2<TDeps, TState, TMethods>): TestServiceBuilder<TMethods>;
|
|
57
|
+
export { createTestService, createTestApp, TestServiceBuilder, TestResponse, TestRequestBuilder, TestApp, DeepPartial };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// src/test-app.ts
|
|
2
|
+
import {
|
|
3
|
+
BadRequestException
|
|
4
|
+
} from "@vertz/core";
|
|
5
|
+
import {
|
|
6
|
+
buildCtx,
|
|
7
|
+
createErrorResponse,
|
|
8
|
+
createJsonResponse,
|
|
9
|
+
parseBody,
|
|
10
|
+
parseRequest,
|
|
11
|
+
runMiddlewareChain,
|
|
12
|
+
Trie
|
|
13
|
+
} from "@vertz/core/internals";
|
|
14
|
+
|
|
15
|
+
class ResponseValidationError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(`Response validation failed: ${message}`);
|
|
18
|
+
this.name = "ResponseValidationError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function validateSchema(schema, value, label) {
|
|
22
|
+
try {
|
|
23
|
+
return schema.parse(value);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error instanceof BadRequestException)
|
|
26
|
+
throw error;
|
|
27
|
+
const message = error instanceof Error ? error.message : `Invalid ${label}`;
|
|
28
|
+
throw new BadRequestException(message);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
var HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"];
|
|
32
|
+
function createTestApp() {
|
|
33
|
+
const serviceMocks = new Map;
|
|
34
|
+
const middlewareMocks = new Map;
|
|
35
|
+
const registrations = [];
|
|
36
|
+
let envOverrides = {};
|
|
37
|
+
function buildHandler(perRequest) {
|
|
38
|
+
const trie = new Trie;
|
|
39
|
+
const realServices = new Map;
|
|
40
|
+
for (const { module } of registrations) {
|
|
41
|
+
for (const service of module.services) {
|
|
42
|
+
if (!realServices.has(service)) {
|
|
43
|
+
realServices.set(service, service.methods({}, undefined));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const serviceMap = new Map([...realServices, ...serviceMocks, ...perRequest.services]);
|
|
48
|
+
for (const { module, options } of registrations) {
|
|
49
|
+
for (const router of module.routers) {
|
|
50
|
+
const resolvedServices = {};
|
|
51
|
+
if (router.inject) {
|
|
52
|
+
for (const [name, serviceDef] of Object.entries(router.inject)) {
|
|
53
|
+
resolvedServices[name] = serviceMap.get(serviceDef);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const route of router.routes) {
|
|
57
|
+
const fullPath = router.prefix + route.path;
|
|
58
|
+
const entry = {
|
|
59
|
+
handler: route.config.handler,
|
|
60
|
+
options: options ?? {},
|
|
61
|
+
services: resolvedServices,
|
|
62
|
+
responseSchema: route.config.response,
|
|
63
|
+
bodySchema: route.config.body,
|
|
64
|
+
querySchema: route.config.query,
|
|
65
|
+
headersSchema: route.config.headers
|
|
66
|
+
};
|
|
67
|
+
trie.add(route.method, fullPath, entry);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const effectiveMiddlewareMocks = new Map([...middlewareMocks, ...perRequest.middlewares]);
|
|
72
|
+
return async (request) => {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = parseRequest(request);
|
|
75
|
+
const match = trie.match(parsed.method, parsed.path);
|
|
76
|
+
if (!match) {
|
|
77
|
+
const allowed = trie.getAllowedMethods(parsed.path);
|
|
78
|
+
if (allowed.length > 0) {
|
|
79
|
+
return createJsonResponse({ error: "MethodNotAllowed", message: "Method Not Allowed", statusCode: 405 }, 405, { allow: allowed.join(", ") });
|
|
80
|
+
}
|
|
81
|
+
return createJsonResponse({ error: "NotFound", message: "Not Found", statusCode: 404 }, 404);
|
|
82
|
+
}
|
|
83
|
+
const body = await parseBody(request);
|
|
84
|
+
const raw = {
|
|
85
|
+
request: parsed.raw,
|
|
86
|
+
method: parsed.method,
|
|
87
|
+
url: parsed.raw.url,
|
|
88
|
+
headers: parsed.raw.headers
|
|
89
|
+
};
|
|
90
|
+
const shared = {
|
|
91
|
+
params: match.params,
|
|
92
|
+
body,
|
|
93
|
+
query: parsed.query,
|
|
94
|
+
headers: parsed.headers,
|
|
95
|
+
raw
|
|
96
|
+
};
|
|
97
|
+
const resolvedMiddlewares = [...effectiveMiddlewareMocks].map(([mw, mockResult]) => ({
|
|
98
|
+
name: mw.name,
|
|
99
|
+
handler: () => mockResult,
|
|
100
|
+
resolvedInject: {}
|
|
101
|
+
}));
|
|
102
|
+
const middlewareState = await runMiddlewareChain(resolvedMiddlewares, shared);
|
|
103
|
+
const entry = match.handler;
|
|
104
|
+
const validatedBody = entry.bodySchema ? validateSchema(entry.bodySchema, body, "body") : body;
|
|
105
|
+
const validatedQuery = entry.querySchema ? validateSchema(entry.querySchema, parsed.query, "query") : parsed.query;
|
|
106
|
+
const validatedHeaders = entry.headersSchema ? validateSchema(entry.headersSchema, parsed.headers, "headers") : parsed.headers;
|
|
107
|
+
const ctx = buildCtx({
|
|
108
|
+
params: match.params,
|
|
109
|
+
body: validatedBody,
|
|
110
|
+
query: validatedQuery,
|
|
111
|
+
headers: validatedHeaders,
|
|
112
|
+
raw,
|
|
113
|
+
middlewareState,
|
|
114
|
+
services: entry.services,
|
|
115
|
+
options: entry.options,
|
|
116
|
+
env: envOverrides
|
|
117
|
+
});
|
|
118
|
+
const result = await entry.handler(ctx);
|
|
119
|
+
if (entry.responseSchema) {
|
|
120
|
+
const validation = entry.responseSchema.safeParse(result);
|
|
121
|
+
if (!validation.success) {
|
|
122
|
+
throw new ResponseValidationError(validation.error?.message ?? "Unknown validation error");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return result === undefined ? new Response(null, { status: 204 }) : createJsonResponse(result);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error instanceof ResponseValidationError)
|
|
128
|
+
throw error;
|
|
129
|
+
return createErrorResponse(error);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
async function executeRequest(method, path, options, perRequest) {
|
|
134
|
+
const handler = buildHandler(perRequest);
|
|
135
|
+
const { body, headers: customHeaders } = options ?? {};
|
|
136
|
+
const headers = { ...customHeaders };
|
|
137
|
+
if (body !== undefined) {
|
|
138
|
+
headers["content-type"] = "application/json";
|
|
139
|
+
}
|
|
140
|
+
const request = new Request(`http://localhost${path}`, {
|
|
141
|
+
method,
|
|
142
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
143
|
+
headers
|
|
144
|
+
});
|
|
145
|
+
const response = await handler(request);
|
|
146
|
+
const isJson = response.headers.get("content-type")?.includes("application/json");
|
|
147
|
+
const responseBody = isJson ? await response.json() : null;
|
|
148
|
+
return {
|
|
149
|
+
status: response.status,
|
|
150
|
+
body: responseBody,
|
|
151
|
+
headers: Object.fromEntries(response.headers),
|
|
152
|
+
ok: response.ok
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function createRequestBuilder(method, path, options) {
|
|
156
|
+
const perRequest = {
|
|
157
|
+
services: new Map,
|
|
158
|
+
middlewares: new Map
|
|
159
|
+
};
|
|
160
|
+
const builder = {
|
|
161
|
+
mock(service, impl) {
|
|
162
|
+
perRequest.services.set(service, impl);
|
|
163
|
+
return builder;
|
|
164
|
+
},
|
|
165
|
+
mockMiddleware(middleware, result) {
|
|
166
|
+
perRequest.middlewares.set(middleware, result);
|
|
167
|
+
return builder;
|
|
168
|
+
},
|
|
169
|
+
then(onfulfilled, onrejected) {
|
|
170
|
+
return executeRequest(method, path, options, perRequest).then(onfulfilled, onrejected);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
return builder;
|
|
174
|
+
}
|
|
175
|
+
const httpMethods = Object.fromEntries(HTTP_METHODS.map((m) => [
|
|
176
|
+
m.toLowerCase(),
|
|
177
|
+
(path, options) => createRequestBuilder(m, path, options)
|
|
178
|
+
]));
|
|
179
|
+
const app = {
|
|
180
|
+
register(module, options) {
|
|
181
|
+
registrations.push({ module, options });
|
|
182
|
+
return app;
|
|
183
|
+
},
|
|
184
|
+
mock(service, impl) {
|
|
185
|
+
serviceMocks.set(service, impl);
|
|
186
|
+
return app;
|
|
187
|
+
},
|
|
188
|
+
mockMiddleware(middleware, result) {
|
|
189
|
+
middlewareMocks.set(middleware, result);
|
|
190
|
+
return app;
|
|
191
|
+
},
|
|
192
|
+
env(vars) {
|
|
193
|
+
envOverrides = vars;
|
|
194
|
+
return app;
|
|
195
|
+
},
|
|
196
|
+
...httpMethods
|
|
197
|
+
};
|
|
198
|
+
return app;
|
|
199
|
+
}
|
|
200
|
+
// src/test-service.ts
|
|
201
|
+
function createTestService(serviceDef) {
|
|
202
|
+
const serviceMocks = new Map;
|
|
203
|
+
async function resolve() {
|
|
204
|
+
const deps = {};
|
|
205
|
+
if (serviceDef.inject) {
|
|
206
|
+
for (const [name, depDef] of Object.entries(serviceDef.inject)) {
|
|
207
|
+
const mock = serviceMocks.get(depDef);
|
|
208
|
+
if (mock === undefined) {
|
|
209
|
+
throw new Error(`Missing mock for injected dependency "${name}". Call .mock(${name}Service, impl) before awaiting.`);
|
|
210
|
+
}
|
|
211
|
+
deps[name] = mock;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const state = serviceDef.onInit ? await serviceDef.onInit(deps) : undefined;
|
|
215
|
+
return serviceDef.methods(deps, state);
|
|
216
|
+
}
|
|
217
|
+
const builder = {
|
|
218
|
+
mock(service, impl) {
|
|
219
|
+
serviceMocks.set(service, impl);
|
|
220
|
+
return builder;
|
|
221
|
+
},
|
|
222
|
+
then(onfulfilled, onrejected) {
|
|
223
|
+
return resolve().then(onfulfilled, onrejected);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
return builder;
|
|
227
|
+
}
|
|
228
|
+
export {
|
|
229
|
+
createTestService,
|
|
230
|
+
createTestApp
|
|
231
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vertz/testing",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "bunup",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"test:watch": "vitest",
|
|
20
|
+
"typecheck": "tsc --noEmit"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@vertz/core": "0.1.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^25.2.1",
|
|
27
|
+
"@vertz/schema": "0.1.0",
|
|
28
|
+
"bunup": "latest",
|
|
29
|
+
"typescript": "^5.7.0",
|
|
30
|
+
"vitest": "^3.0.0"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=22"
|
|
34
|
+
}
|
|
35
|
+
}
|