@velajs/testing 0.2.1 → 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/CHANGELOG.md +5 -0
- package/dist/db/test-database.d.ts +27 -0
- package/dist/db/test-database.js +19 -0
- package/dist/http/path-utils.d.ts +11 -0
- package/dist/http/path-utils.js +37 -0
- package/dist/http/test-http-client.d.ts +37 -0
- package/dist/http/test-http-client.js +61 -0
- package/dist/http/test-http-request.d.ts +46 -0
- package/dist/http/test-http-request.js +87 -0
- package/dist/http/test-response.d.ts +76 -0
- package/dist/http/test-response.js +166 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +11 -0
- package/dist/sse/test-sse-connection.d.ts +46 -0
- package/dist/sse/test-sse-connection.js +196 -0
- package/dist/sse/test-sse-request.d.ts +30 -0
- package/dist/sse/test-sse-request.js +62 -0
- package/dist/testing-module.builder.js +19 -19
- package/dist/testing-module.d.ts +78 -1
- package/dist/testing-module.js +127 -4
- package/dist/websocket-node/index.d.ts +1 -0
- package/dist/websocket-node/index.js +109 -0
- package/dist/ws/test-ws-connection.d.ts +41 -0
- package/dist/ws/test-ws-connection.js +105 -0
- package/dist/ws/test-ws-request.d.ts +43 -0
- package/dist/ws/test-ws-request.js +69 -0
- package/package.json +32 -23
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Ported near-verbatim from @stratal/testing (MIT, © Temitayo Fadojutimi).
|
|
2
|
+
// Web-standard only (ReadableStream + TextDecoder), so it is edge-pure.
|
|
3
|
+
import { expect } from "vitest";
|
|
4
|
+
/**
|
|
5
|
+
* TestSseConnection
|
|
6
|
+
*
|
|
7
|
+
* Reads a streaming `text/event-stream` response body and exposes queue-based
|
|
8
|
+
* wait/assert helpers over the parsed events.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* const sse = await module.sse('/stream/events').connect();
|
|
13
|
+
* await sse.assertEventData('ping');
|
|
14
|
+
* await sse.waitForEnd();
|
|
15
|
+
* ```
|
|
16
|
+
*/ export class TestSseConnection {
|
|
17
|
+
response;
|
|
18
|
+
eventQueue = [];
|
|
19
|
+
eventWaiters = [];
|
|
20
|
+
streamEnded = false;
|
|
21
|
+
endWaiters = [];
|
|
22
|
+
constructor(response){
|
|
23
|
+
this.response = response;
|
|
24
|
+
this.startReading();
|
|
25
|
+
}
|
|
26
|
+
/** The raw `Response`. */ get raw() {
|
|
27
|
+
return this.response;
|
|
28
|
+
}
|
|
29
|
+
/** Wait for the next event (rejects after `timeout` ms). */ async waitForEvent(timeout = 5000) {
|
|
30
|
+
if (this.eventQueue.length > 0) {
|
|
31
|
+
return this.eventQueue.shift();
|
|
32
|
+
}
|
|
33
|
+
if (this.streamEnded) {
|
|
34
|
+
throw new Error('SSE: stream has ended, no more events');
|
|
35
|
+
}
|
|
36
|
+
return new Promise((resolve, reject)=>{
|
|
37
|
+
const waiter = (event)=>{
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
resolve(event);
|
|
40
|
+
};
|
|
41
|
+
const timer = setTimeout(()=>{
|
|
42
|
+
const index = this.eventWaiters.indexOf(waiter);
|
|
43
|
+
if (index !== -1) this.eventWaiters.splice(index, 1);
|
|
44
|
+
reject(new Error(`SSE: no event received within ${timeout}ms`));
|
|
45
|
+
}, timeout);
|
|
46
|
+
this.eventWaiters.push(waiter);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** Wait for the stream to end (rejects after `timeout` ms). */ async waitForEnd(timeout = 5000) {
|
|
50
|
+
if (this.streamEnded) return;
|
|
51
|
+
return new Promise((resolve, reject)=>{
|
|
52
|
+
const waiter = ()=>{
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
resolve();
|
|
55
|
+
};
|
|
56
|
+
const timer = setTimeout(()=>{
|
|
57
|
+
const index = this.endWaiters.indexOf(waiter);
|
|
58
|
+
if (index !== -1) this.endWaiters.splice(index, 1);
|
|
59
|
+
reject(new Error(`SSE: stream did not end within ${timeout}ms`));
|
|
60
|
+
}, timeout);
|
|
61
|
+
this.endWaiters.push(waiter);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** Collect all remaining events until the stream ends. */ async collectEvents(timeout = 5000) {
|
|
65
|
+
const events = [];
|
|
66
|
+
if (this.streamEnded) {
|
|
67
|
+
return [
|
|
68
|
+
...this.eventQueue.splice(0)
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
return new Promise((resolve, reject)=>{
|
|
72
|
+
const originalDispatch = this.dispatchEvent.bind(this);
|
|
73
|
+
this.dispatchEvent = (event)=>{
|
|
74
|
+
events.push(event);
|
|
75
|
+
originalDispatch(event);
|
|
76
|
+
};
|
|
77
|
+
const endWaiter = ()=>{
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
this.dispatchEvent = originalDispatch;
|
|
80
|
+
resolve(events);
|
|
81
|
+
};
|
|
82
|
+
const timer = setTimeout(()=>{
|
|
83
|
+
this.dispatchEvent = originalDispatch;
|
|
84
|
+
const index = this.endWaiters.indexOf(endWaiter);
|
|
85
|
+
if (index !== -1) this.endWaiters.splice(index, 1);
|
|
86
|
+
reject(new Error(`SSE: stream did not end within ${timeout}ms`));
|
|
87
|
+
}, timeout);
|
|
88
|
+
events.push(...this.eventQueue.splice(0));
|
|
89
|
+
this.endWaiters.push(endWaiter);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** Assert the next event matches the expected partial shape. */ async assertEvent(expected, timeout = 5000) {
|
|
93
|
+
const event = await this.waitForEvent(timeout);
|
|
94
|
+
expect(event).toMatchObject(expected);
|
|
95
|
+
}
|
|
96
|
+
/** Assert the next event's `data` equals `expected`. */ async assertEventData(expected, timeout = 5000) {
|
|
97
|
+
const event = await this.waitForEvent(timeout);
|
|
98
|
+
expect(event.data, `Expected SSE data "${expected}", got "${event.data}"`).toBe(expected);
|
|
99
|
+
}
|
|
100
|
+
/** Assert the next event's `data` is JSON equal to `expected`. */ async assertJsonEventData(expected, timeout = 5000) {
|
|
101
|
+
const event = await this.waitForEvent(timeout);
|
|
102
|
+
const parsed = JSON.parse(event.data);
|
|
103
|
+
expect(parsed).toEqual(expected);
|
|
104
|
+
}
|
|
105
|
+
startReading() {
|
|
106
|
+
const body = this.response.body;
|
|
107
|
+
if (!body) {
|
|
108
|
+
this.streamEnded = true;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const reader = body.getReader();
|
|
112
|
+
const decoder = new TextDecoder();
|
|
113
|
+
let buffer = '';
|
|
114
|
+
const read = async ()=>{
|
|
115
|
+
try {
|
|
116
|
+
for(;;){
|
|
117
|
+
const { done, value } = await reader.read();
|
|
118
|
+
if (done) {
|
|
119
|
+
if (buffer.trim()) {
|
|
120
|
+
const event = this.parseEvent(buffer);
|
|
121
|
+
if (event) this.dispatchEvent(event);
|
|
122
|
+
}
|
|
123
|
+
this.endStream();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
buffer += decoder.decode(value, {
|
|
127
|
+
stream: true
|
|
128
|
+
});
|
|
129
|
+
const parts = buffer.split('\n\n');
|
|
130
|
+
buffer = parts.pop();
|
|
131
|
+
for (const part of parts){
|
|
132
|
+
if (!part.trim()) continue;
|
|
133
|
+
const event = this.parseEvent(part);
|
|
134
|
+
if (event) this.dispatchEvent(event);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
this.endStream();
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
void read();
|
|
142
|
+
}
|
|
143
|
+
endStream() {
|
|
144
|
+
this.streamEnded = true;
|
|
145
|
+
for (const waiter of this.endWaiters){
|
|
146
|
+
waiter();
|
|
147
|
+
}
|
|
148
|
+
this.endWaiters = [];
|
|
149
|
+
}
|
|
150
|
+
parseEvent(raw) {
|
|
151
|
+
const lines = raw.split('\n');
|
|
152
|
+
const dataLines = [];
|
|
153
|
+
let event;
|
|
154
|
+
let id;
|
|
155
|
+
let retry;
|
|
156
|
+
for (const line of lines){
|
|
157
|
+
if (line.startsWith(':')) continue; // comment line
|
|
158
|
+
const colonIndex = line.indexOf(':');
|
|
159
|
+
if (colonIndex === -1) continue;
|
|
160
|
+
const field = line.slice(0, colonIndex);
|
|
161
|
+
const value = line[colonIndex + 1] === ' ' ? line.slice(colonIndex + 2) : line.slice(colonIndex + 1);
|
|
162
|
+
switch(field){
|
|
163
|
+
case 'data':
|
|
164
|
+
dataLines.push(value);
|
|
165
|
+
break;
|
|
166
|
+
case 'event':
|
|
167
|
+
event = value;
|
|
168
|
+
break;
|
|
169
|
+
case 'id':
|
|
170
|
+
id = value;
|
|
171
|
+
break;
|
|
172
|
+
case 'retry':
|
|
173
|
+
{
|
|
174
|
+
const parsed = parseInt(value, 10);
|
|
175
|
+
if (!Number.isNaN(parsed)) retry = parsed;
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (dataLines.length === 0) return null;
|
|
181
|
+
const result = {
|
|
182
|
+
data: dataLines.join('\n')
|
|
183
|
+
};
|
|
184
|
+
if (event !== undefined) result.event = event;
|
|
185
|
+
if (id !== undefined) result.id = id;
|
|
186
|
+
if (retry !== undefined) result.retry = retry;
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
dispatchEvent(event) {
|
|
190
|
+
if (this.eventWaiters.length > 0) {
|
|
191
|
+
this.eventWaiters.shift()(event);
|
|
192
|
+
} else {
|
|
193
|
+
this.eventQueue.push(event);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ActingAsResolver, TestPrincipal, TestingModule } from '../testing-module.js';
|
|
2
|
+
import { TestSseConnection } from './test-sse-connection.js';
|
|
3
|
+
/**
|
|
4
|
+
* TestSseRequest
|
|
5
|
+
*
|
|
6
|
+
* Builder for a Server-Sent Events connection. `connect()` issues a GET through
|
|
7
|
+
* `module.fetch()`, asserts a `text/event-stream` 200, and wraps the streaming
|
|
8
|
+
* body in a {@link TestSseConnection}.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* const sse = await module.sse('/stream/events').connect();
|
|
13
|
+
* await sse.assertEvent({ event: 'message', data: 'hello' });
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export declare class TestSseRequest {
|
|
17
|
+
private readonly path;
|
|
18
|
+
private readonly module;
|
|
19
|
+
private readonly requestHeaders;
|
|
20
|
+
private principal;
|
|
21
|
+
private resolver;
|
|
22
|
+
constructor(path: string, module: TestingModule);
|
|
23
|
+
/** Merge additional headers onto the SSE request. */
|
|
24
|
+
withHeaders(headers: Record<string, string>): this;
|
|
25
|
+
/** Authenticate the connection (see {@link TestHttpRequest.actingAs}). */
|
|
26
|
+
actingAs(principal: TestPrincipal, resolver?: ActingAsResolver): this;
|
|
27
|
+
/** Open the stream and return a live {@link TestSseConnection}. */
|
|
28
|
+
connect(): Promise<TestSseConnection>;
|
|
29
|
+
private applyAuthentication;
|
|
30
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Adapted from @stratal/testing (MIT, © Temitayo Fadojutimi). Auth uses the
|
|
2
|
+
// generic resolver seam instead of a hard AuthService import.
|
|
3
|
+
import { expect } from "vitest";
|
|
4
|
+
import { TestSseConnection } from "./test-sse-connection.js";
|
|
5
|
+
/**
|
|
6
|
+
* TestSseRequest
|
|
7
|
+
*
|
|
8
|
+
* Builder for a Server-Sent Events connection. `connect()` issues a GET through
|
|
9
|
+
* `module.fetch()`, asserts a `text/event-stream` 200, and wraps the streaming
|
|
10
|
+
* body in a {@link TestSseConnection}.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* const sse = await module.sse('/stream/events').connect();
|
|
15
|
+
* await sse.assertEvent({ event: 'message', data: 'hello' });
|
|
16
|
+
* ```
|
|
17
|
+
*/ export class TestSseRequest {
|
|
18
|
+
path;
|
|
19
|
+
module;
|
|
20
|
+
requestHeaders = new Headers();
|
|
21
|
+
principal = null;
|
|
22
|
+
resolver = null;
|
|
23
|
+
constructor(path, module){
|
|
24
|
+
this.path = path;
|
|
25
|
+
this.module = module;
|
|
26
|
+
}
|
|
27
|
+
/** Merge additional headers onto the SSE request. */ withHeaders(headers) {
|
|
28
|
+
for (const [key, value] of Object.entries(headers)){
|
|
29
|
+
this.requestHeaders.set(key, value);
|
|
30
|
+
}
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
33
|
+
/** Authenticate the connection (see {@link TestHttpRequest.actingAs}). */ actingAs(principal, resolver) {
|
|
34
|
+
this.principal = principal;
|
|
35
|
+
this.resolver = resolver ?? null;
|
|
36
|
+
return this;
|
|
37
|
+
}
|
|
38
|
+
/** Open the stream and return a live {@link TestSseConnection}. */ async connect() {
|
|
39
|
+
await this.applyAuthentication();
|
|
40
|
+
this.requestHeaders.set('Accept', 'text/event-stream');
|
|
41
|
+
const url = new URL(this.path, 'http://localhost');
|
|
42
|
+
const request = new Request(url.toString(), {
|
|
43
|
+
headers: this.requestHeaders
|
|
44
|
+
});
|
|
45
|
+
const response = await this.module.fetch(request);
|
|
46
|
+
expect(response.status, `Expected status 200, got ${response.status}`).toBe(200);
|
|
47
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
48
|
+
expect(contentType.includes('text/event-stream'), `Expected content-type "text/event-stream", got "${contentType}"`).toBe(true);
|
|
49
|
+
return new TestSseConnection(response);
|
|
50
|
+
}
|
|
51
|
+
async applyAuthentication() {
|
|
52
|
+
if (!this.principal) return;
|
|
53
|
+
const resolver = this.resolver ?? this.module.getAuthResolver();
|
|
54
|
+
if (!resolver) {
|
|
55
|
+
throw new Error('actingAs() requires an auth resolver. Pass one explicitly or register ' + 'a default with module.setAuthResolver(resolver).');
|
|
56
|
+
}
|
|
57
|
+
const headers = await resolver(this.module, this.principal);
|
|
58
|
+
for (const [key, value] of headers.entries()){
|
|
59
|
+
this.requestHeaders.set(key, value);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { REQUEST_CONTEXT, Scope } from "@velajs/vela";
|
|
2
|
-
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_MIDDLEWARE, APP_PIPE,
|
|
1
|
+
import { DiscoveryService, REQUEST_CONTEXT, Scope } from "@velajs/vela";
|
|
2
|
+
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_MIDDLEWARE, APP_PIPE, Container, MetadataRegistry, ModuleLoader, ModuleRef, RouteManager, VelaApplication, bindAppProviders } from "@velajs/vela/internal";
|
|
3
3
|
import { TestingModule } from "./testing-module.js";
|
|
4
4
|
export class OverrideBy {
|
|
5
5
|
builder;
|
|
@@ -97,6 +97,17 @@ export class TestingModuleBuilder {
|
|
|
97
97
|
]
|
|
98
98
|
});
|
|
99
99
|
container.markGlobalToken(ModuleRef);
|
|
100
|
+
// Decorator-driven discovery — global so any provider can inject it, and so
|
|
101
|
+
// callOnApplicationBootstrap() reuses this instance instead of self-building
|
|
102
|
+
// one. Mirrors vela/src/factory/bootstrap.ts.
|
|
103
|
+
container.register({
|
|
104
|
+
provide: DiscoveryService,
|
|
105
|
+
useFactory: (c)=>new DiscoveryService(c),
|
|
106
|
+
inject: [
|
|
107
|
+
Container
|
|
108
|
+
]
|
|
109
|
+
});
|
|
110
|
+
container.markGlobalToken(DiscoveryService);
|
|
100
111
|
for (const t of [
|
|
101
112
|
APP_GUARD,
|
|
102
113
|
APP_PIPE,
|
|
@@ -126,27 +137,16 @@ export class TestingModuleBuilder {
|
|
|
126
137
|
container.register(override.provider);
|
|
127
138
|
}
|
|
128
139
|
const routeManager = new RouteManager(container);
|
|
129
|
-
ComponentManager.init(container);
|
|
130
140
|
const loader = new ModuleLoader(container, routeManager);
|
|
131
141
|
loader.load(TestRootModule);
|
|
132
142
|
// Force-apply overrides into every module bucket that already holds the
|
|
133
|
-
// token. Without this, controller constructor-injection (which
|
|
134
|
-
// requestingModuleId to findRegistration) finds the module's own
|
|
135
|
-
// registration first and never consults the root override.
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
// Reaches into Container internals (the `providers` map) until vela
|
|
139
|
-
// exposes a public "force replace across all scopes" API. The shape is
|
|
140
|
-
// stable in vela 1.6.x; if this breaks under a future vela version, raise
|
|
141
|
-
// a vela API request before working around again.
|
|
142
|
-
const providersMap = container.providers;
|
|
143
|
+
// token (plus root). Without this, controller constructor-injection (which
|
|
144
|
+
// passes requestingModuleId to findRegistration) finds the module's own
|
|
145
|
+
// registration first and never consults the root override. The default
|
|
146
|
+
// 'all-existing' buckets replace every non-root bucket holding the token
|
|
147
|
+
// and re-register at root — the supported form of the old private loop.
|
|
143
148
|
for (const override of this.overrides){
|
|
144
|
-
|
|
145
|
-
if (moduleId === '__root__') continue;
|
|
146
|
-
if (bucket.has(override.token)) {
|
|
147
|
-
container.register(override.provider, moduleId);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
149
|
+
container.replaceProvider(override.provider);
|
|
150
150
|
}
|
|
151
151
|
bindAppProviders(routeManager, container, loader);
|
|
152
152
|
const app = new VelaApplication(container, routeManager);
|
package/dist/testing-module.d.ts
CHANGED
|
@@ -1,10 +1,87 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Token, type Type, type VelaApplication } from '@velajs/vela';
|
|
2
2
|
import type { Container } from '@velajs/vela/internal';
|
|
3
|
+
import { type ISeeder } from '@velajs/vela/seeder';
|
|
4
|
+
import type { TestDatabase } from './db/test-database.js';
|
|
5
|
+
import { TestHttpClient } from './http/test-http-client.js';
|
|
6
|
+
import { TestSseRequest } from './sse/test-sse-request.js';
|
|
7
|
+
import { TestWsRequest } from './ws/test-ws-request.js';
|
|
8
|
+
/** A test principal — an opaque object the auth resolver turns into headers. */
|
|
9
|
+
export type TestPrincipal = Record<string, unknown>;
|
|
10
|
+
/**
|
|
11
|
+
* Turns a principal into request headers (session cookie, bearer token, …).
|
|
12
|
+
* The signature `(module, principal) => Promise<Headers>` is a cross-package
|
|
13
|
+
* contract: sibling packages (e.g. `@velajs/better-auth/testing`) build a
|
|
14
|
+
* resolver against it. Kept generic so `@velajs/testing` needs no auth deps.
|
|
15
|
+
*/
|
|
16
|
+
export type ActingAsResolver = (module: TestingModule, principal: TestPrincipal) => Promise<Headers>;
|
|
17
|
+
/**
|
|
18
|
+
* TestingModule
|
|
19
|
+
*
|
|
20
|
+
* The compiled test harness. Beyond `get`/`createApplication`/`close`, it adds
|
|
21
|
+
* Laravel-flavored ergonomics: a fluent HTTP client, SSE/WS builders, request-
|
|
22
|
+
* scope execution, seeding, and database assertion wrappers.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* const module = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
|
27
|
+
* await module.http.post('/users').withBody({ name: 'A' }).send()
|
|
28
|
+
* .then((r) => r.assertCreated());
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
3
31
|
export declare class TestingModule {
|
|
4
32
|
private readonly app;
|
|
5
33
|
private readonly container;
|
|
34
|
+
private _http;
|
|
35
|
+
private honoApp;
|
|
36
|
+
private authResolver;
|
|
6
37
|
constructor(app: VelaApplication, container: Container);
|
|
38
|
+
/** Resolve a provider from the root container. */
|
|
7
39
|
get<T>(token: Token<T>): T;
|
|
40
|
+
/** Build (once) and return the underlying application. */
|
|
8
41
|
createApplication(): Promise<VelaApplication>;
|
|
42
|
+
/** Lazy fluent HTTP client bound to this module. */
|
|
43
|
+
get http(): TestHttpClient;
|
|
44
|
+
/** Start an SSE connection builder for `path`. */
|
|
45
|
+
sse(path: string): TestSseRequest;
|
|
46
|
+
/** Start a WebSocket connection builder for `path` (needs a transport adapter). */
|
|
47
|
+
ws(path: string): TestWsRequest;
|
|
48
|
+
/**
|
|
49
|
+
* Drive a `Request` through the full Hono pipeline. The Hono app is built
|
|
50
|
+
* once and reused across requests.
|
|
51
|
+
*/
|
|
52
|
+
fetch(request: Request, env?: unknown, ctx?: unknown): Promise<Response>;
|
|
53
|
+
/**
|
|
54
|
+
* Register a default auth resolver used by `actingAs(principal)` when no
|
|
55
|
+
* resolver is passed explicitly.
|
|
56
|
+
*/
|
|
57
|
+
setAuthResolver(resolver: ActingAsResolver): this;
|
|
58
|
+
/** The default auth resolver, if one was registered. */
|
|
59
|
+
getAuthResolver(): ActingAsResolver | null;
|
|
60
|
+
/**
|
|
61
|
+
* Run `callback` inside a request-scoped child container seeded with a mock
|
|
62
|
+
* {@link RequestContext}, so REQUEST-scoped providers (and anything injecting
|
|
63
|
+
* `REQUEST_CONTEXT`) resolve. The child is disposed afterwards.
|
|
64
|
+
*/
|
|
65
|
+
runInRequestScope<T>(callback: (container: Container) => T | Promise<T>): Promise<T>;
|
|
66
|
+
/**
|
|
67
|
+
* Run the given `@Seeder()` classes, each in its own request scope. Throws if
|
|
68
|
+
* a class is not a registered seeder. Requires `SeederModule` (or the seeders
|
|
69
|
+
* themselves) to be present in the module graph.
|
|
70
|
+
*/
|
|
71
|
+
seed(...SeederClasses: Type<ISeeder>[]): Promise<void>;
|
|
72
|
+
/** Assert a row matching `where` exists in `table` (via a {@link TestDatabase}). */
|
|
73
|
+
assertDatabaseHas(db: TestDatabase, table: string, where: Record<string, unknown>): Promise<void>;
|
|
74
|
+
/** Assert no row matching `where` exists in `table`. */
|
|
75
|
+
assertDatabaseMissing(db: TestDatabase, table: string, where: Record<string, unknown>): Promise<void>;
|
|
76
|
+
/** Assert `table` has exactly `expected` rows. */
|
|
77
|
+
assertDatabaseCount(db: TestDatabase, table: string, expected: number): Promise<void>;
|
|
78
|
+
/** Dispose the application. */
|
|
9
79
|
close(signal?: string): Promise<void>;
|
|
80
|
+
private ensureHono;
|
|
81
|
+
/**
|
|
82
|
+
* Build a minimal, functional {@link RequestContext} for out-of-band request
|
|
83
|
+
* scopes. Vela has no `createMockRouterContext`; a real (empty) Hono `Context`
|
|
84
|
+
* backs the `hono` field so nothing dangles.
|
|
85
|
+
*/
|
|
86
|
+
private createMockRequestContext;
|
|
10
87
|
}
|
package/dist/testing-module.js
CHANGED
|
@@ -1,18 +1,141 @@
|
|
|
1
|
-
|
|
1
|
+
import { Context } from "hono";
|
|
2
|
+
import { REQUEST_CONTEXT } from "@velajs/vela";
|
|
3
|
+
import { SeederRegistry } from "@velajs/vela/seeder";
|
|
4
|
+
import { expect } from "vitest";
|
|
5
|
+
import { TestHttpClient } from "./http/test-http-client.js";
|
|
6
|
+
import { TestSseRequest } from "./sse/test-sse-request.js";
|
|
7
|
+
import { TestWsRequest } from "./ws/test-ws-request.js";
|
|
8
|
+
/**
|
|
9
|
+
* TestingModule
|
|
10
|
+
*
|
|
11
|
+
* The compiled test harness. Beyond `get`/`createApplication`/`close`, it adds
|
|
12
|
+
* Laravel-flavored ergonomics: a fluent HTTP client, SSE/WS builders, request-
|
|
13
|
+
* scope execution, seeding, and database assertion wrappers.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* const module = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
|
18
|
+
* await module.http.post('/users').withBody({ name: 'A' }).send()
|
|
19
|
+
* .then((r) => r.assertCreated());
|
|
20
|
+
* ```
|
|
21
|
+
*/ export class TestingModule {
|
|
2
22
|
app;
|
|
3
23
|
container;
|
|
24
|
+
_http = null;
|
|
25
|
+
honoApp = null;
|
|
26
|
+
authResolver = null;
|
|
4
27
|
constructor(app, container){
|
|
5
28
|
this.app = app;
|
|
6
29
|
this.container = container;
|
|
7
30
|
}
|
|
8
|
-
get(token) {
|
|
31
|
+
/** Resolve a provider from the root container. */ get(token) {
|
|
9
32
|
return this.container.resolve(token);
|
|
10
33
|
}
|
|
11
|
-
async createApplication() {
|
|
34
|
+
/** Build (once) and return the underlying application. */ async createApplication() {
|
|
12
35
|
await this.app.initRoutes();
|
|
13
36
|
return this.app;
|
|
14
37
|
}
|
|
15
|
-
|
|
38
|
+
/** Lazy fluent HTTP client bound to this module. */ get http() {
|
|
39
|
+
this._http ??= new TestHttpClient(this);
|
|
40
|
+
return this._http;
|
|
41
|
+
}
|
|
42
|
+
/** Start an SSE connection builder for `path`. */ sse(path) {
|
|
43
|
+
return new TestSseRequest(path, this);
|
|
44
|
+
}
|
|
45
|
+
/** Start a WebSocket connection builder for `path` (needs a transport adapter). */ ws(path) {
|
|
46
|
+
return new TestWsRequest(path, this);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Drive a `Request` through the full Hono pipeline. The Hono app is built
|
|
50
|
+
* once and reused across requests.
|
|
51
|
+
*/ async fetch(request, env, ctx) {
|
|
52
|
+
const hono = await this.ensureHono();
|
|
53
|
+
return hono.fetch(request, env, ctx);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Register a default auth resolver used by `actingAs(principal)` when no
|
|
57
|
+
* resolver is passed explicitly.
|
|
58
|
+
*/ setAuthResolver(resolver) {
|
|
59
|
+
this.authResolver = resolver;
|
|
60
|
+
return this;
|
|
61
|
+
}
|
|
62
|
+
/** The default auth resolver, if one was registered. */ getAuthResolver() {
|
|
63
|
+
return this.authResolver;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Run `callback` inside a request-scoped child container seeded with a mock
|
|
67
|
+
* {@link RequestContext}, so REQUEST-scoped providers (and anything injecting
|
|
68
|
+
* `REQUEST_CONTEXT`) resolve. The child is disposed afterwards.
|
|
69
|
+
*/ async runInRequestScope(callback) {
|
|
70
|
+
const child = this.container.createChild();
|
|
71
|
+
child.setRequestInstance(REQUEST_CONTEXT, this.createMockRequestContext());
|
|
72
|
+
try {
|
|
73
|
+
return await callback(child);
|
|
74
|
+
} finally{
|
|
75
|
+
await child.dispose();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Run the given `@Seeder()` classes, each in its own request scope. Throws if
|
|
80
|
+
* a class is not a registered seeder. Requires `SeederModule` (or the seeders
|
|
81
|
+
* themselves) to be present in the module graph.
|
|
82
|
+
*/ async seed(...SeederClasses) {
|
|
83
|
+
const registry = this.container.resolve(SeederRegistry);
|
|
84
|
+
const known = new Set(registry.list().map((s)=>s.target));
|
|
85
|
+
for (const SeederClass of SeederClasses){
|
|
86
|
+
if (!known.has(SeederClass)) {
|
|
87
|
+
throw new Error(`Seeder "${SeederClass.name}" is not registered. Add it to a module's ` + 'providers or SeederModule.forRoot({ seeders: [...] }).');
|
|
88
|
+
}
|
|
89
|
+
await this.runInRequestScope(async (child)=>{
|
|
90
|
+
const instance = child.resolve(SeederClass);
|
|
91
|
+
await instance.run();
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Assert a row matching `where` exists in `table` (via a {@link TestDatabase}). */ async assertDatabaseHas(db, table, where) {
|
|
96
|
+
const exists = await db.has(table, where);
|
|
97
|
+
expect(exists, `Expected ${table} to have a row matching ${JSON.stringify(where)}`).toBe(true);
|
|
98
|
+
}
|
|
99
|
+
/** Assert no row matching `where` exists in `table`. */ async assertDatabaseMissing(db, table, where) {
|
|
100
|
+
const exists = await db.has(table, where);
|
|
101
|
+
expect(exists, `Expected ${table} NOT to have a row matching ${JSON.stringify(where)}`).toBe(false);
|
|
102
|
+
}
|
|
103
|
+
/** Assert `table` has exactly `expected` rows. */ async assertDatabaseCount(db, table, expected) {
|
|
104
|
+
const actual = await db.count(table);
|
|
105
|
+
expect(actual, `Expected ${table} count ${expected}, got ${actual}`).toBe(expected);
|
|
106
|
+
}
|
|
107
|
+
/** Dispose the application. */ async close(signal) {
|
|
16
108
|
await this.app.close(signal);
|
|
17
109
|
}
|
|
110
|
+
async ensureHono() {
|
|
111
|
+
if (!this.honoApp) {
|
|
112
|
+
const app = await this.createApplication();
|
|
113
|
+
this.honoApp = app.getHonoApp();
|
|
114
|
+
}
|
|
115
|
+
return this.honoApp;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Build a minimal, functional {@link RequestContext} for out-of-band request
|
|
119
|
+
* scopes. Vela has no `createMockRouterContext`; a real (empty) Hono `Context`
|
|
120
|
+
* backs the `hono` field so nothing dangles.
|
|
121
|
+
*/ createMockRequestContext() {
|
|
122
|
+
const bag = new Map();
|
|
123
|
+
const request = new Request('http://localhost/');
|
|
124
|
+
const hono = new Context(request);
|
|
125
|
+
return {
|
|
126
|
+
id: crypto.randomUUID(),
|
|
127
|
+
receivedAt: new Date(),
|
|
128
|
+
request,
|
|
129
|
+
hono,
|
|
130
|
+
set (key, value) {
|
|
131
|
+
bag.set(key, value);
|
|
132
|
+
},
|
|
133
|
+
get (key) {
|
|
134
|
+
return bag.get(key);
|
|
135
|
+
},
|
|
136
|
+
has (key) {
|
|
137
|
+
return bag.has(key);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
18
141
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|