@stratal/testing 0.0.13 → 0.0.14
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.mts +1070 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1689 -0
- package/dist/index.mjs.map +1 -0
- package/dist/mocks/index.d.mts +2 -0
- package/dist/mocks/index.mjs +2 -0
- package/dist/mocks/nodemailer.d.mts +12 -0
- package/dist/mocks/nodemailer.d.mts.map +1 -0
- package/dist/mocks/nodemailer.mjs +7 -0
- package/dist/mocks/nodemailer.mjs.map +1 -0
- package/dist/mocks/zenstack-language.d.mts +48 -0
- package/dist/mocks/zenstack-language.d.mts.map +1 -0
- package/dist/mocks/zenstack-language.mjs +48 -0
- package/dist/mocks/zenstack-language.mjs.map +1 -0
- package/dist/vitest-plugin/index.d.mts +50 -0
- package/dist/vitest-plugin/index.d.mts.map +1 -0
- package/dist/vitest-plugin/index.mjs +86 -0
- package/dist/vitest-plugin/index.mjs.map +1 -0
- package/package.json +21 -19
- package/dist/auth/acting-as.d.ts +0 -21
- package/dist/auth/acting-as.d.ts.map +0 -1
- package/dist/auth/acting-as.js +0 -68
- package/dist/auth/acting-as.js.map +0 -1
- package/dist/auth/index.d.ts +0 -2
- package/dist/auth/index.d.ts.map +0 -1
- package/dist/auth/index.js +0 -2
- package/dist/auth/index.js.map +0 -1
- package/dist/core/env/index.d.ts +0 -2
- package/dist/core/env/index.d.ts.map +0 -1
- package/dist/core/env/index.js +0 -2
- package/dist/core/env/index.js.map +0 -1
- package/dist/core/env/test-env.d.ts +0 -9
- package/dist/core/env/test-env.d.ts.map +0 -1
- package/dist/core/env/test-env.js +0 -14
- package/dist/core/env/test-env.js.map +0 -1
- package/dist/core/http/fetch-mock.types.d.ts +0 -48
- package/dist/core/http/fetch-mock.types.d.ts.map +0 -1
- package/dist/core/http/fetch-mock.types.js +0 -2
- package/dist/core/http/fetch-mock.types.js.map +0 -1
- package/dist/core/http/index.d.ts +0 -6
- package/dist/core/http/index.d.ts.map +0 -1
- package/dist/core/http/index.js +0 -5
- package/dist/core/http/index.js.map +0 -1
- package/dist/core/http/mock-fetch.d.ts +0 -88
- package/dist/core/http/mock-fetch.d.ts.map +0 -1
- package/dist/core/http/mock-fetch.js +0 -111
- package/dist/core/http/mock-fetch.js.map +0 -1
- package/dist/core/http/test-http-client.d.ts +0 -54
- package/dist/core/http/test-http-client.d.ts.map +0 -1
- package/dist/core/http/test-http-client.js +0 -75
- package/dist/core/http/test-http-client.js.map +0 -1
- package/dist/core/http/test-http-request.d.ts +0 -60
- package/dist/core/http/test-http-request.d.ts.map +0 -1
- package/dist/core/http/test-http-request.js +0 -106
- package/dist/core/http/test-http-request.js.map +0 -1
- package/dist/core/http/test-response.d.ts +0 -161
- package/dist/core/http/test-response.d.ts.map +0 -1
- package/dist/core/http/test-response.js +0 -309
- package/dist/core/http/test-response.js.map +0 -1
- package/dist/core/index.d.ts +0 -7
- package/dist/core/index.d.ts.map +0 -1
- package/dist/core/index.js +0 -7
- package/dist/core/index.js.map +0 -1
- package/dist/core/override/index.d.ts +0 -2
- package/dist/core/override/index.d.ts.map +0 -1
- package/dist/core/override/index.js +0 -2
- package/dist/core/override/index.js.map +0 -1
- package/dist/core/override/provider-override-builder.d.ts +0 -78
- package/dist/core/override/provider-override-builder.d.ts.map +0 -1
- package/dist/core/override/provider-override-builder.js +0 -94
- package/dist/core/override/provider-override-builder.js.map +0 -1
- package/dist/core/sse/index.d.ts +0 -3
- package/dist/core/sse/index.d.ts.map +0 -1
- package/dist/core/sse/index.js +0 -3
- package/dist/core/sse/index.js.map +0 -1
- package/dist/core/sse/test-sse-connection.d.ts +0 -61
- package/dist/core/sse/test-sse-connection.d.ts.map +0 -1
- package/dist/core/sse/test-sse-connection.js +0 -233
- package/dist/core/sse/test-sse-connection.js.map +0 -1
- package/dist/core/sse/test-sse-request.d.ts +0 -42
- package/dist/core/sse/test-sse-request.d.ts.map +0 -1
- package/dist/core/sse/test-sse-request.js +0 -76
- package/dist/core/sse/test-sse-request.js.map +0 -1
- package/dist/core/test.d.ts +0 -48
- package/dist/core/test.d.ts.map +0 -1
- package/dist/core/test.js +0 -53
- package/dist/core/test.js.map +0 -1
- package/dist/core/testing-module-builder.d.ts +0 -57
- package/dist/core/testing-module-builder.d.ts.map +0 -1
- package/dist/core/testing-module-builder.js +0 -109
- package/dist/core/testing-module-builder.js.map +0 -1
- package/dist/core/testing-module.d.ts +0 -113
- package/dist/core/testing-module.d.ts.map +0 -1
- package/dist/core/testing-module.js +0 -177
- package/dist/core/testing-module.js.map +0 -1
- package/dist/core/ws/index.d.ts +0 -3
- package/dist/core/ws/index.d.ts.map +0 -1
- package/dist/core/ws/index.js +0 -3
- package/dist/core/ws/index.js.map +0 -1
- package/dist/core/ws/test-ws-connection.d.ts +0 -54
- package/dist/core/ws/test-ws-connection.d.ts.map +0 -1
- package/dist/core/ws/test-ws-connection.js +0 -119
- package/dist/core/ws/test-ws-connection.js.map +0 -1
- package/dist/core/ws/test-ws-request.d.ts +0 -43
- package/dist/core/ws/test-ws-request.d.ts.map +0 -1
- package/dist/core/ws/test-ws-request.js +0 -83
- package/dist/core/ws/test-ws-request.js.map +0 -1
- package/dist/errors/index.d.ts +0 -3
- package/dist/errors/index.d.ts.map +0 -1
- package/dist/errors/index.js +0 -3
- package/dist/errors/index.js.map +0 -1
- package/dist/errors/setup-error.d.ts +0 -9
- package/dist/errors/setup-error.d.ts.map +0 -1
- package/dist/errors/setup-error.js +0 -11
- package/dist/errors/setup-error.js.map +0 -1
- package/dist/errors/test-error.d.ts +0 -9
- package/dist/errors/test-error.d.ts.map +0 -1
- package/dist/errors/test-error.js +0 -15
- package/dist/errors/test-error.js.map +0 -1
- package/dist/index.d.ts +0 -21
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -29
- package/dist/index.js.map +0 -1
- package/dist/mocks/index.d.ts +0 -3
- package/dist/mocks/index.d.ts.map +0 -1
- package/dist/mocks/index.js +0 -3
- package/dist/mocks/index.js.map +0 -1
- package/dist/mocks/nodemailer.d.ts +0 -10
- package/dist/mocks/nodemailer.d.ts.map +0 -1
- package/dist/mocks/nodemailer.js +0 -9
- package/dist/mocks/nodemailer.js.map +0 -1
- package/dist/mocks/zenstack-language.d.ts +0 -46
- package/dist/mocks/zenstack-language.d.ts.map +0 -1
- package/dist/mocks/zenstack-language.js +0 -47
- package/dist/mocks/zenstack-language.js.map +0 -1
- package/dist/storage/fake-storage.service.d.ts +0 -114
- package/dist/storage/fake-storage.service.d.ts.map +0 -1
- package/dist/storage/fake-storage.service.js +0 -233
- package/dist/storage/fake-storage.service.js.map +0 -1
- package/dist/storage/index.d.ts +0 -2
- package/dist/storage/index.d.ts.map +0 -1
- package/dist/storage/index.js +0 -2
- package/dist/storage/index.js.map +0 -1
- package/dist/types.d.ts +0 -5
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -3
- package/dist/types.js.map +0 -1
- package/dist/vitest-plugin/index.d.ts +0 -2
- package/dist/vitest-plugin/index.d.ts.map +0 -1
- package/dist/vitest-plugin/index.js +0 -2
- package/dist/vitest-plugin/index.js.map +0 -1
- package/dist/vitest-plugin/stratal-test.d.ts +0 -28
- package/dist/vitest-plugin/stratal-test.d.ts.map +0 -1
- package/dist/vitest-plugin/stratal-test.js +0 -47
- package/dist/vitest-plugin/stratal-test.js.map +0 -1
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1689 @@
|
|
|
1
|
+
import { env, waitUntil } from "cloudflare:workers";
|
|
2
|
+
import { Application } from "stratal";
|
|
3
|
+
import { LogLevel } from "stratal/logger";
|
|
4
|
+
import { Module } from "stratal/module";
|
|
5
|
+
import { FileNotFoundError, STORAGE_TOKENS, StorageManagerService, StorageService } from "stratal/storage";
|
|
6
|
+
import { DI_TOKENS, Transient, inject } from "stratal/di";
|
|
7
|
+
import { expect } from "vitest";
|
|
8
|
+
import { connectionSymbol } from "@stratal/framework/database";
|
|
9
|
+
import { AUTH_SERVICE } from "@stratal/framework/auth";
|
|
10
|
+
import { setSessionCookie } from "better-auth/cookies";
|
|
11
|
+
import { convertSetCookieToCookie } from "better-auth/test";
|
|
12
|
+
import { HttpResponse, HttpResponse as HttpResponse$1, http, http as http$1 } from "msw";
|
|
13
|
+
import { setupServer } from "msw/node";
|
|
14
|
+
//#region src/core/override/provider-override-builder.ts
|
|
15
|
+
/**
|
|
16
|
+
* Fluent builder for provider overrides
|
|
17
|
+
*
|
|
18
|
+
* Provides a NestJS-style API for overriding providers in tests.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* const module = await Test.createTestingModule({
|
|
23
|
+
* imports: [RegistrationModule],
|
|
24
|
+
* })
|
|
25
|
+
* .overrideProvider(EMAIL_TOKENS.EmailService)
|
|
26
|
+
* .useValue(mockEmailService)
|
|
27
|
+
* .compile()
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
var ProviderOverrideBuilder = class {
|
|
31
|
+
constructor(parent, token) {
|
|
32
|
+
this.parent = parent;
|
|
33
|
+
this.token = token;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Use a static value as the provider
|
|
37
|
+
*
|
|
38
|
+
* The value will be registered directly in the container.
|
|
39
|
+
*
|
|
40
|
+
* @param value - The value to use as the provider
|
|
41
|
+
* @returns The parent TestingModuleBuilder for chaining
|
|
42
|
+
*/
|
|
43
|
+
useValue(value) {
|
|
44
|
+
return this.parent.addProviderOverride({
|
|
45
|
+
token: this.token,
|
|
46
|
+
type: "value",
|
|
47
|
+
implementation: value
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Use a class as the provider
|
|
52
|
+
*
|
|
53
|
+
* The class will be registered as a singleton.
|
|
54
|
+
*
|
|
55
|
+
* @param cls - The class constructor to use as the provider
|
|
56
|
+
* @returns The parent TestingModuleBuilder for chaining
|
|
57
|
+
*/
|
|
58
|
+
useClass(cls) {
|
|
59
|
+
return this.parent.addProviderOverride({
|
|
60
|
+
token: this.token,
|
|
61
|
+
type: "class",
|
|
62
|
+
implementation: cls
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Use a factory function as the provider
|
|
67
|
+
*
|
|
68
|
+
* The factory receives the container and should return the provider instance.
|
|
69
|
+
*
|
|
70
|
+
* @param factory - Factory function that creates the provider
|
|
71
|
+
* @returns The parent TestingModuleBuilder for chaining
|
|
72
|
+
*/
|
|
73
|
+
useFactory(factory) {
|
|
74
|
+
return this.parent.addProviderOverride({
|
|
75
|
+
token: this.token,
|
|
76
|
+
type: "factory",
|
|
77
|
+
implementation: factory
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Use an existing token as the provider (alias)
|
|
82
|
+
*
|
|
83
|
+
* The override token will resolve to the same instance as the target token.
|
|
84
|
+
*
|
|
85
|
+
* @param existingToken - The token to alias
|
|
86
|
+
* @returns The parent TestingModuleBuilder for chaining
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```typescript
|
|
90
|
+
* const module = await Test.createTestingModule({
|
|
91
|
+
* imports: [MyModule],
|
|
92
|
+
* })
|
|
93
|
+
* .overrideProvider(ABSTRACT_TOKEN)
|
|
94
|
+
* .useExisting(ConcreteService)
|
|
95
|
+
* .compile()
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
useExisting(existingToken) {
|
|
99
|
+
return this.parent.addProviderOverride({
|
|
100
|
+
token: this.token,
|
|
101
|
+
type: "existing",
|
|
102
|
+
implementation: existingToken
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region \0@oxc-project+runtime@0.115.0/helpers/decorateMetadata.js
|
|
108
|
+
function __decorateMetadata(k, v) {
|
|
109
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
110
|
+
}
|
|
111
|
+
//#endregion
|
|
112
|
+
//#region \0@oxc-project+runtime@0.115.0/helpers/decorateParam.js
|
|
113
|
+
function __decorateParam(paramIndex, decorator) {
|
|
114
|
+
return function(target, key) {
|
|
115
|
+
decorator(target, key, paramIndex);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
//#endregion
|
|
119
|
+
//#region \0@oxc-project+runtime@0.115.0/helpers/decorate.js
|
|
120
|
+
function __decorate(decorators, target, key, desc) {
|
|
121
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
122
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
123
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
124
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
125
|
+
}
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/storage/fake-storage.service.ts
|
|
128
|
+
var _ref;
|
|
129
|
+
let FakeStorageService = class FakeStorageService extends StorageService {
|
|
130
|
+
files = /* @__PURE__ */ new Map();
|
|
131
|
+
constructor(storageManager, options) {
|
|
132
|
+
super(storageManager, options);
|
|
133
|
+
this.storageManager = storageManager;
|
|
134
|
+
this.options = options;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Upload content to fake storage
|
|
138
|
+
*/
|
|
139
|
+
async upload(body, relativePath, options, disk) {
|
|
140
|
+
const content = await this.bodyToUint8Array(body);
|
|
141
|
+
const diskName = this.resolveDisk(disk);
|
|
142
|
+
this.files.set(relativePath, {
|
|
143
|
+
content,
|
|
144
|
+
mimeType: options.mimeType ?? "application/octet-stream",
|
|
145
|
+
size: options.size,
|
|
146
|
+
metadata: options.metadata,
|
|
147
|
+
uploadedAt: /* @__PURE__ */ new Date()
|
|
148
|
+
});
|
|
149
|
+
return {
|
|
150
|
+
path: relativePath,
|
|
151
|
+
disk: diskName,
|
|
152
|
+
fullPath: relativePath,
|
|
153
|
+
size: options.size,
|
|
154
|
+
mimeType: options.mimeType ?? "application/octet-stream",
|
|
155
|
+
uploadedAt: /* @__PURE__ */ new Date()
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Download a file from fake storage
|
|
160
|
+
*/
|
|
161
|
+
download(path) {
|
|
162
|
+
const file = this.files.get(path);
|
|
163
|
+
if (!file) return Promise.reject(new FileNotFoundError(path));
|
|
164
|
+
return Promise.resolve({
|
|
165
|
+
toStream: () => new ReadableStream({ start(controller) {
|
|
166
|
+
controller.enqueue(file.content);
|
|
167
|
+
controller.close();
|
|
168
|
+
} }),
|
|
169
|
+
toString: () => Promise.resolve(new TextDecoder().decode(file.content)),
|
|
170
|
+
toArrayBuffer: () => Promise.resolve(file.content),
|
|
171
|
+
contentType: file.mimeType,
|
|
172
|
+
size: file.size,
|
|
173
|
+
metadata: file.metadata
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Delete a file from fake storage
|
|
178
|
+
*/
|
|
179
|
+
delete(path) {
|
|
180
|
+
this.files.delete(path);
|
|
181
|
+
return Promise.resolve();
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Check if a file exists in fake storage
|
|
185
|
+
*/
|
|
186
|
+
exists(path) {
|
|
187
|
+
return Promise.resolve(this.files.has(path));
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Generate a fake presigned download URL
|
|
191
|
+
*/
|
|
192
|
+
getPresignedDownloadUrl(path, expiresIn) {
|
|
193
|
+
return Promise.resolve(this.createPresignedUrl(path, "GET", expiresIn));
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Generate a fake presigned upload URL
|
|
197
|
+
*/
|
|
198
|
+
getPresignedUploadUrl(path, expiresIn) {
|
|
199
|
+
return Promise.resolve(this.createPresignedUrl(path, "PUT", expiresIn));
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Generate a fake presigned delete URL
|
|
203
|
+
*/
|
|
204
|
+
getPresignedDeleteUrl(path, expiresIn) {
|
|
205
|
+
return Promise.resolve(this.createPresignedUrl(path, "DELETE", expiresIn));
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Chunked upload (same as regular upload for fake)
|
|
209
|
+
*/
|
|
210
|
+
async chunkedUpload(body, path, options, disk) {
|
|
211
|
+
const content = await this.bodyToUint8Array(body);
|
|
212
|
+
const size = options.size ?? content.length;
|
|
213
|
+
return this.upload(body, path, {
|
|
214
|
+
...options,
|
|
215
|
+
size
|
|
216
|
+
}, disk);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Assert that a file exists at the given path
|
|
220
|
+
*
|
|
221
|
+
* @param path - Path to check
|
|
222
|
+
* @throws AssertionError if file does not exist
|
|
223
|
+
*/
|
|
224
|
+
assertExists(path) {
|
|
225
|
+
expect(this.files.has(path), `Expected file to exist at: ${path}\nStored files: ${this.getStoredPaths().join(", ") || "(none)"}`).toBe(true);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Assert that a file does NOT exist at the given path
|
|
229
|
+
*
|
|
230
|
+
* @param path - Path to check
|
|
231
|
+
* @throws AssertionError if file exists
|
|
232
|
+
*/
|
|
233
|
+
assertMissing(path) {
|
|
234
|
+
expect(this.files.has(path), `Expected file NOT to exist at: ${path}`).toBe(false);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Assert storage is empty
|
|
238
|
+
*
|
|
239
|
+
* @throws AssertionError if any files exist
|
|
240
|
+
*/
|
|
241
|
+
assertEmpty() {
|
|
242
|
+
expect(this.files.size, `Expected storage to be empty but found ${this.files.size} files: ${this.getStoredPaths().join(", ")}`).toBe(0);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Assert storage has exactly N files
|
|
246
|
+
*
|
|
247
|
+
* @param count - Expected number of files
|
|
248
|
+
* @throws AssertionError if count doesn't match
|
|
249
|
+
*/
|
|
250
|
+
assertCount(count) {
|
|
251
|
+
expect(this.files.size, `Expected ${count} files in storage but found ${this.files.size}`).toBe(count);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Get all stored files (for inspection)
|
|
255
|
+
*/
|
|
256
|
+
getStoredFiles() {
|
|
257
|
+
return new Map(this.files);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Get all stored file paths
|
|
261
|
+
*/
|
|
262
|
+
getStoredPaths() {
|
|
263
|
+
return Array.from(this.files.keys());
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Get a specific file by path
|
|
267
|
+
*/
|
|
268
|
+
getFile(path) {
|
|
269
|
+
return this.files.get(path);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Clear all stored files (call in beforeEach for test isolation)
|
|
273
|
+
*/
|
|
274
|
+
clear() {
|
|
275
|
+
this.files.clear();
|
|
276
|
+
}
|
|
277
|
+
createPresignedUrl(path, method, expiresIn = 300) {
|
|
278
|
+
const expiresAt = new Date(Date.now() + expiresIn * 1e3);
|
|
279
|
+
return {
|
|
280
|
+
url: `https://fake-storage.test/${path}?method=${method}&expires=${expiresAt.toISOString()}`,
|
|
281
|
+
expiresIn,
|
|
282
|
+
expiresAt,
|
|
283
|
+
method
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
async bodyToUint8Array(body) {
|
|
287
|
+
if (!body) return new Uint8Array(0);
|
|
288
|
+
if (body instanceof Uint8Array) return body;
|
|
289
|
+
if (body instanceof ArrayBuffer) return new Uint8Array(body);
|
|
290
|
+
if (typeof body === "string") return new TextEncoder().encode(body);
|
|
291
|
+
if (body instanceof Blob) {
|
|
292
|
+
const buffer = await body.arrayBuffer();
|
|
293
|
+
return new Uint8Array(buffer);
|
|
294
|
+
}
|
|
295
|
+
if (body instanceof ReadableStream) return new Uint8Array(await new Response(body).arrayBuffer());
|
|
296
|
+
if (body instanceof FormData || body instanceof URLSearchParams) return new Uint8Array(await new Response(body).arrayBuffer());
|
|
297
|
+
return new Uint8Array(0);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
FakeStorageService = __decorate([
|
|
301
|
+
Transient(STORAGE_TOKENS.StorageService),
|
|
302
|
+
__decorateParam(0, inject(STORAGE_TOKENS.StorageManager)),
|
|
303
|
+
__decorateParam(1, inject(STORAGE_TOKENS.Options)),
|
|
304
|
+
__decorateMetadata("design:paramtypes", [typeof (_ref = typeof StorageManagerService !== "undefined" && StorageManagerService) === "function" ? _ref : Object, Object])
|
|
305
|
+
], FakeStorageService);
|
|
306
|
+
//#endregion
|
|
307
|
+
//#region src/core/env/test-env.ts
|
|
308
|
+
/**
|
|
309
|
+
* Get test environment with optional overrides
|
|
310
|
+
*
|
|
311
|
+
* @param overrides - Optional partial env to merge with cloudflare:test env
|
|
312
|
+
* @returns Complete Env object for testing
|
|
313
|
+
*/
|
|
314
|
+
function getTestEnv(overrides) {
|
|
315
|
+
return {
|
|
316
|
+
...env,
|
|
317
|
+
...overrides
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
//#endregion
|
|
321
|
+
//#region src/auth/acting-as.ts
|
|
322
|
+
async function makeSignature(value, secret) {
|
|
323
|
+
const algorithm = {
|
|
324
|
+
name: "HMAC",
|
|
325
|
+
hash: "SHA-256"
|
|
326
|
+
};
|
|
327
|
+
const secretBuf = new TextEncoder().encode(secret);
|
|
328
|
+
const key = await crypto.subtle.importKey("raw", secretBuf, algorithm, false, ["sign"]);
|
|
329
|
+
const signature = await crypto.subtle.sign(algorithm.name, key, new TextEncoder().encode(value));
|
|
330
|
+
return btoa(String.fromCharCode(...new Uint8Array(signature)));
|
|
331
|
+
}
|
|
332
|
+
function buildCookieString(name, value, options = {}) {
|
|
333
|
+
let str = `${name}=${encodeURIComponent(value)}`;
|
|
334
|
+
if (options.path) str += `; Path=${options.path}`;
|
|
335
|
+
if (options.httpOnly) str += "; HttpOnly";
|
|
336
|
+
if (options.secure) str += "; Secure";
|
|
337
|
+
if (options.sameSite) str += `; SameSite=${options.sameSite}`;
|
|
338
|
+
if (options.maxAge !== void 0) str += `; Max-Age=${Math.floor(options.maxAge)}`;
|
|
339
|
+
return str;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* ActingAs
|
|
343
|
+
*
|
|
344
|
+
* Creates authentication sessions for testing.
|
|
345
|
+
* Uses Better Auth's internalAdapter to create real database sessions.
|
|
346
|
+
*
|
|
347
|
+
* @example
|
|
348
|
+
* ```typescript
|
|
349
|
+
* const actingAs = new ActingAs(authService)
|
|
350
|
+
* const headers = await actingAs.createSessionForUser({ id: 'user-123' })
|
|
351
|
+
* ```
|
|
352
|
+
*/
|
|
353
|
+
var ActingAs = class {
|
|
354
|
+
constructor(authService) {
|
|
355
|
+
this.authService = authService;
|
|
356
|
+
}
|
|
357
|
+
async createSessionForUser(user) {
|
|
358
|
+
const ctx = await this.authService.auth.$context;
|
|
359
|
+
const secret = ctx.secret;
|
|
360
|
+
const session = await ctx.internalAdapter.createSession(user.id, void 0, {
|
|
361
|
+
ipAddress: "127.0.0.1",
|
|
362
|
+
userAgent: "test-client"
|
|
363
|
+
});
|
|
364
|
+
const dbUser = await ctx.internalAdapter.findUserById(user.id);
|
|
365
|
+
if (!dbUser) throw new Error(`User not found: ${user.id}`);
|
|
366
|
+
const responseHeaders = new Headers();
|
|
367
|
+
await setSessionCookie({
|
|
368
|
+
context: ctx,
|
|
369
|
+
getSignedCookie: () => null,
|
|
370
|
+
setSignedCookie: async (name, value, _secret, options = {}) => {
|
|
371
|
+
const signedValue = `${value}.${await makeSignature(value, secret)}`;
|
|
372
|
+
responseHeaders.append("Set-Cookie", buildCookieString(name, signedValue, options));
|
|
373
|
+
},
|
|
374
|
+
setCookie: (name, value, options = {}) => {
|
|
375
|
+
responseHeaders.append("Set-Cookie", buildCookieString(name, value, options));
|
|
376
|
+
}
|
|
377
|
+
}, {
|
|
378
|
+
session,
|
|
379
|
+
user: dbUser
|
|
380
|
+
}, false);
|
|
381
|
+
return convertSetCookieToCookie(responseHeaders);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
//#endregion
|
|
385
|
+
//#region src/core/http/test-response.ts
|
|
386
|
+
/**
|
|
387
|
+
* TestResponse
|
|
388
|
+
*
|
|
389
|
+
* Wrapper around Response with assertion methods.
|
|
390
|
+
*
|
|
391
|
+
* @example
|
|
392
|
+
* ```typescript
|
|
393
|
+
* response
|
|
394
|
+
* .assertOk()
|
|
395
|
+
* .assertStatus(200)
|
|
396
|
+
* .assertJsonPath('data.id', userId)
|
|
397
|
+
* ```
|
|
398
|
+
*/
|
|
399
|
+
var TestResponse = class {
|
|
400
|
+
jsonData = null;
|
|
401
|
+
textData = null;
|
|
402
|
+
constructor(response) {
|
|
403
|
+
this.response = response;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Get the raw Response object.
|
|
407
|
+
*/
|
|
408
|
+
get raw() {
|
|
409
|
+
return this.response;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Get the response status code.
|
|
413
|
+
*/
|
|
414
|
+
get status() {
|
|
415
|
+
return this.response.status;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Get response headers.
|
|
419
|
+
*/
|
|
420
|
+
get headers() {
|
|
421
|
+
return this.response.headers;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Get the response body as JSON.
|
|
425
|
+
*/
|
|
426
|
+
async json() {
|
|
427
|
+
if (this.jsonData === null) this.jsonData = await this.response.clone().json();
|
|
428
|
+
return this.jsonData;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Get the response body as text.
|
|
432
|
+
*/
|
|
433
|
+
async text() {
|
|
434
|
+
this.textData ??= await this.response.clone().text();
|
|
435
|
+
return this.textData;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Assert response status is 200 OK.
|
|
439
|
+
*/
|
|
440
|
+
assertOk() {
|
|
441
|
+
return this.assertStatus(200);
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Assert response status is 201 Created.
|
|
445
|
+
*/
|
|
446
|
+
assertCreated() {
|
|
447
|
+
return this.assertStatus(201);
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Assert response status is 204 No Content.
|
|
451
|
+
*/
|
|
452
|
+
assertNoContent() {
|
|
453
|
+
return this.assertStatus(204);
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Assert response status is 400 Bad Request.
|
|
457
|
+
*/
|
|
458
|
+
assertBadRequest() {
|
|
459
|
+
return this.assertStatus(400);
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Assert response status is 401 Unauthorized.
|
|
463
|
+
*/
|
|
464
|
+
assertUnauthorized() {
|
|
465
|
+
return this.assertStatus(401);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Assert response status is 403 Forbidden.
|
|
469
|
+
*/
|
|
470
|
+
assertForbidden() {
|
|
471
|
+
return this.assertStatus(403);
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Assert response status is 404 Not Found.
|
|
475
|
+
*/
|
|
476
|
+
assertNotFound() {
|
|
477
|
+
return this.assertStatus(404);
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Assert response status is 422 Unprocessable Entity.
|
|
481
|
+
*/
|
|
482
|
+
assertUnprocessable() {
|
|
483
|
+
return this.assertStatus(422);
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Assert response status is 500 Internal Server Error.
|
|
487
|
+
*/
|
|
488
|
+
assertServerError() {
|
|
489
|
+
return this.assertStatus(500);
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Assert response has the given status code.
|
|
493
|
+
*/
|
|
494
|
+
assertStatus(expected) {
|
|
495
|
+
expect(this.response.status, `Expected status ${expected}, got ${this.response.status}`).toBe(expected);
|
|
496
|
+
return this;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Assert response status is in the 2xx success range.
|
|
500
|
+
*/
|
|
501
|
+
assertSuccessful() {
|
|
502
|
+
expect(this.response.status >= 200 && this.response.status < 300, `Expected successful status (2xx), got ${this.response.status}`).toBe(true);
|
|
503
|
+
return this;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Assert JSON response contains the given data.
|
|
507
|
+
*/
|
|
508
|
+
async assertJson(expected) {
|
|
509
|
+
const actual = await this.json();
|
|
510
|
+
for (const [key, value] of Object.entries(expected)) expect(actual[key], `Expected JSON key "${key}" to be ${JSON.stringify(value)}, got ${JSON.stringify(actual[key])}`).toBe(value);
|
|
511
|
+
return this;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Assert JSON response has value at the given path.
|
|
515
|
+
*
|
|
516
|
+
* @param path - Dot-notation path (e.g., 'data.user.id')
|
|
517
|
+
* @param expected - Expected value at path
|
|
518
|
+
*/
|
|
519
|
+
async assertJsonPath(path, expected) {
|
|
520
|
+
const json = await this.json();
|
|
521
|
+
const actual = this.getValueAtPath(json, path);
|
|
522
|
+
expect(actual, `Expected JSON path "${path}" to be ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`).toBe(expected);
|
|
523
|
+
return this;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Assert JSON response structure matches the given schema.
|
|
527
|
+
*/
|
|
528
|
+
async assertJsonStructure(structure) {
|
|
529
|
+
const json = await this.json();
|
|
530
|
+
for (const key of structure) expect(key in json, `Expected JSON to have key "${key}", got keys: ${JSON.stringify(Object.keys(json))}`).toBe(true);
|
|
531
|
+
return this;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Assert a JSON path exists (value can be anything, including null).
|
|
535
|
+
*
|
|
536
|
+
* @param path - Dot-notation path (e.g., 'data.user.id')
|
|
537
|
+
*/
|
|
538
|
+
async assertJsonPathExists(path) {
|
|
539
|
+
const json = await this.json();
|
|
540
|
+
expect(this.hasValueAtPath(json, path), `Expected JSON path "${path}" to exist`).toBe(true);
|
|
541
|
+
return this;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Assert a JSON path does not exist.
|
|
545
|
+
*
|
|
546
|
+
* @param path - Dot-notation path (e.g., 'data.user.deletedAt')
|
|
547
|
+
*/
|
|
548
|
+
async assertJsonPathMissing(path) {
|
|
549
|
+
const json = await this.json();
|
|
550
|
+
expect(this.hasValueAtPath(json, path), `Expected JSON path "${path}" to not exist`).toBe(false);
|
|
551
|
+
return this;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Assert JSON value at path matches a custom predicate.
|
|
555
|
+
*
|
|
556
|
+
* @param path - Dot-notation path (e.g., 'data.items')
|
|
557
|
+
* @param matcher - Predicate function to validate the value
|
|
558
|
+
*/
|
|
559
|
+
async assertJsonPathMatches(path, matcher) {
|
|
560
|
+
const json = await this.json();
|
|
561
|
+
const value = this.getValueAtPath(json, path);
|
|
562
|
+
expect(matcher(value), `Expected JSON path "${path}" to match predicate, got ${JSON.stringify(value)}`).toBe(true);
|
|
563
|
+
return this;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Assert string value at path contains a substring.
|
|
567
|
+
*
|
|
568
|
+
* @param path - Dot-notation path (e.g., 'data.message')
|
|
569
|
+
* @param substring - Substring to search for
|
|
570
|
+
*/
|
|
571
|
+
async assertJsonPathContains(path, substring) {
|
|
572
|
+
const json = await this.json();
|
|
573
|
+
const value = this.getValueAtPath(json, path);
|
|
574
|
+
expect(typeof value === "string", `Expected JSON path "${path}" to be a string, got ${typeof value}`).toBe(true);
|
|
575
|
+
expect(value.includes(substring), `Expected JSON path "${path}" to contain "${substring}", got "${String(value)}"`).toBe(true);
|
|
576
|
+
return this;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Assert array value at path includes a specific item.
|
|
580
|
+
*
|
|
581
|
+
* @param path - Dot-notation path (e.g., 'data.tags')
|
|
582
|
+
* @param item - Item to search for in the array
|
|
583
|
+
*/
|
|
584
|
+
async assertJsonPathIncludes(path, item) {
|
|
585
|
+
const json = await this.json();
|
|
586
|
+
const value = this.getValueAtPath(json, path);
|
|
587
|
+
expect(Array.isArray(value), `Expected JSON path "${path}" to be an array, got ${typeof value}`).toBe(true);
|
|
588
|
+
expect(value.includes(item), `Expected JSON path "${path}" to include ${JSON.stringify(item)}`).toBe(true);
|
|
589
|
+
return this;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Assert array length at path equals the expected count.
|
|
593
|
+
*
|
|
594
|
+
* @param path - Dot-notation path (e.g., 'data.items')
|
|
595
|
+
* @param count - Expected array length
|
|
596
|
+
*/
|
|
597
|
+
async assertJsonPathCount(path, count) {
|
|
598
|
+
const json = await this.json();
|
|
599
|
+
const value = this.getValueAtPath(json, path);
|
|
600
|
+
expect(Array.isArray(value), `Expected JSON path "${path}" to be an array, got ${typeof value}`).toBe(true);
|
|
601
|
+
expect(value.length, `Expected JSON path "${path}" to have ${count} items, got ${value.length}`).toBe(count);
|
|
602
|
+
return this;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Assert multiple JSON paths at once (batch assertion).
|
|
606
|
+
*
|
|
607
|
+
* @param expectations - Object mapping paths to expected values
|
|
608
|
+
*/
|
|
609
|
+
async assertJsonPaths(expectations) {
|
|
610
|
+
const json = await this.json();
|
|
611
|
+
for (const [path, expected] of Object.entries(expectations)) {
|
|
612
|
+
const actual = this.getValueAtPath(json, path);
|
|
613
|
+
expect(actual, `Expected JSON path "${path}" to be ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`).toBe(expected);
|
|
614
|
+
}
|
|
615
|
+
return this;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Assert response has the given header.
|
|
619
|
+
*/
|
|
620
|
+
assertHeader(name, expected) {
|
|
621
|
+
const actual = this.response.headers.get(name);
|
|
622
|
+
expect(actual !== null, `Expected header "${name}" to be present`).toBe(true);
|
|
623
|
+
if (expected !== void 0) expect(actual, `Expected header "${name}" to be "${expected}", got "${actual}"`).toBe(expected);
|
|
624
|
+
return this;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Assert response does not have the given header.
|
|
628
|
+
*/
|
|
629
|
+
assertHeaderMissing(name) {
|
|
630
|
+
const actual = this.response.headers.get(name);
|
|
631
|
+
expect(actual, `Expected header "${name}" to be absent, but got "${actual}"`).toBeNull();
|
|
632
|
+
return this;
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Get value at dot-notation path.
|
|
636
|
+
*/
|
|
637
|
+
getValueAtPath(obj, path) {
|
|
638
|
+
const parts = path.split(".");
|
|
639
|
+
let current = obj;
|
|
640
|
+
for (const part of parts) {
|
|
641
|
+
if (current === null || current === void 0) return;
|
|
642
|
+
current = current[part];
|
|
643
|
+
}
|
|
644
|
+
return current;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Check if a path exists in the object (even if value is null/undefined).
|
|
648
|
+
*/
|
|
649
|
+
hasValueAtPath(obj, path) {
|
|
650
|
+
const parts = path.split(".");
|
|
651
|
+
let current = obj;
|
|
652
|
+
for (const part of parts) {
|
|
653
|
+
if (current === null || current === void 0) return false;
|
|
654
|
+
if (typeof current !== "object") return false;
|
|
655
|
+
const record = current;
|
|
656
|
+
if (!(part in record)) return false;
|
|
657
|
+
current = record[part];
|
|
658
|
+
}
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
//#endregion
|
|
663
|
+
//#region src/core/http/test-http-request.ts
|
|
664
|
+
/**
|
|
665
|
+
* TestHttpRequest
|
|
666
|
+
*
|
|
667
|
+
* Request builder with fluent API for configuring test HTTP requests.
|
|
668
|
+
*
|
|
669
|
+
* @example
|
|
670
|
+
* ```typescript
|
|
671
|
+
* const response = await module.http
|
|
672
|
+
* .post('/api/v1/register')
|
|
673
|
+
* .withBody({ name: 'Test School' })
|
|
674
|
+
* .withHeaders({ 'X-Custom': 'value' })
|
|
675
|
+
* .send()
|
|
676
|
+
* ```
|
|
677
|
+
*
|
|
678
|
+
* @example Authenticated request
|
|
679
|
+
* ```typescript
|
|
680
|
+
* const response = await module.http
|
|
681
|
+
* .get('/api/v1/profile')
|
|
682
|
+
* .actingAs({ id: user.id })
|
|
683
|
+
* .send()
|
|
684
|
+
* ```
|
|
685
|
+
*/
|
|
686
|
+
var TestHttpRequest = class {
|
|
687
|
+
body = null;
|
|
688
|
+
requestHeaders;
|
|
689
|
+
actingAsUser = null;
|
|
690
|
+
constructor(method, path, headers, module, host = null) {
|
|
691
|
+
this.method = method;
|
|
692
|
+
this.path = path;
|
|
693
|
+
this.module = module;
|
|
694
|
+
this.host = host;
|
|
695
|
+
this.requestHeaders = new Headers(headers);
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Set the request body
|
|
699
|
+
*/
|
|
700
|
+
withBody(data) {
|
|
701
|
+
this.body = data;
|
|
702
|
+
return this;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Add headers to the request
|
|
706
|
+
*/
|
|
707
|
+
withHeaders(headers) {
|
|
708
|
+
for (const [key, value] of Object.entries(headers)) this.requestHeaders.set(key, value);
|
|
709
|
+
return this;
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Set Content-Type to application/json
|
|
713
|
+
*/
|
|
714
|
+
asJson() {
|
|
715
|
+
this.requestHeaders.set("Content-Type", "application/json");
|
|
716
|
+
return this;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Authenticate the request as a specific user
|
|
720
|
+
*/
|
|
721
|
+
actingAs(user) {
|
|
722
|
+
this.actingAsUser = user;
|
|
723
|
+
return this;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Send the request and return response
|
|
727
|
+
*
|
|
728
|
+
* Calls module.fetch() - NOT SELF.fetch()
|
|
729
|
+
*/
|
|
730
|
+
async send() {
|
|
731
|
+
await this.applyAuthentication();
|
|
732
|
+
if (this.body && !this.requestHeaders.has("Content-Type")) this.requestHeaders.set("Content-Type", "application/json");
|
|
733
|
+
const url = new URL(this.path, `http://${this.host ?? "localhost"}`);
|
|
734
|
+
const request = new Request(url.toString(), {
|
|
735
|
+
method: this.method,
|
|
736
|
+
headers: this.requestHeaders,
|
|
737
|
+
body: this.body ? JSON.stringify(this.body) : null
|
|
738
|
+
});
|
|
739
|
+
return new TestResponse(await this.module.fetch(request));
|
|
740
|
+
}
|
|
741
|
+
async applyAuthentication() {
|
|
742
|
+
if (!this.actingAsUser) return;
|
|
743
|
+
await this.module.runInRequestScope(async () => {
|
|
744
|
+
const actingAs = new ActingAs(this.module.get(AUTH_SERVICE));
|
|
745
|
+
const authHeaders = this.actingAsUser ? await actingAs.createSessionForUser(this.actingAsUser) : new Headers();
|
|
746
|
+
for (const [key, value] of authHeaders.entries()) this.requestHeaders.set(key, value);
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
//#endregion
|
|
751
|
+
//#region src/core/http/test-http-client.ts
|
|
752
|
+
/**
|
|
753
|
+
* TestHttpClient
|
|
754
|
+
*
|
|
755
|
+
* Fluent HTTP client for making test requests.
|
|
756
|
+
*
|
|
757
|
+
* @example
|
|
758
|
+
* ```typescript
|
|
759
|
+
* const response = await module.http
|
|
760
|
+
* .forHost('example.com')
|
|
761
|
+
* .post('/api/v1/users')
|
|
762
|
+
* .withBody({ name: 'Test' })
|
|
763
|
+
* .send()
|
|
764
|
+
*
|
|
765
|
+
* response.assertCreated()
|
|
766
|
+
* ```
|
|
767
|
+
*/
|
|
768
|
+
var TestHttpClient = class {
|
|
769
|
+
defaultHeaders = new Headers();
|
|
770
|
+
host = null;
|
|
771
|
+
constructor(module) {
|
|
772
|
+
this.module = module;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Set the host for the request
|
|
776
|
+
*/
|
|
777
|
+
forHost(host) {
|
|
778
|
+
this.host = host;
|
|
779
|
+
return this;
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Set default headers for all requests
|
|
783
|
+
*/
|
|
784
|
+
withHeaders(headers) {
|
|
785
|
+
for (const [key, value] of Object.entries(headers)) this.defaultHeaders.set(key, value);
|
|
786
|
+
return this;
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Create a GET request
|
|
790
|
+
*/
|
|
791
|
+
get(path) {
|
|
792
|
+
return this.createRequest("GET", path);
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Create a POST request
|
|
796
|
+
*/
|
|
797
|
+
post(path) {
|
|
798
|
+
return this.createRequest("POST", path);
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Create a PUT request
|
|
802
|
+
*/
|
|
803
|
+
put(path) {
|
|
804
|
+
return this.createRequest("PUT", path);
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Create a PATCH request
|
|
808
|
+
*/
|
|
809
|
+
patch(path) {
|
|
810
|
+
return this.createRequest("PATCH", path);
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Create a DELETE request
|
|
814
|
+
*/
|
|
815
|
+
delete(path) {
|
|
816
|
+
return this.createRequest("DELETE", path);
|
|
817
|
+
}
|
|
818
|
+
createRequest(method, path) {
|
|
819
|
+
return new TestHttpRequest(method, path, this.defaultHeaders, this.module, this.host);
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
//#endregion
|
|
823
|
+
//#region src/core/sse/test-sse-connection.ts
|
|
824
|
+
/**
|
|
825
|
+
* TestSseConnection
|
|
826
|
+
*
|
|
827
|
+
* Live SSE connection wrapper with assertion helpers for testing.
|
|
828
|
+
*
|
|
829
|
+
* @example
|
|
830
|
+
* ```typescript
|
|
831
|
+
* const sse = await module.sse('/streaming/sse').connect()
|
|
832
|
+
* await sse.assertEvent({ event: 'message', data: 'hello', id: '1' })
|
|
833
|
+
* await sse.waitForEnd()
|
|
834
|
+
* ```
|
|
835
|
+
*/
|
|
836
|
+
var TestSseConnection = class {
|
|
837
|
+
eventQueue = [];
|
|
838
|
+
eventWaiters = [];
|
|
839
|
+
streamEnded = false;
|
|
840
|
+
endWaiters = [];
|
|
841
|
+
constructor(response) {
|
|
842
|
+
this.response = response;
|
|
843
|
+
this.startReading();
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Wait for the next SSE event
|
|
847
|
+
*/
|
|
848
|
+
async waitForEvent(timeout = 5e3) {
|
|
849
|
+
if (this.eventQueue.length > 0) return this.eventQueue.shift();
|
|
850
|
+
if (this.streamEnded) throw new Error("SSE: stream has ended, no more events");
|
|
851
|
+
return new Promise((resolve, reject) => {
|
|
852
|
+
const waiter = (event) => {
|
|
853
|
+
clearTimeout(timer);
|
|
854
|
+
resolve(event);
|
|
855
|
+
};
|
|
856
|
+
const timer = setTimeout(() => {
|
|
857
|
+
const index = this.eventWaiters.indexOf(waiter);
|
|
858
|
+
if (index !== -1) this.eventWaiters.splice(index, 1);
|
|
859
|
+
reject(/* @__PURE__ */ new Error(`SSE: no event received within ${timeout}ms`));
|
|
860
|
+
}, timeout);
|
|
861
|
+
this.eventWaiters.push(waiter);
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Wait for the stream to end
|
|
866
|
+
*/
|
|
867
|
+
async waitForEnd(timeout = 5e3) {
|
|
868
|
+
if (this.streamEnded) return;
|
|
869
|
+
return new Promise((resolve, reject) => {
|
|
870
|
+
const waiter = () => {
|
|
871
|
+
clearTimeout(timer);
|
|
872
|
+
resolve();
|
|
873
|
+
};
|
|
874
|
+
const timer = setTimeout(() => {
|
|
875
|
+
const index = this.endWaiters.indexOf(waiter);
|
|
876
|
+
if (index !== -1) this.endWaiters.splice(index, 1);
|
|
877
|
+
reject(/* @__PURE__ */ new Error(`SSE: stream did not end within ${timeout}ms`));
|
|
878
|
+
}, timeout);
|
|
879
|
+
this.endWaiters.push(waiter);
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Collect all remaining events until the stream ends
|
|
884
|
+
*/
|
|
885
|
+
async collectEvents(timeout = 5e3) {
|
|
886
|
+
const events = [];
|
|
887
|
+
if (this.streamEnded) return [...this.eventQueue.splice(0)];
|
|
888
|
+
return new Promise((resolve, reject) => {
|
|
889
|
+
const originalDispatch = this.dispatchEvent.bind(this);
|
|
890
|
+
this.dispatchEvent = (event) => {
|
|
891
|
+
events.push(event);
|
|
892
|
+
originalDispatch(event);
|
|
893
|
+
};
|
|
894
|
+
const endWaiter = () => {
|
|
895
|
+
clearTimeout(timer);
|
|
896
|
+
this.dispatchEvent = originalDispatch;
|
|
897
|
+
resolve(events);
|
|
898
|
+
};
|
|
899
|
+
const timer = setTimeout(() => {
|
|
900
|
+
this.dispatchEvent = originalDispatch;
|
|
901
|
+
const index = this.endWaiters.indexOf(endWaiter);
|
|
902
|
+
if (index !== -1) this.endWaiters.splice(index, 1);
|
|
903
|
+
reject(/* @__PURE__ */ new Error(`SSE: stream did not end within ${timeout}ms`));
|
|
904
|
+
}, timeout);
|
|
905
|
+
events.push(...this.eventQueue.splice(0));
|
|
906
|
+
this.endWaiters.push(endWaiter);
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Assert that the next event matches the expected partial shape
|
|
911
|
+
*/
|
|
912
|
+
async assertEvent(expected, timeout = 5e3) {
|
|
913
|
+
expect(await this.waitForEvent(timeout)).toMatchObject(expected);
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Assert that the next event's data matches the expected string
|
|
917
|
+
*/
|
|
918
|
+
async assertEventData(expected, timeout = 5e3) {
|
|
919
|
+
const event = await this.waitForEvent(timeout);
|
|
920
|
+
expect(event.data, `Expected SSE data "${expected}", got "${event.data}"`).toBe(expected);
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Assert that the next event's data is JSON matching the expected value
|
|
924
|
+
*/
|
|
925
|
+
async assertJsonEventData(expected, timeout = 5e3) {
|
|
926
|
+
const event = await this.waitForEvent(timeout);
|
|
927
|
+
expect(JSON.parse(event.data)).toEqual(expected);
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Access the raw Response
|
|
931
|
+
*/
|
|
932
|
+
get raw() {
|
|
933
|
+
return this.response;
|
|
934
|
+
}
|
|
935
|
+
startReading() {
|
|
936
|
+
const body = this.response.body;
|
|
937
|
+
if (!body) {
|
|
938
|
+
this.streamEnded = true;
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
const reader = body.getReader();
|
|
942
|
+
const decoder = new TextDecoder();
|
|
943
|
+
let buffer = "";
|
|
944
|
+
const read = async () => {
|
|
945
|
+
try {
|
|
946
|
+
while (true) {
|
|
947
|
+
const { done, value } = await reader.read();
|
|
948
|
+
if (done) {
|
|
949
|
+
if (buffer.trim()) {
|
|
950
|
+
const event = this.parseEvent(buffer);
|
|
951
|
+
if (event) this.dispatchEvent(event);
|
|
952
|
+
}
|
|
953
|
+
this.streamEnded = true;
|
|
954
|
+
for (const waiter of this.endWaiters) waiter();
|
|
955
|
+
this.endWaiters = [];
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
buffer += decoder.decode(value, { stream: true });
|
|
959
|
+
const parts = buffer.split("\n\n");
|
|
960
|
+
buffer = parts.pop();
|
|
961
|
+
for (const part of parts) {
|
|
962
|
+
if (!part.trim()) continue;
|
|
963
|
+
const event = this.parseEvent(part);
|
|
964
|
+
if (event) this.dispatchEvent(event);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
} catch {
|
|
968
|
+
this.streamEnded = true;
|
|
969
|
+
for (const waiter of this.endWaiters) waiter();
|
|
970
|
+
this.endWaiters = [];
|
|
971
|
+
}
|
|
972
|
+
};
|
|
973
|
+
read();
|
|
974
|
+
}
|
|
975
|
+
parseEvent(raw) {
|
|
976
|
+
const lines = raw.split("\n");
|
|
977
|
+
const dataLines = [];
|
|
978
|
+
let event;
|
|
979
|
+
let id;
|
|
980
|
+
let retry;
|
|
981
|
+
for (const line of lines) {
|
|
982
|
+
if (line.startsWith(":")) continue;
|
|
983
|
+
const colonIndex = line.indexOf(":");
|
|
984
|
+
if (colonIndex === -1) continue;
|
|
985
|
+
const field = line.slice(0, colonIndex);
|
|
986
|
+
const value = line[colonIndex + 1] === " " ? line.slice(colonIndex + 2) : line.slice(colonIndex + 1);
|
|
987
|
+
switch (field) {
|
|
988
|
+
case "data":
|
|
989
|
+
dataLines.push(value);
|
|
990
|
+
break;
|
|
991
|
+
case "event":
|
|
992
|
+
event = value;
|
|
993
|
+
break;
|
|
994
|
+
case "id":
|
|
995
|
+
id = value;
|
|
996
|
+
break;
|
|
997
|
+
case "retry": {
|
|
998
|
+
const parsed = parseInt(value, 10);
|
|
999
|
+
if (!isNaN(parsed)) retry = parsed;
|
|
1000
|
+
break;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
if (dataLines.length === 0) return null;
|
|
1005
|
+
const result = { data: dataLines.join("\n") };
|
|
1006
|
+
if (event !== void 0) result.event = event;
|
|
1007
|
+
if (id !== void 0) result.id = id;
|
|
1008
|
+
if (retry !== void 0) result.retry = retry;
|
|
1009
|
+
return result;
|
|
1010
|
+
}
|
|
1011
|
+
dispatchEvent(event) {
|
|
1012
|
+
if (this.eventWaiters.length > 0) this.eventWaiters.shift()(event);
|
|
1013
|
+
else this.eventQueue.push(event);
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
1016
|
+
//#endregion
|
|
1017
|
+
//#region src/core/sse/test-sse-request.ts
|
|
1018
|
+
/**
|
|
1019
|
+
* TestSseRequest
|
|
1020
|
+
*
|
|
1021
|
+
* Builder for SSE connection requests. Follows the TestWsRequest pattern.
|
|
1022
|
+
*
|
|
1023
|
+
* @example
|
|
1024
|
+
* ```typescript
|
|
1025
|
+
* const sse = await module.sse('/streaming/sse').connect()
|
|
1026
|
+
* await sse.assertEvent({ event: 'message', data: 'hello' })
|
|
1027
|
+
* await sse.waitForEnd()
|
|
1028
|
+
* ```
|
|
1029
|
+
*
|
|
1030
|
+
* @example Authenticated SSE
|
|
1031
|
+
* ```typescript
|
|
1032
|
+
* const sse = await module.sse('/streaming/sse').actingAs({ id: user.id }).connect()
|
|
1033
|
+
* ```
|
|
1034
|
+
*/
|
|
1035
|
+
var TestSseRequest = class {
|
|
1036
|
+
requestHeaders = new Headers();
|
|
1037
|
+
actingAsUser = null;
|
|
1038
|
+
constructor(path, module) {
|
|
1039
|
+
this.path = path;
|
|
1040
|
+
this.module = module;
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Add custom headers to the request
|
|
1044
|
+
*/
|
|
1045
|
+
withHeaders(headers) {
|
|
1046
|
+
for (const [key, value] of Object.entries(headers)) this.requestHeaders.set(key, value);
|
|
1047
|
+
return this;
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Authenticate the SSE connection as a specific user
|
|
1051
|
+
*/
|
|
1052
|
+
actingAs(user) {
|
|
1053
|
+
this.actingAsUser = user;
|
|
1054
|
+
return this;
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Send the request and return a live SSE connection
|
|
1058
|
+
*/
|
|
1059
|
+
async connect() {
|
|
1060
|
+
await this.applyAuthentication();
|
|
1061
|
+
this.requestHeaders.set("Accept", "text/event-stream");
|
|
1062
|
+
const url = new URL(this.path, "http://localhost");
|
|
1063
|
+
const request = new Request(url.toString(), { headers: this.requestHeaders });
|
|
1064
|
+
const response = await this.module.fetch(request);
|
|
1065
|
+
expect(response.status, `Expected status 200, got ${response.status}`).toBe(200);
|
|
1066
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
1067
|
+
expect(contentType.includes("text/event-stream"), `Expected content-type "text/event-stream", got "${contentType}"`).toBe(true);
|
|
1068
|
+
return new TestSseConnection(response);
|
|
1069
|
+
}
|
|
1070
|
+
async applyAuthentication() {
|
|
1071
|
+
if (!this.actingAsUser) return;
|
|
1072
|
+
await this.module.runInRequestScope(async () => {
|
|
1073
|
+
const actingAs = new ActingAs(this.module.get(AUTH_SERVICE));
|
|
1074
|
+
const authHeaders = this.actingAsUser ? await actingAs.createSessionForUser(this.actingAsUser) : new Headers();
|
|
1075
|
+
for (const [key, value] of authHeaders.entries()) this.requestHeaders.set(key, value);
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1079
|
+
//#endregion
|
|
1080
|
+
//#region src/core/ws/test-ws-connection.ts
|
|
1081
|
+
/**
|
|
1082
|
+
* TestWsConnection
|
|
1083
|
+
*
|
|
1084
|
+
* Live WebSocket connection wrapper with assertion helpers for testing.
|
|
1085
|
+
*
|
|
1086
|
+
* @example
|
|
1087
|
+
* ```typescript
|
|
1088
|
+
* const ws = await module.ws('/ws/chat').connect()
|
|
1089
|
+
* ws.send('hello')
|
|
1090
|
+
* await ws.assertMessage('echo:hello')
|
|
1091
|
+
* ws.close()
|
|
1092
|
+
* await ws.waitForClose()
|
|
1093
|
+
* ```
|
|
1094
|
+
*/
|
|
1095
|
+
var TestWsConnection = class {
|
|
1096
|
+
messageQueue = [];
|
|
1097
|
+
messageWaiters = [];
|
|
1098
|
+
closeEvent = null;
|
|
1099
|
+
closeWaiters = [];
|
|
1100
|
+
constructor(ws) {
|
|
1101
|
+
this.ws = ws;
|
|
1102
|
+
this.ws.addEventListener("message", (event) => {
|
|
1103
|
+
const data = event.data;
|
|
1104
|
+
if (this.messageWaiters.length > 0) this.messageWaiters.shift()(data);
|
|
1105
|
+
else this.messageQueue.push(data);
|
|
1106
|
+
});
|
|
1107
|
+
this.ws.addEventListener("close", (event) => {
|
|
1108
|
+
this.closeEvent = {
|
|
1109
|
+
code: event.code,
|
|
1110
|
+
reason: event.reason
|
|
1111
|
+
};
|
|
1112
|
+
for (const waiter of this.closeWaiters) waiter(this.closeEvent);
|
|
1113
|
+
this.closeWaiters = [];
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Send a message through the WebSocket
|
|
1118
|
+
*/
|
|
1119
|
+
send(data) {
|
|
1120
|
+
this.ws.send(data);
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Close the WebSocket connection
|
|
1124
|
+
*/
|
|
1125
|
+
close(code, reason) {
|
|
1126
|
+
this.ws.close(code, reason);
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Wait for the next message, returning its data
|
|
1130
|
+
*/
|
|
1131
|
+
async waitForMessage(timeout = 5e3) {
|
|
1132
|
+
if (this.messageQueue.length > 0) return this.messageQueue.shift();
|
|
1133
|
+
return new Promise((resolve, reject) => {
|
|
1134
|
+
const waiter = (data) => {
|
|
1135
|
+
clearTimeout(timer);
|
|
1136
|
+
resolve(data);
|
|
1137
|
+
};
|
|
1138
|
+
const timer = setTimeout(() => {
|
|
1139
|
+
const index = this.messageWaiters.indexOf(waiter);
|
|
1140
|
+
if (index !== -1) this.messageWaiters.splice(index, 1);
|
|
1141
|
+
reject(/* @__PURE__ */ new Error(`WebSocket: no message received within ${timeout}ms`));
|
|
1142
|
+
}, timeout);
|
|
1143
|
+
this.messageWaiters.push(waiter);
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Wait for the connection to close
|
|
1148
|
+
*/
|
|
1149
|
+
async waitForClose(timeout = 5e3) {
|
|
1150
|
+
if (this.closeEvent) return this.closeEvent;
|
|
1151
|
+
return new Promise((resolve, reject) => {
|
|
1152
|
+
const waiter = (event) => {
|
|
1153
|
+
clearTimeout(timer);
|
|
1154
|
+
resolve(event);
|
|
1155
|
+
};
|
|
1156
|
+
const timer = setTimeout(() => {
|
|
1157
|
+
const index = this.closeWaiters.indexOf(waiter);
|
|
1158
|
+
if (index !== -1) this.closeWaiters.splice(index, 1);
|
|
1159
|
+
reject(/* @__PURE__ */ new Error(`WebSocket: connection did not close within ${timeout}ms`));
|
|
1160
|
+
}, timeout);
|
|
1161
|
+
this.closeWaiters.push(waiter);
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Assert that the next message equals the expected value
|
|
1166
|
+
*/
|
|
1167
|
+
async assertMessage(expected, timeout = 5e3) {
|
|
1168
|
+
const data = await this.waitForMessage(timeout);
|
|
1169
|
+
const message = typeof data === "string" ? data : "[ArrayBuffer]";
|
|
1170
|
+
expect(message, `Expected WebSocket message "${expected}", got "${message}"`).toBe(expected);
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Assert that the connection closes, optionally with an expected code
|
|
1174
|
+
*/
|
|
1175
|
+
async assertClosed(expectedCode, timeout = 5e3) {
|
|
1176
|
+
const event = await this.waitForClose(timeout);
|
|
1177
|
+
if (expectedCode !== void 0) expect(event.code, `Expected close code ${expectedCode}, got ${event.code}`).toBe(expectedCode);
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* Access the raw Cloudflare WebSocket
|
|
1181
|
+
*/
|
|
1182
|
+
get raw() {
|
|
1183
|
+
return this.ws;
|
|
1184
|
+
}
|
|
1185
|
+
};
|
|
1186
|
+
//#endregion
|
|
1187
|
+
//#region src/core/ws/test-ws-request.ts
|
|
1188
|
+
/**
|
|
1189
|
+
* TestWsRequest
|
|
1190
|
+
*
|
|
1191
|
+
* Builder for WebSocket upgrade requests. Follows the TestHttpRequest pattern.
|
|
1192
|
+
*
|
|
1193
|
+
* @example
|
|
1194
|
+
* ```typescript
|
|
1195
|
+
* const ws = await module.ws('/ws/chat').connect()
|
|
1196
|
+
* ws.send('hello')
|
|
1197
|
+
* await ws.assertMessage('echo:hello')
|
|
1198
|
+
* ws.close()
|
|
1199
|
+
* ```
|
|
1200
|
+
*
|
|
1201
|
+
* @example Authenticated WebSocket
|
|
1202
|
+
* ```typescript
|
|
1203
|
+
* const ws = await module.ws('/ws/chat').actingAs({ id: user.id }).connect()
|
|
1204
|
+
* ```
|
|
1205
|
+
*/
|
|
1206
|
+
var TestWsRequest = class {
|
|
1207
|
+
requestHeaders = new Headers();
|
|
1208
|
+
actingAsUser = null;
|
|
1209
|
+
constructor(path, module) {
|
|
1210
|
+
this.path = path;
|
|
1211
|
+
this.module = module;
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Add custom headers to the upgrade request
|
|
1215
|
+
*/
|
|
1216
|
+
withHeaders(headers) {
|
|
1217
|
+
for (const [key, value] of Object.entries(headers)) this.requestHeaders.set(key, value);
|
|
1218
|
+
return this;
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Authenticate the WebSocket connection as a specific user
|
|
1222
|
+
*/
|
|
1223
|
+
actingAs(user) {
|
|
1224
|
+
this.actingAsUser = user;
|
|
1225
|
+
return this;
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Send the upgrade request and return a live WebSocket connection
|
|
1229
|
+
*/
|
|
1230
|
+
async connect() {
|
|
1231
|
+
await this.applyAuthentication();
|
|
1232
|
+
this.requestHeaders.set("Upgrade", "websocket");
|
|
1233
|
+
this.requestHeaders.set("Connection", "Upgrade");
|
|
1234
|
+
this.requestHeaders.set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==");
|
|
1235
|
+
this.requestHeaders.set("Sec-WebSocket-Version", "13");
|
|
1236
|
+
const url = new URL(this.path, "http://localhost");
|
|
1237
|
+
const request = new Request(url.toString(), { headers: this.requestHeaders });
|
|
1238
|
+
const response = await this.module.fetch(request);
|
|
1239
|
+
expect(response.status, `Expected status 101 (Switching Protocols), got ${response.status}`).toBe(101);
|
|
1240
|
+
const ws = response.webSocket;
|
|
1241
|
+
if (!ws) throw new Error("Response did not include a WebSocket connection");
|
|
1242
|
+
ws.accept();
|
|
1243
|
+
return new TestWsConnection(ws);
|
|
1244
|
+
}
|
|
1245
|
+
async applyAuthentication() {
|
|
1246
|
+
if (!this.actingAsUser) return;
|
|
1247
|
+
await this.module.runInRequestScope(async () => {
|
|
1248
|
+
const actingAs = new ActingAs(this.module.get(AUTH_SERVICE));
|
|
1249
|
+
const authHeaders = this.actingAsUser ? await actingAs.createSessionForUser(this.actingAsUser) : new Headers();
|
|
1250
|
+
for (const [key, value] of authHeaders.entries()) this.requestHeaders.set(key, value);
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
};
|
|
1254
|
+
//#endregion
|
|
1255
|
+
//#region src/core/testing-module.ts
|
|
1256
|
+
/**
|
|
1257
|
+
* TestingModule
|
|
1258
|
+
*
|
|
1259
|
+
* Provides access to the test application, container, HTTP client, and utilities.
|
|
1260
|
+
*
|
|
1261
|
+
* @example
|
|
1262
|
+
* ```typescript
|
|
1263
|
+
* const module = await Test.createTestingModule({
|
|
1264
|
+
* modules: [RegistrationModule],
|
|
1265
|
+
* }).compile()
|
|
1266
|
+
*
|
|
1267
|
+
* // Make HTTP requests
|
|
1268
|
+
* const response = await module.http
|
|
1269
|
+
* .post('/api/v1/register')
|
|
1270
|
+
* .withBody({ ... })
|
|
1271
|
+
* .send()
|
|
1272
|
+
*
|
|
1273
|
+
* // Access services
|
|
1274
|
+
* const service = module.get(REGISTRATION_TOKENS.RegistrationService)
|
|
1275
|
+
*
|
|
1276
|
+
* // Database utilities
|
|
1277
|
+
* await module.truncateDb()
|
|
1278
|
+
* await module.seed(new UserSeeder())
|
|
1279
|
+
* await module.assertDatabaseHas('user', { email: 'test@example.com' })
|
|
1280
|
+
*
|
|
1281
|
+
* // Cleanup
|
|
1282
|
+
* await module.close()
|
|
1283
|
+
* ```
|
|
1284
|
+
*/
|
|
1285
|
+
var TestingModule = class {
|
|
1286
|
+
_http = null;
|
|
1287
|
+
_requestContainer;
|
|
1288
|
+
constructor(app, env, ctx) {
|
|
1289
|
+
this.app = app;
|
|
1290
|
+
this.env = env;
|
|
1291
|
+
this.ctx = ctx;
|
|
1292
|
+
const mockContext = this.app.createMockRouterContext();
|
|
1293
|
+
this._requestContainer = this.app.container.createRequestScope(mockContext);
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* Resolve a service from the container
|
|
1297
|
+
*/
|
|
1298
|
+
get(token) {
|
|
1299
|
+
return this._requestContainer.resolve(token);
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* Get HTTP test client for making requests
|
|
1303
|
+
*/
|
|
1304
|
+
get http() {
|
|
1305
|
+
this._http ??= new TestHttpClient(this);
|
|
1306
|
+
return this._http;
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Get fake storage service for assertions
|
|
1310
|
+
*/
|
|
1311
|
+
get storage() {
|
|
1312
|
+
return this.get(STORAGE_TOKENS.StorageService);
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Create a WebSocket test request builder for the given path
|
|
1316
|
+
*/
|
|
1317
|
+
ws(path) {
|
|
1318
|
+
return new TestWsRequest(path, this);
|
|
1319
|
+
}
|
|
1320
|
+
/**
|
|
1321
|
+
* Create an SSE test request builder for the given path
|
|
1322
|
+
*/
|
|
1323
|
+
sse(path) {
|
|
1324
|
+
return new TestSseRequest(path, this);
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* Get Application instance
|
|
1328
|
+
*/
|
|
1329
|
+
get application() {
|
|
1330
|
+
return this.app;
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Get DI Container (request-scoped)
|
|
1334
|
+
*/
|
|
1335
|
+
get container() {
|
|
1336
|
+
return this._requestContainer;
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Execute an HTTP request through HonoApp
|
|
1340
|
+
*/
|
|
1341
|
+
async fetch(request) {
|
|
1342
|
+
return this.app.hono.fetch(request, this.env, this.ctx);
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Run callback in request scope (for DB operations, service access)
|
|
1346
|
+
*/
|
|
1347
|
+
async runInRequestScope(callback) {
|
|
1348
|
+
const mockContext = this.app.createMockRouterContext();
|
|
1349
|
+
return this.app.container.runInRequestScope(mockContext, callback);
|
|
1350
|
+
}
|
|
1351
|
+
getDb(name) {
|
|
1352
|
+
const token = name ? connectionSymbol(name) : DI_TOKENS.Database;
|
|
1353
|
+
return this._requestContainer.resolve(token);
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Truncate all non-prisma tables in the database
|
|
1357
|
+
*/
|
|
1358
|
+
async truncateDb(name) {
|
|
1359
|
+
const db = this.getDb(name);
|
|
1360
|
+
const tables = await db.$queryRaw`
|
|
1361
|
+
SELECT tablename::text as tablename FROM pg_tables
|
|
1362
|
+
WHERE schemaname = current_schema()
|
|
1363
|
+
AND tablename NOT LIKE '_prisma%'
|
|
1364
|
+
`;
|
|
1365
|
+
if (tables.length === 0) return;
|
|
1366
|
+
const tableList = tables.map((t) => `"${t.tablename}"`).join(", ");
|
|
1367
|
+
await db.$executeRawUnsafe(`TRUNCATE ${tableList} RESTART IDENTITY CASCADE`);
|
|
1368
|
+
}
|
|
1369
|
+
async seed(...args) {
|
|
1370
|
+
let name;
|
|
1371
|
+
let seeders;
|
|
1372
|
+
if (typeof args[0] === "string") {
|
|
1373
|
+
name = args[0];
|
|
1374
|
+
seeders = args.slice(1);
|
|
1375
|
+
} else seeders = args;
|
|
1376
|
+
await this.getDb(name).$transaction(async (tx) => {
|
|
1377
|
+
for (const seeder of seeders) await seeder.run(tx);
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Assert that a record exists in the database
|
|
1382
|
+
*/
|
|
1383
|
+
async assertDatabaseHas(table, data, name) {
|
|
1384
|
+
expect(await this.getDb(name)[table].findFirst({ where: data }), `Expected ${table} with ${JSON.stringify(data)}`).not.toBeNull();
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Assert that a record does not exist in the database
|
|
1388
|
+
*/
|
|
1389
|
+
async assertDatabaseMissing(table, data, name) {
|
|
1390
|
+
expect(await this.getDb(name)[table].findFirst({ where: data }), `Expected ${table} NOT to have ${JSON.stringify(data)}`).toBeNull();
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Assert the number of records in a table
|
|
1394
|
+
*/
|
|
1395
|
+
async assertDatabaseCount(table, expected, name) {
|
|
1396
|
+
const actual = await this.getDb(name)[table].count();
|
|
1397
|
+
expect(actual, `Expected ${table} count ${expected}, got ${actual}`).toBe(expected);
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Cleanup - call in afterAll
|
|
1401
|
+
*/
|
|
1402
|
+
async close() {
|
|
1403
|
+
await this._requestContainer.dispose();
|
|
1404
|
+
await this.app.shutdown();
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
//#endregion
|
|
1408
|
+
//#region src/core/testing-module-builder.ts
|
|
1409
|
+
/**
|
|
1410
|
+
* Builder for creating test modules with provider overrides
|
|
1411
|
+
*/
|
|
1412
|
+
var TestingModuleBuilder = class {
|
|
1413
|
+
overrides = [];
|
|
1414
|
+
constructor(config) {
|
|
1415
|
+
this.config = config;
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* Override a provider with a custom implementation
|
|
1419
|
+
*/
|
|
1420
|
+
overrideProvider(token) {
|
|
1421
|
+
return new ProviderOverrideBuilder(this, token);
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Add a provider override (internal use by ProviderOverrideBuilder)
|
|
1425
|
+
*
|
|
1426
|
+
* @internal
|
|
1427
|
+
*/
|
|
1428
|
+
addProviderOverride(override) {
|
|
1429
|
+
this.overrides.push(override);
|
|
1430
|
+
return this;
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Merge additional environment bindings
|
|
1434
|
+
*/
|
|
1435
|
+
withEnv(env) {
|
|
1436
|
+
this.config.env = {
|
|
1437
|
+
...this.config.env,
|
|
1438
|
+
...env
|
|
1439
|
+
};
|
|
1440
|
+
return this;
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* Compile the testing module
|
|
1444
|
+
*
|
|
1445
|
+
* Creates the Application, applies overrides, initializes, and returns TestingModule.
|
|
1446
|
+
*/
|
|
1447
|
+
async compile() {
|
|
1448
|
+
const env = getTestEnv(this.config.env);
|
|
1449
|
+
const ctx = { waitUntil };
|
|
1450
|
+
const allImports = [...Test.getBaseModules(), ...this.config.imports ?? []];
|
|
1451
|
+
const app = new Application({
|
|
1452
|
+
module: this.createTestRootModule({
|
|
1453
|
+
imports: allImports,
|
|
1454
|
+
providers: this.config.providers,
|
|
1455
|
+
controllers: this.config.controllers,
|
|
1456
|
+
consumers: this.config.consumers,
|
|
1457
|
+
jobs: this.config.jobs
|
|
1458
|
+
}),
|
|
1459
|
+
logging: {
|
|
1460
|
+
level: this.config.logging?.level ?? LogLevel.ERROR,
|
|
1461
|
+
formatter: this.config.logging?.formatter ?? "pretty"
|
|
1462
|
+
},
|
|
1463
|
+
env,
|
|
1464
|
+
ctx
|
|
1465
|
+
});
|
|
1466
|
+
app.container.registerSingleton(STORAGE_TOKENS.StorageService, FakeStorageService);
|
|
1467
|
+
for (const override of this.overrides) switch (override.type) {
|
|
1468
|
+
case "value":
|
|
1469
|
+
app.container.registerValue(override.token, override.implementation);
|
|
1470
|
+
break;
|
|
1471
|
+
case "class":
|
|
1472
|
+
app.container.registerSingleton(override.token, override.implementation);
|
|
1473
|
+
break;
|
|
1474
|
+
case "factory":
|
|
1475
|
+
app.container.registerFactory(override.token, override.implementation);
|
|
1476
|
+
break;
|
|
1477
|
+
case "existing":
|
|
1478
|
+
app.container.registerExisting(override.token, override.implementation);
|
|
1479
|
+
break;
|
|
1480
|
+
}
|
|
1481
|
+
await app.initialize();
|
|
1482
|
+
return new TestingModule(app, env, ctx);
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* Create a test root module with the given options
|
|
1486
|
+
*/
|
|
1487
|
+
createTestRootModule(options) {
|
|
1488
|
+
let TestRootModule = class TestRootModule {};
|
|
1489
|
+
TestRootModule = __decorate([Module(options)], TestRootModule);
|
|
1490
|
+
return TestRootModule;
|
|
1491
|
+
}
|
|
1492
|
+
};
|
|
1493
|
+
//#endregion
|
|
1494
|
+
//#region src/core/test.ts
|
|
1495
|
+
/**
|
|
1496
|
+
* Test
|
|
1497
|
+
*
|
|
1498
|
+
* Static class for creating testing modules.
|
|
1499
|
+
* Provides a NestJS-style API for configuring test modules.
|
|
1500
|
+
*
|
|
1501
|
+
* @example
|
|
1502
|
+
* ```typescript
|
|
1503
|
+
* // In vitest.setup.ts:
|
|
1504
|
+
* Test.setBaseModules([CoreModule])
|
|
1505
|
+
*
|
|
1506
|
+
* // In test files:
|
|
1507
|
+
* const module = await Test.createTestingModule({
|
|
1508
|
+
* imports: [RegistrationModule, GeoModule],
|
|
1509
|
+
* })
|
|
1510
|
+
* .overrideProvider(EMAIL_TOKENS.EmailService)
|
|
1511
|
+
* .useValue(mockEmailService)
|
|
1512
|
+
* .compile()
|
|
1513
|
+
* ```
|
|
1514
|
+
*/
|
|
1515
|
+
var Test = class {
|
|
1516
|
+
/**
|
|
1517
|
+
* Base modules to include in all test modules
|
|
1518
|
+
* Set once in vitest.setup.ts
|
|
1519
|
+
*/
|
|
1520
|
+
static baseModules = [];
|
|
1521
|
+
/**
|
|
1522
|
+
* Set base modules to include in all test modules
|
|
1523
|
+
* Should be called once in vitest.setup.ts
|
|
1524
|
+
*
|
|
1525
|
+
* @param modules - Modules to include before test-specific modules (e.g., CoreModule)
|
|
1526
|
+
*/
|
|
1527
|
+
static setBaseModules(modules) {
|
|
1528
|
+
this.baseModules = modules;
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* Get base modules
|
|
1532
|
+
*/
|
|
1533
|
+
static getBaseModules() {
|
|
1534
|
+
return this.baseModules;
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Create a testing module builder
|
|
1538
|
+
*
|
|
1539
|
+
* @param config - Configuration with modules and optional env overrides
|
|
1540
|
+
* @returns TestingModuleBuilder for configuring and compiling the module
|
|
1541
|
+
*/
|
|
1542
|
+
static createTestingModule(config) {
|
|
1543
|
+
return new TestingModuleBuilder(config);
|
|
1544
|
+
}
|
|
1545
|
+
};
|
|
1546
|
+
//#endregion
|
|
1547
|
+
//#region src/core/http/mock-fetch.ts
|
|
1548
|
+
/**
|
|
1549
|
+
* MSW-based fetch mock for declarative HTTP mocking in tests.
|
|
1550
|
+
*
|
|
1551
|
+
* Replaces the old Cloudflare `fetchMock` (undici MockAgent) with MSW's `setupServer`.
|
|
1552
|
+
* Works in both Node.js and workerd test environments.
|
|
1553
|
+
*
|
|
1554
|
+
* @example
|
|
1555
|
+
* ```typescript
|
|
1556
|
+
* import { createMockFetch } from '@stratal/testing'
|
|
1557
|
+
*
|
|
1558
|
+
* const mock = createMockFetch()
|
|
1559
|
+
*
|
|
1560
|
+
* beforeAll(() => mock.listen())
|
|
1561
|
+
* afterEach(() => mock.reset())
|
|
1562
|
+
* afterAll(() => mock.close())
|
|
1563
|
+
*
|
|
1564
|
+
* it('should mock external API', async () => {
|
|
1565
|
+
* mock.mockJsonResponse('https://api.example.com/data', { success: true })
|
|
1566
|
+
*
|
|
1567
|
+
* const response = await fetch('https://api.example.com/data')
|
|
1568
|
+
* const json = await response.json()
|
|
1569
|
+
*
|
|
1570
|
+
* expect(json.success).toBe(true)
|
|
1571
|
+
* })
|
|
1572
|
+
* ```
|
|
1573
|
+
*/
|
|
1574
|
+
var MockFetch = class {
|
|
1575
|
+
server;
|
|
1576
|
+
constructor(handlers = []) {
|
|
1577
|
+
this.server = setupServer(...handlers);
|
|
1578
|
+
}
|
|
1579
|
+
/** Start intercepting. Call in beforeAll/beforeEach. */
|
|
1580
|
+
listen() {
|
|
1581
|
+
this.server.listen({ onUnhandledRequest: "error" });
|
|
1582
|
+
}
|
|
1583
|
+
/** Reset runtime handlers. Call in afterEach. */
|
|
1584
|
+
reset() {
|
|
1585
|
+
this.server.resetHandlers();
|
|
1586
|
+
}
|
|
1587
|
+
/** Stop intercepting. Call in afterAll. */
|
|
1588
|
+
close() {
|
|
1589
|
+
this.server.close();
|
|
1590
|
+
}
|
|
1591
|
+
/** Add runtime handler(s) for a single test. */
|
|
1592
|
+
use(...handlers) {
|
|
1593
|
+
this.server.use(...handlers);
|
|
1594
|
+
}
|
|
1595
|
+
/**
|
|
1596
|
+
* Mock a JSON response.
|
|
1597
|
+
*
|
|
1598
|
+
* @param url - Full URL to mock (e.g., 'https://api.example.com/users')
|
|
1599
|
+
* @param data - JSON data to return
|
|
1600
|
+
* @param options - HTTP method, status code, headers
|
|
1601
|
+
*
|
|
1602
|
+
* @example
|
|
1603
|
+
* ```typescript
|
|
1604
|
+
* mock.mockJsonResponse('https://api.example.com/users', { users: [] })
|
|
1605
|
+
* mock.mockJsonResponse('https://api.example.com/users', { created: true }, { method: 'POST', status: 201 })
|
|
1606
|
+
* ```
|
|
1607
|
+
*/
|
|
1608
|
+
mockJsonResponse(url, data, options = {}) {
|
|
1609
|
+
const handler = http$1[(options.method ?? "GET").toLowerCase()](url, () => HttpResponse$1.json(data, {
|
|
1610
|
+
status: options.status ?? 200,
|
|
1611
|
+
headers: options.headers
|
|
1612
|
+
}));
|
|
1613
|
+
this.server.use(handler);
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Mock an error response.
|
|
1617
|
+
*
|
|
1618
|
+
* @param url - Full URL to mock
|
|
1619
|
+
* @param status - HTTP error status code
|
|
1620
|
+
* @param message - Optional error message
|
|
1621
|
+
* @param options - HTTP method, headers
|
|
1622
|
+
*
|
|
1623
|
+
* @example
|
|
1624
|
+
* ```typescript
|
|
1625
|
+
* mock.mockError('https://api.example.com/fail', 401, 'Unauthorized')
|
|
1626
|
+
* mock.mockError('https://api.example.com/fail', 500, 'Server Error', { method: 'POST' })
|
|
1627
|
+
* ```
|
|
1628
|
+
*/
|
|
1629
|
+
mockError(url, status, message, options = {}) {
|
|
1630
|
+
const method = (options.method ?? "GET").toLowerCase();
|
|
1631
|
+
const body = message ? { error: message } : void 0;
|
|
1632
|
+
this.server.use(http$1[method](url, () => HttpResponse$1.json(body, {
|
|
1633
|
+
status,
|
|
1634
|
+
headers: options.headers
|
|
1635
|
+
})));
|
|
1636
|
+
}
|
|
1637
|
+
};
|
|
1638
|
+
/**
|
|
1639
|
+
* Factory function to create a new MockFetch instance
|
|
1640
|
+
*
|
|
1641
|
+
* @param handlers - Optional initial MSW request handlers
|
|
1642
|
+
* @returns A new MockFetch instance
|
|
1643
|
+
*
|
|
1644
|
+
* @example
|
|
1645
|
+
* ```typescript
|
|
1646
|
+
* import { createMockFetch } from '@stratal/testing'
|
|
1647
|
+
*
|
|
1648
|
+
* const mock = createMockFetch()
|
|
1649
|
+
*
|
|
1650
|
+
* beforeAll(() => mock.listen())
|
|
1651
|
+
* afterEach(() => mock.reset())
|
|
1652
|
+
* afterAll(() => mock.close())
|
|
1653
|
+
* ```
|
|
1654
|
+
*/
|
|
1655
|
+
function createMockFetch(handlers) {
|
|
1656
|
+
return new MockFetch(handlers);
|
|
1657
|
+
}
|
|
1658
|
+
//#endregion
|
|
1659
|
+
//#region src/types.ts
|
|
1660
|
+
var Seeder = class {};
|
|
1661
|
+
//#endregion
|
|
1662
|
+
//#region src/errors/test-error.ts
|
|
1663
|
+
/**
|
|
1664
|
+
* Base error class for all test framework errors.
|
|
1665
|
+
* Extends from Error and allows easy identification via `instanceof`.
|
|
1666
|
+
*/
|
|
1667
|
+
var TestError = class extends Error {
|
|
1668
|
+
constructor(message, cause) {
|
|
1669
|
+
super(message);
|
|
1670
|
+
this.cause = cause;
|
|
1671
|
+
this.name = "TestError";
|
|
1672
|
+
Error.captureStackTrace(this, this.constructor);
|
|
1673
|
+
}
|
|
1674
|
+
};
|
|
1675
|
+
//#endregion
|
|
1676
|
+
//#region src/errors/setup-error.ts
|
|
1677
|
+
/**
|
|
1678
|
+
* Error thrown when test setup fails.
|
|
1679
|
+
* Examples: schema creation failure, migration failure, application bootstrap failure.
|
|
1680
|
+
*/
|
|
1681
|
+
var TestSetupError = class extends TestError {
|
|
1682
|
+
constructor(message, cause) {
|
|
1683
|
+
super(`Test setup failed: ${message}`, cause);
|
|
1684
|
+
}
|
|
1685
|
+
};
|
|
1686
|
+
//#endregion
|
|
1687
|
+
export { ActingAs, FakeStorageService, HttpResponse, MockFetch, ProviderOverrideBuilder, Seeder, Test, TestError, TestHttpClient, TestHttpRequest, TestResponse, TestSetupError, TestSseConnection, TestSseRequest, TestWsConnection, TestWsRequest, TestingModule, TestingModuleBuilder, createMockFetch, getTestEnv, http };
|
|
1688
|
+
|
|
1689
|
+
//# sourceMappingURL=index.mjs.map
|