@vertz/testing 0.2.28 → 0.2.30
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 +73 -1
- package/dist/index.js +174 -0
- package/package.json +5 -4
package/dist/index.d.ts
CHANGED
|
@@ -131,5 +131,77 @@ declare function createTestApp(config?: TestAppConfig): TestApp;
|
|
|
131
131
|
* @returns A typed test app instance.
|
|
132
132
|
*/
|
|
133
133
|
declare function createTestApp<TRouteMap extends RouteMapEntry>(config?: TestAppConfig): TestAppWithRoutes<TRouteMap>;
|
|
134
|
+
import { AppBuilder } from "@vertz/server";
|
|
135
|
+
import { ModelDef } from "@vertz/db";
|
|
136
|
+
import { EntityDefinition, ListResult, ServiceActionDef, ServiceDefinition } from "@vertz/server";
|
|
137
|
+
import { ListResult as ListResult2 } from "@vertz/server";
|
|
138
|
+
interface ErrorBody {
|
|
139
|
+
error: string;
|
|
140
|
+
message: string;
|
|
141
|
+
statusCode: number;
|
|
142
|
+
details?: unknown;
|
|
143
|
+
}
|
|
144
|
+
type TestResponse2<T = unknown> = {
|
|
145
|
+
ok: true;
|
|
146
|
+
status: number;
|
|
147
|
+
body: T;
|
|
148
|
+
headers: Record<string, string>;
|
|
149
|
+
raw: Response;
|
|
150
|
+
} | {
|
|
151
|
+
ok: false;
|
|
152
|
+
status: number;
|
|
153
|
+
body: ErrorBody;
|
|
154
|
+
headers: Record<string, string>;
|
|
155
|
+
raw: Response;
|
|
156
|
+
};
|
|
157
|
+
interface RequestOptions2<TBody = unknown> {
|
|
158
|
+
body?: TBody;
|
|
159
|
+
headers?: Record<string, string>;
|
|
160
|
+
}
|
|
161
|
+
interface EntityListOptions {
|
|
162
|
+
where?: Record<string, unknown>;
|
|
163
|
+
orderBy?: Record<string, "asc" | "desc">;
|
|
164
|
+
limit?: number;
|
|
165
|
+
after?: string;
|
|
166
|
+
select?: Record<string, true>;
|
|
167
|
+
include?: Record<string, unknown>;
|
|
168
|
+
headers?: Record<string, string>;
|
|
169
|
+
}
|
|
170
|
+
interface EntityRequestOptions {
|
|
171
|
+
headers?: Record<string, string>;
|
|
172
|
+
}
|
|
173
|
+
interface ServiceCallOptions {
|
|
174
|
+
headers?: Record<string, string>;
|
|
175
|
+
}
|
|
176
|
+
interface TestClientOptions {
|
|
177
|
+
defaultHeaders?: Record<string, string>;
|
|
178
|
+
}
|
|
179
|
+
interface EntityTestProxy<TModel extends ModelDef> {
|
|
180
|
+
list(options?: EntityListOptions): Promise<TestResponse2<ListResult<TModel["table"]["$response"]>>>;
|
|
181
|
+
get(id: string, options?: EntityRequestOptions): Promise<TestResponse2<TModel["table"]["$response"]>>;
|
|
182
|
+
create(body: TModel["table"]["$create_input"], options?: EntityRequestOptions): Promise<TestResponse2<TModel["table"]["$response"]>>;
|
|
183
|
+
update(id: string, body: TModel["table"]["$update_input"], options?: EntityRequestOptions): Promise<TestResponse2<TModel["table"]["$response"]>>;
|
|
184
|
+
delete(id: string, options?: EntityRequestOptions): Promise<TestResponse2<null>>;
|
|
185
|
+
}
|
|
186
|
+
/** Extract TActions from a ServiceDefinition's phantom type field */
|
|
187
|
+
type InferActions<TDef> = TDef extends {
|
|
188
|
+
readonly __actions?: infer A;
|
|
189
|
+
} ? A extends Record<string, ServiceActionDef<infer _I, infer _O, infer _C>> ? A : Record<string, ServiceActionDef> : Record<string, ServiceActionDef>;
|
|
190
|
+
type ExtractInput<T> = T extends ServiceActionDef<infer TInput, infer _O, infer _C> ? TInput : unknown;
|
|
191
|
+
type ExtractOutput<T> = T extends ServiceActionDef<infer _I, infer TOutput, infer _C> ? TOutput : unknown;
|
|
192
|
+
type ServiceTestProxy<TDef extends ServiceDefinition> = { [K in Extract<keyof InferActions<TDef>, string>] : [unknown] extends [ExtractInput<InferActions<TDef>[K]>] ? (options?: ServiceCallOptions) => Promise<TestResponse2<ExtractOutput<InferActions<TDef>[K]>>> : (body: ExtractInput<InferActions<TDef>[K]>, options?: ServiceCallOptions) => Promise<TestResponse2<ExtractOutput<InferActions<TDef>[K]>>> };
|
|
193
|
+
interface TestClient {
|
|
194
|
+
entity<TModel extends ModelDef>(def: EntityDefinition<TModel>): EntityTestProxy<TModel>;
|
|
195
|
+
service<TDef extends ServiceDefinition>(def: TDef): ServiceTestProxy<TDef>;
|
|
196
|
+
/** Returns a new immutable client with merged default headers. */
|
|
197
|
+
withHeaders(headers: Record<string, string>): TestClient;
|
|
198
|
+
get(path: string, options?: RequestOptions2): Promise<TestResponse2>;
|
|
199
|
+
post(path: string, options?: RequestOptions2): Promise<TestResponse2>;
|
|
200
|
+
put(path: string, options?: RequestOptions2): Promise<TestResponse2>;
|
|
201
|
+
patch(path: string, options?: RequestOptions2): Promise<TestResponse2>;
|
|
202
|
+
delete(path: string, options?: RequestOptions2): Promise<TestResponse2>;
|
|
203
|
+
head(path: string, options?: RequestOptions2): Promise<TestResponse2>;
|
|
204
|
+
}
|
|
205
|
+
declare function createTestClient(server: AppBuilder, options?: TestClientOptions): TestClient;
|
|
134
206
|
type DeepPartial<T> = { [P in keyof T]? : T[P] extends object ? DeepPartial<T[P]> : T[P] };
|
|
135
|
-
export { createTestApp, TestRouteEntry, TestResponse, TestRequestBuilder, TestAppConfig, TestApp, RouteMapEntry, RequestOptions, DeepPartial };
|
|
207
|
+
export { createTestClient, createTestApp, TestResponse2 as TypedTestResponse, TestRouteEntry, TestResponse, TestRequestBuilder, TestClientOptions, TestClient, TestAppConfig, TestApp, ServiceTestProxy, ServiceCallOptions, RouteMapEntry, RequestOptions, ListResult2 as ListResult, ErrorBody, EntityTestProxy, EntityRequestOptions, EntityListOptions, DeepPartial };
|
package/dist/index.js
CHANGED
|
@@ -157,6 +157,180 @@ class ResponseValidationError extends Error {
|
|
|
157
157
|
this.name = "ResponseValidationError";
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
|
+
// src/test-client.ts
|
|
161
|
+
function resolveEntityBasePath(server, entityName) {
|
|
162
|
+
const routes = server.router.routes;
|
|
163
|
+
const suffix = `/${entityName}`;
|
|
164
|
+
const listRoute = routes.find((r) => r.method === "GET" && (r.path === suffix || r.path.endsWith(suffix)));
|
|
165
|
+
if (listRoute)
|
|
166
|
+
return listRoute.path;
|
|
167
|
+
return `/api/${entityName}`;
|
|
168
|
+
}
|
|
169
|
+
function resolveServiceBasePath(server, serviceName) {
|
|
170
|
+
const routes = server.router.routes;
|
|
171
|
+
const segmentPattern = new RegExp(`/${serviceName}/`);
|
|
172
|
+
const serviceRoute = routes.find((r) => r.method === "POST" && segmentPattern.test(r.path));
|
|
173
|
+
if (serviceRoute) {
|
|
174
|
+
const idx = serviceRoute.path.indexOf(`/${serviceName}/`);
|
|
175
|
+
return serviceRoute.path.slice(0, idx + 1 + serviceName.length);
|
|
176
|
+
}
|
|
177
|
+
return `/api/${serviceName}`;
|
|
178
|
+
}
|
|
179
|
+
async function parseResponse(raw) {
|
|
180
|
+
const status = raw.status;
|
|
181
|
+
const ok = raw.ok;
|
|
182
|
+
const headers = {};
|
|
183
|
+
raw.headers.forEach((value, key) => {
|
|
184
|
+
headers[key] = value;
|
|
185
|
+
});
|
|
186
|
+
let body;
|
|
187
|
+
const contentType = raw.headers.get("content-type");
|
|
188
|
+
if (contentType?.includes("application/json")) {
|
|
189
|
+
body = await raw.json();
|
|
190
|
+
} else if (status === 204 || raw.headers.get("content-length") === "0") {
|
|
191
|
+
body = null;
|
|
192
|
+
} else {
|
|
193
|
+
try {
|
|
194
|
+
body = await raw.json();
|
|
195
|
+
} catch {
|
|
196
|
+
body = null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { ok, status, body, headers, raw };
|
|
200
|
+
}
|
|
201
|
+
function getRequestHandler(server) {
|
|
202
|
+
if ("requestHandler" in server && typeof server.requestHandler === "function") {
|
|
203
|
+
return server.requestHandler;
|
|
204
|
+
}
|
|
205
|
+
return server.handler;
|
|
206
|
+
}
|
|
207
|
+
async function dispatch(handler, method, path, defaultHeaders, options) {
|
|
208
|
+
const mergedHeaders = { ...defaultHeaders, ...options?.headers };
|
|
209
|
+
if (options?.body !== undefined) {
|
|
210
|
+
mergedHeaders["content-type"] = "application/json";
|
|
211
|
+
}
|
|
212
|
+
const request = new Request(`http://localhost${path}`, {
|
|
213
|
+
method,
|
|
214
|
+
body: options?.body !== undefined ? JSON.stringify(options.body) : undefined,
|
|
215
|
+
headers: mergedHeaders
|
|
216
|
+
});
|
|
217
|
+
const raw = await handler(request);
|
|
218
|
+
return parseResponse(raw);
|
|
219
|
+
}
|
|
220
|
+
function createEntityProxy(handler, basePath, defaultHeaders) {
|
|
221
|
+
return {
|
|
222
|
+
list(options) {
|
|
223
|
+
const params = new URLSearchParams;
|
|
224
|
+
if (options?.where)
|
|
225
|
+
params.set("where", JSON.stringify(options.where));
|
|
226
|
+
if (options?.orderBy)
|
|
227
|
+
params.set("orderBy", JSON.stringify(options.orderBy));
|
|
228
|
+
if (options?.limit !== undefined)
|
|
229
|
+
params.set("limit", String(options.limit));
|
|
230
|
+
if (options?.after)
|
|
231
|
+
params.set("after", options.after);
|
|
232
|
+
if (options?.select)
|
|
233
|
+
params.set("select", JSON.stringify(options.select));
|
|
234
|
+
if (options?.include)
|
|
235
|
+
params.set("include", JSON.stringify(options.include));
|
|
236
|
+
const qs = params.toString();
|
|
237
|
+
const path = qs ? `${basePath}?${qs}` : basePath;
|
|
238
|
+
return dispatch(handler, "GET", path, { ...defaultHeaders, ...options?.headers });
|
|
239
|
+
},
|
|
240
|
+
get(id, options) {
|
|
241
|
+
return dispatch(handler, "GET", `${basePath}/${id}`, {
|
|
242
|
+
...defaultHeaders,
|
|
243
|
+
...options?.headers
|
|
244
|
+
});
|
|
245
|
+
},
|
|
246
|
+
create(body, options) {
|
|
247
|
+
return dispatch(handler, "POST", basePath, { ...defaultHeaders, ...options?.headers }, {
|
|
248
|
+
body
|
|
249
|
+
});
|
|
250
|
+
},
|
|
251
|
+
update(id, body, options) {
|
|
252
|
+
return dispatch(handler, "PATCH", `${basePath}/${id}`, {
|
|
253
|
+
...defaultHeaders,
|
|
254
|
+
...options?.headers
|
|
255
|
+
}, { body });
|
|
256
|
+
},
|
|
257
|
+
delete(id, options) {
|
|
258
|
+
return dispatch(handler, "DELETE", `${basePath}/${id}`, {
|
|
259
|
+
...defaultHeaders,
|
|
260
|
+
...options?.headers
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
function createServiceProxy(handler, basePath, serviceDef, defaultHeaders) {
|
|
266
|
+
const actionNames = Object.keys(serviceDef.actions);
|
|
267
|
+
return new Proxy({}, {
|
|
268
|
+
get(_target, prop) {
|
|
269
|
+
if (typeof prop !== "string")
|
|
270
|
+
return;
|
|
271
|
+
if (!actionNames.includes(prop))
|
|
272
|
+
return;
|
|
273
|
+
return (...args) => {
|
|
274
|
+
const action = serviceDef.actions[prop];
|
|
275
|
+
const hasBody = action?.body !== undefined;
|
|
276
|
+
let body;
|
|
277
|
+
let options;
|
|
278
|
+
if (hasBody) {
|
|
279
|
+
body = args[0];
|
|
280
|
+
options = args[1];
|
|
281
|
+
} else {
|
|
282
|
+
options = args[0];
|
|
283
|
+
}
|
|
284
|
+
const method = action?.method?.toUpperCase() ?? "POST";
|
|
285
|
+
const actionPath = action?.path ?? `/${prop}`;
|
|
286
|
+
const fullPath = `${basePath}${actionPath}`;
|
|
287
|
+
return dispatch(handler, method, fullPath, {
|
|
288
|
+
...defaultHeaders,
|
|
289
|
+
...options?.headers
|
|
290
|
+
}, body !== undefined ? { body } : undefined);
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
function createTestClient(server, options) {
|
|
296
|
+
const defaultHeaders = options?.defaultHeaders ?? {};
|
|
297
|
+
const handler = getRequestHandler(server);
|
|
298
|
+
function makeClient(headers) {
|
|
299
|
+
return {
|
|
300
|
+
entity(def) {
|
|
301
|
+
const basePath = resolveEntityBasePath(server, def.name);
|
|
302
|
+
return createEntityProxy(handler, basePath, headers);
|
|
303
|
+
},
|
|
304
|
+
service(def) {
|
|
305
|
+
const basePath = resolveServiceBasePath(server, def.name);
|
|
306
|
+
return createServiceProxy(handler, basePath, def, headers);
|
|
307
|
+
},
|
|
308
|
+
withHeaders(newHeaders) {
|
|
309
|
+
return makeClient({ ...headers, ...newHeaders });
|
|
310
|
+
},
|
|
311
|
+
get(path, opts) {
|
|
312
|
+
return dispatch(handler, "GET", path, headers, opts);
|
|
313
|
+
},
|
|
314
|
+
post(path, opts) {
|
|
315
|
+
return dispatch(handler, "POST", path, headers, opts);
|
|
316
|
+
},
|
|
317
|
+
put(path, opts) {
|
|
318
|
+
return dispatch(handler, "PUT", path, headers, opts);
|
|
319
|
+
},
|
|
320
|
+
patch(path, opts) {
|
|
321
|
+
return dispatch(handler, "PATCH", path, headers, opts);
|
|
322
|
+
},
|
|
323
|
+
delete(path, opts) {
|
|
324
|
+
return dispatch(handler, "DELETE", path, headers, opts);
|
|
325
|
+
},
|
|
326
|
+
head(path, opts) {
|
|
327
|
+
return dispatch(handler, "HEAD", path, headers, opts);
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
return makeClient(defaultHeaders);
|
|
332
|
+
}
|
|
160
333
|
export {
|
|
334
|
+
createTestClient,
|
|
161
335
|
createTestApp
|
|
162
336
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vertz/testing",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.30",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Testing utilities for Vertz applications",
|
|
@@ -30,12 +30,13 @@
|
|
|
30
30
|
"typecheck": "tsc --noEmit"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@vertz/core": "^0.2.
|
|
34
|
-
"@vertz/
|
|
33
|
+
"@vertz/core": "^0.2.29",
|
|
34
|
+
"@vertz/db": "^0.2.29",
|
|
35
|
+
"@vertz/server": "^0.2.29"
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|
|
37
38
|
"@types/node": "^25.3.1",
|
|
38
|
-
"@vertz/schema": "^0.2.
|
|
39
|
+
"@vertz/schema": "^0.2.29",
|
|
39
40
|
"bunup": "^0.16.31",
|
|
40
41
|
"typescript": "^5.7.0"
|
|
41
42
|
},
|