@buenojs/bueno 0.8.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/.env.example +109 -0
- package/.github/workflows/ci.yml +31 -0
- package/LICENSE +21 -0
- package/README.md +892 -0
- package/architecture.md +652 -0
- package/bun.lock +70 -0
- package/dist/cli/index.js +3233 -0
- package/dist/index.js +9014 -0
- package/package.json +77 -0
- package/src/cache/index.ts +795 -0
- package/src/cli/ARCHITECTURE.md +837 -0
- package/src/cli/bin.ts +10 -0
- package/src/cli/commands/build.ts +425 -0
- package/src/cli/commands/dev.ts +248 -0
- package/src/cli/commands/generate.ts +541 -0
- package/src/cli/commands/help.ts +55 -0
- package/src/cli/commands/index.ts +112 -0
- package/src/cli/commands/migration.ts +355 -0
- package/src/cli/commands/new.ts +804 -0
- package/src/cli/commands/start.ts +208 -0
- package/src/cli/core/args.ts +283 -0
- package/src/cli/core/console.ts +349 -0
- package/src/cli/core/index.ts +60 -0
- package/src/cli/core/prompt.ts +424 -0
- package/src/cli/core/spinner.ts +265 -0
- package/src/cli/index.ts +135 -0
- package/src/cli/templates/deploy.ts +295 -0
- package/src/cli/templates/docker.ts +307 -0
- package/src/cli/templates/index.ts +24 -0
- package/src/cli/utils/fs.ts +428 -0
- package/src/cli/utils/index.ts +8 -0
- package/src/cli/utils/strings.ts +197 -0
- package/src/config/env.ts +408 -0
- package/src/config/index.ts +506 -0
- package/src/config/loader.ts +329 -0
- package/src/config/merge.ts +285 -0
- package/src/config/types.ts +320 -0
- package/src/config/validation.ts +441 -0
- package/src/container/forward-ref.ts +143 -0
- package/src/container/index.ts +386 -0
- package/src/context/index.ts +360 -0
- package/src/database/index.ts +1142 -0
- package/src/database/migrations/index.ts +371 -0
- package/src/database/schema/index.ts +619 -0
- package/src/frontend/api-routes.ts +640 -0
- package/src/frontend/bundler.ts +643 -0
- package/src/frontend/console-client.ts +419 -0
- package/src/frontend/console-stream.ts +587 -0
- package/src/frontend/dev-server.ts +846 -0
- package/src/frontend/file-router.ts +611 -0
- package/src/frontend/frameworks/index.ts +106 -0
- package/src/frontend/frameworks/react.ts +85 -0
- package/src/frontend/frameworks/solid.ts +104 -0
- package/src/frontend/frameworks/svelte.ts +110 -0
- package/src/frontend/frameworks/vue.ts +92 -0
- package/src/frontend/hmr-client.ts +663 -0
- package/src/frontend/hmr.ts +728 -0
- package/src/frontend/index.ts +342 -0
- package/src/frontend/islands.ts +552 -0
- package/src/frontend/isr.ts +555 -0
- package/src/frontend/layout.ts +475 -0
- package/src/frontend/ssr/react.ts +446 -0
- package/src/frontend/ssr/solid.ts +523 -0
- package/src/frontend/ssr/svelte.ts +546 -0
- package/src/frontend/ssr/vue.ts +504 -0
- package/src/frontend/ssr.ts +699 -0
- package/src/frontend/types.ts +2274 -0
- package/src/health/index.ts +604 -0
- package/src/index.ts +410 -0
- package/src/lock/index.ts +587 -0
- package/src/logger/index.ts +444 -0
- package/src/logger/transports/index.ts +969 -0
- package/src/metrics/index.ts +494 -0
- package/src/middleware/built-in.ts +360 -0
- package/src/middleware/index.ts +94 -0
- package/src/modules/filters.ts +458 -0
- package/src/modules/guards.ts +405 -0
- package/src/modules/index.ts +1256 -0
- package/src/modules/interceptors.ts +574 -0
- package/src/modules/lazy.ts +418 -0
- package/src/modules/lifecycle.ts +478 -0
- package/src/modules/metadata.ts +90 -0
- package/src/modules/pipes.ts +626 -0
- package/src/router/index.ts +339 -0
- package/src/router/linear.ts +371 -0
- package/src/router/regex.ts +292 -0
- package/src/router/tree.ts +562 -0
- package/src/rpc/index.ts +1263 -0
- package/src/security/index.ts +436 -0
- package/src/ssg/index.ts +631 -0
- package/src/storage/index.ts +456 -0
- package/src/telemetry/index.ts +1097 -0
- package/src/testing/index.ts +1586 -0
- package/src/types/index.ts +236 -0
- package/src/types/optional-deps.d.ts +219 -0
- package/src/validation/index.ts +276 -0
- package/src/websocket/index.ts +1004 -0
- package/tests/integration/cli.test.ts +1016 -0
- package/tests/integration/fullstack.test.ts +234 -0
- package/tests/unit/cache.test.ts +174 -0
- package/tests/unit/cli-commands.test.ts +892 -0
- package/tests/unit/cli.test.ts +1258 -0
- package/tests/unit/container.test.ts +279 -0
- package/tests/unit/context.test.ts +221 -0
- package/tests/unit/database.test.ts +183 -0
- package/tests/unit/linear-router.test.ts +280 -0
- package/tests/unit/lock.test.ts +336 -0
- package/tests/unit/middleware.test.ts +184 -0
- package/tests/unit/modules.test.ts +142 -0
- package/tests/unit/pubsub.test.ts +257 -0
- package/tests/unit/regex-router.test.ts +265 -0
- package/tests/unit/router.test.ts +373 -0
- package/tests/unit/rpc.test.ts +1248 -0
- package/tests/unit/security.test.ts +174 -0
- package/tests/unit/telemetry.test.ts +371 -0
- package/tests/unit/test-cache.test.ts +110 -0
- package/tests/unit/test-database.test.ts +282 -0
- package/tests/unit/tree-router.test.ts +325 -0
- package/tests/unit/validation.test.ts +794 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,1586 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Testing Utilities
|
|
3
|
+
*
|
|
4
|
+
* Helper functions for testing Bueno applications with bun:test.
|
|
5
|
+
* Provides request/response testing utilities, mocking, and fixtures.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Context } from "../context";
|
|
9
|
+
import type { Middleware } from "../middleware";
|
|
10
|
+
import type { Router } from "../router";
|
|
11
|
+
|
|
12
|
+
// ============= Types =============
|
|
13
|
+
|
|
14
|
+
export interface TestRequestOptions {
|
|
15
|
+
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
|
|
16
|
+
headers?: Record<string, string>;
|
|
17
|
+
query?: Record<string, string>;
|
|
18
|
+
body?: unknown;
|
|
19
|
+
cookies?: Record<string, string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TestResponse {
|
|
23
|
+
status: number;
|
|
24
|
+
headers: Headers;
|
|
25
|
+
body: unknown;
|
|
26
|
+
text: string;
|
|
27
|
+
json: () => Promise<unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TestContext {
|
|
31
|
+
request: Request;
|
|
32
|
+
response: Response | null;
|
|
33
|
+
context: Context | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ============= Test Request Builder =============
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create a test request
|
|
40
|
+
*/
|
|
41
|
+
export function createTestRequest(
|
|
42
|
+
path: string,
|
|
43
|
+
options: TestRequestOptions = {},
|
|
44
|
+
): Request {
|
|
45
|
+
const {
|
|
46
|
+
method = "GET",
|
|
47
|
+
headers = {},
|
|
48
|
+
query = {},
|
|
49
|
+
body,
|
|
50
|
+
cookies = {},
|
|
51
|
+
} = options;
|
|
52
|
+
|
|
53
|
+
// Build URL with query params
|
|
54
|
+
const url = new URL(`http://localhost${path}`);
|
|
55
|
+
for (const [key, value] of Object.entries(query)) {
|
|
56
|
+
url.searchParams.set(key, value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Build headers
|
|
60
|
+
const requestHeaders = new Headers(headers);
|
|
61
|
+
|
|
62
|
+
// Add cookies
|
|
63
|
+
if (Object.keys(cookies).length > 0) {
|
|
64
|
+
const cookieString = Object.entries(cookies)
|
|
65
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
66
|
+
.join("; ");
|
|
67
|
+
requestHeaders.set("Cookie", cookieString);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Build body
|
|
71
|
+
let requestBody:
|
|
72
|
+
| string
|
|
73
|
+
| ArrayBuffer
|
|
74
|
+
| FormData
|
|
75
|
+
| URLSearchParams
|
|
76
|
+
| undefined;
|
|
77
|
+
if (body !== undefined) {
|
|
78
|
+
if (typeof body === "string") {
|
|
79
|
+
requestBody = body;
|
|
80
|
+
if (!requestHeaders.has("Content-Type")) {
|
|
81
|
+
requestHeaders.set("Content-Type", "text/plain");
|
|
82
|
+
}
|
|
83
|
+
} else if (body instanceof FormData) {
|
|
84
|
+
requestBody = body;
|
|
85
|
+
} else if (body instanceof URLSearchParams) {
|
|
86
|
+
requestBody = body;
|
|
87
|
+
if (!requestHeaders.has("Content-Type")) {
|
|
88
|
+
requestHeaders.set("Content-Type", "application/x-www-form-urlencoded");
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
requestBody = JSON.stringify(body);
|
|
92
|
+
if (!requestHeaders.has("Content-Type")) {
|
|
93
|
+
requestHeaders.set("Content-Type", "application/json");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return new Request(url.toString(), {
|
|
99
|
+
method,
|
|
100
|
+
headers: requestHeaders,
|
|
101
|
+
body: requestBody,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ============= Test Response Helpers =============
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Create a test response wrapper
|
|
109
|
+
*/
|
|
110
|
+
export async function createTestResponse(
|
|
111
|
+
response: Response,
|
|
112
|
+
): Promise<TestResponse> {
|
|
113
|
+
const clone = response.clone();
|
|
114
|
+
let body: unknown = null;
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const contentType = response.headers.get("Content-Type") || "";
|
|
118
|
+
if (contentType.includes("application/json")) {
|
|
119
|
+
body = await response.json();
|
|
120
|
+
} else {
|
|
121
|
+
body = await response.text();
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
body = null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
status: response.status,
|
|
129
|
+
headers: response.headers,
|
|
130
|
+
body,
|
|
131
|
+
text: await clone.text(),
|
|
132
|
+
json: async () => response.json(),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ============= App Tester =============
|
|
137
|
+
|
|
138
|
+
export class AppTester {
|
|
139
|
+
private router: Router;
|
|
140
|
+
|
|
141
|
+
constructor(router: Router) {
|
|
142
|
+
this.router = router;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Make a test request to the app
|
|
147
|
+
*/
|
|
148
|
+
async request(
|
|
149
|
+
path: string,
|
|
150
|
+
options?: TestRequestOptions,
|
|
151
|
+
): Promise<TestResponse> {
|
|
152
|
+
const request = createTestRequest(path, options);
|
|
153
|
+
const url = new URL(request.url);
|
|
154
|
+
|
|
155
|
+
const match = this.router.match(request.method as "GET", url.pathname);
|
|
156
|
+
|
|
157
|
+
if (!match) {
|
|
158
|
+
return createTestResponse(new Response("Not Found", { status: 404 }));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const context = new Context(request, match.params);
|
|
162
|
+
|
|
163
|
+
// Handle middleware
|
|
164
|
+
if (match.middleware && match.middleware.length > 0) {
|
|
165
|
+
const { compose } = await import("../middleware");
|
|
166
|
+
const pipeline = compose(match.middleware as Middleware[]);
|
|
167
|
+
const response = await pipeline(
|
|
168
|
+
context,
|
|
169
|
+
async () => match.handler(context) as Response,
|
|
170
|
+
);
|
|
171
|
+
return createTestResponse(response);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const response = await match.handler(context);
|
|
175
|
+
return createTestResponse(response as Response);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* GET request helper
|
|
180
|
+
*/
|
|
181
|
+
async get(
|
|
182
|
+
path: string,
|
|
183
|
+
options?: Omit<TestRequestOptions, "method" | "body">,
|
|
184
|
+
): Promise<TestResponse> {
|
|
185
|
+
return this.request(path, { ...options, method: "GET" });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* POST request helper
|
|
190
|
+
*/
|
|
191
|
+
async post(
|
|
192
|
+
path: string,
|
|
193
|
+
body?: unknown,
|
|
194
|
+
options?: Omit<TestRequestOptions, "method" | "body">,
|
|
195
|
+
): Promise<TestResponse> {
|
|
196
|
+
return this.request(path, { ...options, method: "POST", body });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* PUT request helper
|
|
201
|
+
*/
|
|
202
|
+
async put(
|
|
203
|
+
path: string,
|
|
204
|
+
body?: unknown,
|
|
205
|
+
options?: Omit<TestRequestOptions, "method" | "body">,
|
|
206
|
+
): Promise<TestResponse> {
|
|
207
|
+
return this.request(path, { ...options, method: "PUT", body });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* PATCH request helper
|
|
212
|
+
*/
|
|
213
|
+
async patch(
|
|
214
|
+
path: string,
|
|
215
|
+
body?: unknown,
|
|
216
|
+
options?: Omit<TestRequestOptions, "method" | "body">,
|
|
217
|
+
): Promise<TestResponse> {
|
|
218
|
+
return this.request(path, { ...options, method: "PATCH", body });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* DELETE request helper
|
|
223
|
+
*/
|
|
224
|
+
async delete(
|
|
225
|
+
path: string,
|
|
226
|
+
options?: Omit<TestRequestOptions, "method">,
|
|
227
|
+
): Promise<TestResponse> {
|
|
228
|
+
return this.request(path, { ...options, method: "DELETE" });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Create an app tester
|
|
234
|
+
*/
|
|
235
|
+
export function createTester(router: Router): AppTester {
|
|
236
|
+
return new AppTester(router);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ============= Mock Helpers =============
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Create a mock context for testing handlers directly
|
|
243
|
+
*/
|
|
244
|
+
export function createMockContext(
|
|
245
|
+
path: string,
|
|
246
|
+
options: TestRequestOptions = {},
|
|
247
|
+
): Context {
|
|
248
|
+
const request = createTestRequest(path, options);
|
|
249
|
+
return new Context(request, {});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Create a mock context with params
|
|
254
|
+
*/
|
|
255
|
+
export function createMockContextWithParams(
|
|
256
|
+
path: string,
|
|
257
|
+
params: Record<string, string>,
|
|
258
|
+
options: TestRequestOptions = {},
|
|
259
|
+
): Context {
|
|
260
|
+
const request = createTestRequest(path, options);
|
|
261
|
+
return new Context(request, params);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ============= Assertion Helpers =============
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Assert response status
|
|
268
|
+
*/
|
|
269
|
+
export function assertStatus(response: TestResponse, expected: number): void {
|
|
270
|
+
if (response.status !== expected) {
|
|
271
|
+
throw new Error(`Expected status ${expected}, got ${response.status}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Assert response is OK (2xx)
|
|
277
|
+
*/
|
|
278
|
+
export function assertOK(response: TestResponse): void {
|
|
279
|
+
if (response.status < 200 || response.status >= 300) {
|
|
280
|
+
throw new Error(`Expected OK status, got ${response.status}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Assert response is JSON
|
|
286
|
+
*/
|
|
287
|
+
export function assertJSON(response: TestResponse): void {
|
|
288
|
+
const contentType = response.headers.get("Content-Type");
|
|
289
|
+
if (!contentType?.includes("application/json")) {
|
|
290
|
+
throw new Error(`Expected JSON response, got ${contentType}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Assert response body
|
|
296
|
+
*/
|
|
297
|
+
export function assertBody(response: TestResponse, expected: unknown): void {
|
|
298
|
+
if (JSON.stringify(response.body) !== JSON.stringify(expected)) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`Expected body ${JSON.stringify(expected)}, got ${JSON.stringify(response.body)}`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Assert response has header
|
|
307
|
+
*/
|
|
308
|
+
export function assertHeader(
|
|
309
|
+
response: TestResponse,
|
|
310
|
+
name: string,
|
|
311
|
+
value?: string,
|
|
312
|
+
): void {
|
|
313
|
+
const headerValue = response.headers.get(name);
|
|
314
|
+
if (!headerValue) {
|
|
315
|
+
throw new Error(`Expected header ${name} to be present`);
|
|
316
|
+
}
|
|
317
|
+
if (value && headerValue !== value) {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`Expected header ${name} to be ${value}, got ${headerValue}`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Assert redirect
|
|
326
|
+
*/
|
|
327
|
+
export function assertRedirect(
|
|
328
|
+
response: TestResponse,
|
|
329
|
+
location?: string,
|
|
330
|
+
): void {
|
|
331
|
+
if (response.status < 300 || response.status >= 400) {
|
|
332
|
+
throw new Error(`Expected redirect status, got ${response.status}`);
|
|
333
|
+
}
|
|
334
|
+
if (location) {
|
|
335
|
+
assertHeader(response, "Location", location);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ============= Snapshot Helpers =============
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Create a snapshot of response for testing
|
|
343
|
+
*/
|
|
344
|
+
export function snapshotResponse(response: TestResponse): object {
|
|
345
|
+
return {
|
|
346
|
+
status: response.status,
|
|
347
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
348
|
+
body: response.body,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ============= Fixture Factory =============
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Create a test fixture factory
|
|
356
|
+
*/
|
|
357
|
+
export class FixtureFactory {
|
|
358
|
+
private sequences: Map<string, number> = new Map();
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Generate a unique ID
|
|
362
|
+
*/
|
|
363
|
+
id(prefix = "test"): string {
|
|
364
|
+
const seq = (this.sequences.get(prefix) ?? 0) + 1;
|
|
365
|
+
this.sequences.set(prefix, seq);
|
|
366
|
+
return `${prefix}_${seq}`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Generate a unique email
|
|
371
|
+
*/
|
|
372
|
+
email(domain = "test.com"): string {
|
|
373
|
+
return `${this.id("email")}@${domain}`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Generate a unique UUID
|
|
378
|
+
*/
|
|
379
|
+
uuid(): string {
|
|
380
|
+
return crypto.randomUUID();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Reset all sequences
|
|
385
|
+
*/
|
|
386
|
+
reset(): void {
|
|
387
|
+
this.sequences.clear();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function createFixtureFactory(): FixtureFactory {
|
|
392
|
+
return new FixtureFactory();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ============= Wait/Timeout Helpers =============
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Wait for a condition to be true
|
|
399
|
+
*/
|
|
400
|
+
export async function waitFor(
|
|
401
|
+
condition: () => boolean | Promise<boolean>,
|
|
402
|
+
timeout = 5000,
|
|
403
|
+
interval = 50,
|
|
404
|
+
): Promise<void> {
|
|
405
|
+
const start = Date.now();
|
|
406
|
+
|
|
407
|
+
while (Date.now() - start < timeout) {
|
|
408
|
+
if (await condition()) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
throw new Error("Timeout waiting for condition");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Sleep for a duration
|
|
419
|
+
*/
|
|
420
|
+
export function sleep(ms: number): Promise<void> {
|
|
421
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ============= Test Cache =============
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Cache operation record for testing
|
|
428
|
+
*/
|
|
429
|
+
export interface CacheOperation {
|
|
430
|
+
type: "get" | "set" | "delete" | "has" | "clear";
|
|
431
|
+
key?: string;
|
|
432
|
+
value?: unknown;
|
|
433
|
+
timestamp: number;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Cache statistics for testing
|
|
438
|
+
*/
|
|
439
|
+
export interface CacheStats {
|
|
440
|
+
hits: number;
|
|
441
|
+
misses: number;
|
|
442
|
+
sets: number;
|
|
443
|
+
deletes: number;
|
|
444
|
+
keyCount: number;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* TestCache - A cache implementation specifically for testing purposes.
|
|
449
|
+
* Wraps an in-memory cache with operation tracking and test utilities.
|
|
450
|
+
*/
|
|
451
|
+
export class TestCache {
|
|
452
|
+
private store = new Map<string, unknown>();
|
|
453
|
+
private _operations: CacheOperation[] = [];
|
|
454
|
+
private _stats = {
|
|
455
|
+
hits: 0,
|
|
456
|
+
misses: 0,
|
|
457
|
+
sets: 0,
|
|
458
|
+
deletes: 0,
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Get the list of all operations performed on this cache
|
|
463
|
+
*/
|
|
464
|
+
get operations(): ReadonlyArray<CacheOperation> {
|
|
465
|
+
return this._operations;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Get a value from the cache
|
|
470
|
+
*/
|
|
471
|
+
async get<T = unknown>(key: string): Promise<T | null> {
|
|
472
|
+
const value = this.store.get(key);
|
|
473
|
+
this._operations.push({
|
|
474
|
+
type: "get",
|
|
475
|
+
key,
|
|
476
|
+
value: value ?? null,
|
|
477
|
+
timestamp: Date.now(),
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
if (value !== undefined) {
|
|
481
|
+
this._stats.hits++;
|
|
482
|
+
return value as T;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
this._stats.misses++;
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Set a value in the cache
|
|
491
|
+
*/
|
|
492
|
+
async set<T>(key: string, value: T): Promise<void> {
|
|
493
|
+
this.store.set(key, value);
|
|
494
|
+
this._stats.sets++;
|
|
495
|
+
this._operations.push({
|
|
496
|
+
type: "set",
|
|
497
|
+
key,
|
|
498
|
+
value,
|
|
499
|
+
timestamp: Date.now(),
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Delete a value from the cache
|
|
505
|
+
*/
|
|
506
|
+
async delete(key: string): Promise<boolean> {
|
|
507
|
+
const existed = this.store.delete(key);
|
|
508
|
+
this._stats.deletes++;
|
|
509
|
+
this._operations.push({
|
|
510
|
+
type: "delete",
|
|
511
|
+
key,
|
|
512
|
+
timestamp: Date.now(),
|
|
513
|
+
});
|
|
514
|
+
return existed;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Check if a key exists in the cache
|
|
519
|
+
*/
|
|
520
|
+
async has(key: string): Promise<boolean> {
|
|
521
|
+
const exists = this.store.has(key);
|
|
522
|
+
this._operations.push({
|
|
523
|
+
type: "has",
|
|
524
|
+
key,
|
|
525
|
+
value: exists,
|
|
526
|
+
timestamp: Date.now(),
|
|
527
|
+
});
|
|
528
|
+
return exists;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Clear all keys from the cache
|
|
533
|
+
*/
|
|
534
|
+
async clearAll(): Promise<void> {
|
|
535
|
+
this.store.clear();
|
|
536
|
+
this._operations.push({
|
|
537
|
+
type: "clear",
|
|
538
|
+
timestamp: Date.now(),
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Get cache statistics
|
|
544
|
+
*/
|
|
545
|
+
getStats(): CacheStats {
|
|
546
|
+
return {
|
|
547
|
+
...this._stats,
|
|
548
|
+
keyCount: this.store.size,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Get all keys in the cache
|
|
554
|
+
*/
|
|
555
|
+
getKeys(): string[] {
|
|
556
|
+
return Array.from(this.store.keys());
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Get all key-value pairs in the cache
|
|
561
|
+
*/
|
|
562
|
+
getEntries(): [string, unknown][] {
|
|
563
|
+
return Array.from(this.store.entries());
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Set multiple entries at once
|
|
568
|
+
*/
|
|
569
|
+
async setMany(entries: Record<string, unknown>): Promise<void> {
|
|
570
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
571
|
+
await this.set(key, value);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Get a value without affecting hit/miss statistics
|
|
577
|
+
*/
|
|
578
|
+
peek<T = unknown>(key: string): T | null {
|
|
579
|
+
const value = this.store.get(key);
|
|
580
|
+
return value !== undefined ? (value as T) : null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Reset the cache completely - clear all data, stats, and operations
|
|
585
|
+
*/
|
|
586
|
+
reset(): void {
|
|
587
|
+
this.store.clear();
|
|
588
|
+
this._operations = [];
|
|
589
|
+
this._stats = {
|
|
590
|
+
hits: 0,
|
|
591
|
+
misses: 0,
|
|
592
|
+
sets: 0,
|
|
593
|
+
deletes: 0,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Create a new TestCache instance, optionally with initial data
|
|
600
|
+
*/
|
|
601
|
+
export async function createTestCache(initialData?: Record<string, unknown>): Promise<TestCache> {
|
|
602
|
+
const cache = new TestCache();
|
|
603
|
+
if (initialData) {
|
|
604
|
+
await cache.setMany(initialData);
|
|
605
|
+
}
|
|
606
|
+
return cache;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ============= Cache Assertions =============
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Assert that a key exists in the cache
|
|
613
|
+
*/
|
|
614
|
+
export function assertCacheHas(cache: TestCache, key: string): void {
|
|
615
|
+
const keys = cache.getKeys();
|
|
616
|
+
if (!keys.includes(key)) {
|
|
617
|
+
throw new Error(
|
|
618
|
+
`Expected cache to have key "${key}". Available keys: [${keys.join(", ")}]`,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Assert that a key does not exist in the cache
|
|
625
|
+
*/
|
|
626
|
+
export function assertCacheNotHas(cache: TestCache, key: string): void {
|
|
627
|
+
const keys = cache.getKeys();
|
|
628
|
+
if (keys.includes(key)) {
|
|
629
|
+
throw new Error(`Expected cache to NOT have key "${key}"`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Assert a cached value matches expected
|
|
635
|
+
*/
|
|
636
|
+
export function assertCacheValue<T = unknown>(
|
|
637
|
+
cache: TestCache,
|
|
638
|
+
key: string,
|
|
639
|
+
expected: T,
|
|
640
|
+
): void {
|
|
641
|
+
const value = cache.peek<T>(key);
|
|
642
|
+
if (value === null) {
|
|
643
|
+
throw new Error(`Expected cache to have key "${key}"`);
|
|
644
|
+
}
|
|
645
|
+
if (JSON.stringify(value) !== JSON.stringify(expected)) {
|
|
646
|
+
throw new Error(
|
|
647
|
+
`Expected cache value for "${key}" to be ${JSON.stringify(expected)}, got ${JSON.stringify(value)}`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Assert cache statistics match expected values
|
|
654
|
+
*/
|
|
655
|
+
export function assertCacheStats(
|
|
656
|
+
cache: TestCache,
|
|
657
|
+
expected: Partial<CacheStats>,
|
|
658
|
+
): void {
|
|
659
|
+
const stats = cache.getStats();
|
|
660
|
+
|
|
661
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
662
|
+
const actualValue = stats[key as keyof CacheStats];
|
|
663
|
+
if (actualValue !== value) {
|
|
664
|
+
throw new Error(
|
|
665
|
+
`Expected cache stat "${key}" to be ${value}, got ${actualValue}`,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ============= Test Database =============
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Schema definition for test database
|
|
675
|
+
*/
|
|
676
|
+
export interface TestDatabaseSchema {
|
|
677
|
+
[table: string]: {
|
|
678
|
+
[column: string]: string; // Column definition, e.g., "INTEGER PRIMARY KEY"
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Seed data for test database
|
|
684
|
+
*/
|
|
685
|
+
export interface TestDatabaseSeed {
|
|
686
|
+
[table: string]: Record<string, unknown>[];
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Options for creating a test database
|
|
691
|
+
*/
|
|
692
|
+
export interface TestDatabaseOptions {
|
|
693
|
+
schema?: TestDatabaseSchema;
|
|
694
|
+
seed?: TestDatabaseSeed;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Database operation record for testing
|
|
699
|
+
*/
|
|
700
|
+
export interface DatabaseOperation {
|
|
701
|
+
type: "query" | "execute" | "transaction";
|
|
702
|
+
sql: string;
|
|
703
|
+
params?: unknown[];
|
|
704
|
+
timestamp: number;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* TestDatabase - An in-memory SQLite database for testing purposes.
|
|
709
|
+
* Provides operation tracking, transaction support, and test utilities.
|
|
710
|
+
*/
|
|
711
|
+
export class TestDatabase {
|
|
712
|
+
private sql: unknown = null;
|
|
713
|
+
private _operations: DatabaseOperation[] = [];
|
|
714
|
+
private _isConnected = false;
|
|
715
|
+
private _schema: TestDatabaseSchema = {};
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Get the list of all operations performed on this database
|
|
719
|
+
*/
|
|
720
|
+
get operations(): ReadonlyArray<DatabaseOperation> {
|
|
721
|
+
return this._operations;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Connect to the in-memory SQLite database
|
|
726
|
+
*/
|
|
727
|
+
async connect(): Promise<void> {
|
|
728
|
+
if (this._isConnected) return;
|
|
729
|
+
|
|
730
|
+
try {
|
|
731
|
+
const { SQL } = await import("bun");
|
|
732
|
+
this.sql = new SQL(":memory:", { adapter: "sqlite" });
|
|
733
|
+
this._isConnected = true;
|
|
734
|
+
} catch (error) {
|
|
735
|
+
throw new Error(
|
|
736
|
+
`Failed to connect to test database: ${error instanceof Error ? error.message : String(error)}`,
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Check if connected
|
|
743
|
+
*/
|
|
744
|
+
get isConnected(): boolean {
|
|
745
|
+
return this._isConnected;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Execute a SQL query and return results
|
|
750
|
+
*/
|
|
751
|
+
async query<T = unknown>(sqlString: string, params: unknown[] = []): Promise<T[]> {
|
|
752
|
+
this.ensureConnection();
|
|
753
|
+
|
|
754
|
+
this._operations.push({
|
|
755
|
+
type: "query",
|
|
756
|
+
sql: sqlString,
|
|
757
|
+
params,
|
|
758
|
+
timestamp: Date.now(),
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
const sql = this.sql as {
|
|
762
|
+
unsafe: (query: string, params?: unknown[]) => Promise<T[]>;
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
return sql.unsafe(sqlString, params);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Execute a query and return a single row
|
|
770
|
+
*/
|
|
771
|
+
async queryOne<T = unknown>(sqlString: string, params: unknown[] = []): Promise<T | null> {
|
|
772
|
+
const results = await this.query<T>(sqlString, params);
|
|
773
|
+
return results.length > 0 ? results[0] : null;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Execute a statement (INSERT, UPDATE, DELETE)
|
|
778
|
+
*/
|
|
779
|
+
async execute(sqlString: string, params: unknown[] = []): Promise<{ rows: unknown[]; rowCount: number; insertId?: number | string }> {
|
|
780
|
+
this.ensureConnection();
|
|
781
|
+
|
|
782
|
+
this._operations.push({
|
|
783
|
+
type: "execute",
|
|
784
|
+
sql: sqlString,
|
|
785
|
+
params,
|
|
786
|
+
timestamp: Date.now(),
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
const sql = this.sql as {
|
|
790
|
+
unsafe: (query: string, params?: unknown[]) => Promise<unknown[]>;
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
const results = await sql.unsafe(sqlString, params);
|
|
794
|
+
|
|
795
|
+
// For SQLite, try to get the last insert ID
|
|
796
|
+
const lastIdResult = await sql.unsafe("SELECT last_insert_rowid() as id");
|
|
797
|
+
const insertId = lastIdResult[0] as { id: number | string } | undefined;
|
|
798
|
+
|
|
799
|
+
return {
|
|
800
|
+
rows: results,
|
|
801
|
+
rowCount: results.length,
|
|
802
|
+
insertId: insertId?.id,
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Run operations in a transaction
|
|
808
|
+
*/
|
|
809
|
+
async transaction<T>(callback: (db: TestDatabase) => Promise<T>): Promise<T> {
|
|
810
|
+
this.ensureConnection();
|
|
811
|
+
|
|
812
|
+
this._operations.push({
|
|
813
|
+
type: "transaction",
|
|
814
|
+
sql: "BEGIN TRANSACTION",
|
|
815
|
+
timestamp: Date.now(),
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
const sql = this.sql as {
|
|
819
|
+
unsafe: (query: string, params?: unknown[]) => Promise<unknown[]>;
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
try {
|
|
823
|
+
await sql.unsafe("BEGIN TRANSACTION");
|
|
824
|
+
const result = await callback(this);
|
|
825
|
+
await sql.unsafe("COMMIT");
|
|
826
|
+
return result;
|
|
827
|
+
} catch (error) {
|
|
828
|
+
await sql.unsafe("ROLLBACK");
|
|
829
|
+
throw error;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Rollback all changes (useful between tests when using savepoints)
|
|
835
|
+
*/
|
|
836
|
+
async rollback(): Promise<void> {
|
|
837
|
+
this.ensureConnection();
|
|
838
|
+
|
|
839
|
+
const sql = this.sql as {
|
|
840
|
+
unsafe: (query: string) => Promise<unknown[]>;
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
await sql.unsafe("ROLLBACK");
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Close the database connection
|
|
848
|
+
*/
|
|
849
|
+
async close(): Promise<void> {
|
|
850
|
+
if (!this._isConnected) return;
|
|
851
|
+
|
|
852
|
+
const sql = this.sql as {
|
|
853
|
+
close: () => Promise<void>;
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
if (sql.close) {
|
|
857
|
+
await sql.close();
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
this.sql = null;
|
|
861
|
+
this._isConnected = false;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Get all tables in the database
|
|
866
|
+
*/
|
|
867
|
+
async getTables(): Promise<string[]> {
|
|
868
|
+
const result = await this.query<{ name: string }>(
|
|
869
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
|
|
870
|
+
);
|
|
871
|
+
return result.map((row) => row.name);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Seed database with initial data
|
|
876
|
+
*/
|
|
877
|
+
async seed(tables: TestDatabaseSeed): Promise<void> {
|
|
878
|
+
for (const [table, rows] of Object.entries(tables)) {
|
|
879
|
+
for (const row of rows) {
|
|
880
|
+
const keys = Object.keys(row);
|
|
881
|
+
const values = Object.values(row);
|
|
882
|
+
const placeholders = values.map(() => "?").join(", ");
|
|
883
|
+
const columns = keys.join(", ");
|
|
884
|
+
|
|
885
|
+
await this.execute(
|
|
886
|
+
`INSERT INTO ${table} (${columns}) VALUES (${placeholders})`,
|
|
887
|
+
values,
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Drop all tables and reset state
|
|
895
|
+
*/
|
|
896
|
+
async reset(): Promise<void> {
|
|
897
|
+
const tables = await this.getTables();
|
|
898
|
+
|
|
899
|
+
for (const table of tables) {
|
|
900
|
+
await this.execute(`DROP TABLE IF EXISTS ${table}`);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
this._operations = [];
|
|
904
|
+
this._schema = {};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Create a table from column definitions
|
|
909
|
+
*/
|
|
910
|
+
async createTable(name: string, columns: Record<string, string>): Promise<void> {
|
|
911
|
+
const columnDefs = Object.entries(columns)
|
|
912
|
+
.map(([colName, def]) => `${colName} ${def}`)
|
|
913
|
+
.join(", ");
|
|
914
|
+
|
|
915
|
+
await this.execute(`CREATE TABLE ${name} (${columnDefs})`);
|
|
916
|
+
this._schema[name] = columns;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Drop a table
|
|
921
|
+
*/
|
|
922
|
+
async dropTable(name: string): Promise<void> {
|
|
923
|
+
await this.execute(`DROP TABLE IF EXISTS ${name}`);
|
|
924
|
+
delete this._schema[name];
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Clear all rows from a table (truncate)
|
|
929
|
+
*/
|
|
930
|
+
async truncate(name: string): Promise<void> {
|
|
931
|
+
await this.execute(`DELETE FROM ${name}`);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Get the current schema
|
|
936
|
+
*/
|
|
937
|
+
getSchema(): TestDatabaseSchema {
|
|
938
|
+
return { ...this._schema };
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Get table info
|
|
943
|
+
*/
|
|
944
|
+
async getTableInfo(table: string): Promise<{ cid: number; name: string; type: string; notnull: number; dflt_value: unknown; pk: number }[]> {
|
|
945
|
+
return this.query(`PRAGMA table_info(${table})`);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Count rows in a table
|
|
950
|
+
*/
|
|
951
|
+
async count(table: string, where?: string, params: unknown[] = []): Promise<number> {
|
|
952
|
+
const sql = where
|
|
953
|
+
? `SELECT COUNT(*) as count FROM ${table} WHERE ${where}`
|
|
954
|
+
: `SELECT COUNT(*) as count FROM ${table}`;
|
|
955
|
+
|
|
956
|
+
const result = await this.queryOne<{ count: number | string }>(sql, params);
|
|
957
|
+
return Number(result?.count ?? 0);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Check if a row exists
|
|
962
|
+
*/
|
|
963
|
+
async exists(table: string, where: string, params: unknown[] = []): Promise<boolean> {
|
|
964
|
+
const count = await this.count(table, where, params);
|
|
965
|
+
return count > 0;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Clear operation history
|
|
970
|
+
*/
|
|
971
|
+
clearOperations(): void {
|
|
972
|
+
this._operations = [];
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Ensure connection is established
|
|
977
|
+
*/
|
|
978
|
+
private ensureConnection(): void {
|
|
979
|
+
if (!this._isConnected || !this.sql) {
|
|
980
|
+
throw new Error("TestDatabase not connected. Call connect() first.");
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Create a new TestDatabase instance, optionally with schema and seed data
|
|
987
|
+
*/
|
|
988
|
+
export async function createTestDatabase(options: TestDatabaseOptions = {}): Promise<TestDatabase> {
|
|
989
|
+
const db = new TestDatabase();
|
|
990
|
+
await db.connect();
|
|
991
|
+
|
|
992
|
+
// Create schema
|
|
993
|
+
if (options.schema) {
|
|
994
|
+
for (const [table, columns] of Object.entries(options.schema)) {
|
|
995
|
+
await db.createTable(table, columns);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Seed data
|
|
1000
|
+
if (options.seed) {
|
|
1001
|
+
await db.seed(options.seed);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
return db;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// ============= Database Assertions =============
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Assert row count in a table
|
|
1011
|
+
*/
|
|
1012
|
+
export async function assertTableRowCount(
|
|
1013
|
+
db: TestDatabase,
|
|
1014
|
+
table: string,
|
|
1015
|
+
expected: number,
|
|
1016
|
+
): Promise<void> {
|
|
1017
|
+
const count = await db.count(table);
|
|
1018
|
+
if (count !== expected) {
|
|
1019
|
+
throw new Error(
|
|
1020
|
+
`Expected table "${table}" to have ${expected} rows, but found ${count}`,
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Assert a row exists in a table
|
|
1027
|
+
*/
|
|
1028
|
+
export async function assertTableHasRow(
|
|
1029
|
+
db: TestDatabase,
|
|
1030
|
+
table: string,
|
|
1031
|
+
condition: string,
|
|
1032
|
+
params: unknown[] = [],
|
|
1033
|
+
): Promise<void> {
|
|
1034
|
+
const exists = await db.exists(table, condition, params);
|
|
1035
|
+
if (!exists) {
|
|
1036
|
+
throw new Error(
|
|
1037
|
+
`Expected table "${table}" to have a row matching: ${condition}`,
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Assert a row does not exist in a table
|
|
1044
|
+
*/
|
|
1045
|
+
export async function assertTableNotHasRow(
|
|
1046
|
+
db: TestDatabase,
|
|
1047
|
+
table: string,
|
|
1048
|
+
condition: string,
|
|
1049
|
+
params: unknown[] = [],
|
|
1050
|
+
): Promise<void> {
|
|
1051
|
+
const exists = await db.exists(table, condition, params);
|
|
1052
|
+
if (exists) {
|
|
1053
|
+
throw new Error(
|
|
1054
|
+
`Expected table "${table}" to NOT have a row matching: ${condition}`,
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Assert table exists in database
|
|
1061
|
+
*/
|
|
1062
|
+
export async function assertTableExists(db: TestDatabase, table: string): Promise<void> {
|
|
1063
|
+
const tables = await db.getTables();
|
|
1064
|
+
if (!tables.includes(table)) {
|
|
1065
|
+
throw new Error(
|
|
1066
|
+
`Expected table "${table}" to exist. Available tables: [${tables.join(", ")}]`,
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Assert table does not exist in database
|
|
1073
|
+
*/
|
|
1074
|
+
export async function assertTableNotExists(db: TestDatabase, table: string): Promise<void> {
|
|
1075
|
+
const tables = await db.getTables();
|
|
1076
|
+
if (tables.includes(table)) {
|
|
1077
|
+
throw new Error(`Expected table "${table}" to NOT exist`);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Assert a specific value in a table
|
|
1083
|
+
*/
|
|
1084
|
+
export async function assertTableValue<T = unknown>(
|
|
1085
|
+
db: TestDatabase,
|
|
1086
|
+
table: string,
|
|
1087
|
+
column: string,
|
|
1088
|
+
condition: string,
|
|
1089
|
+
expected: T,
|
|
1090
|
+
params: unknown[] = [],
|
|
1091
|
+
): Promise<void> {
|
|
1092
|
+
const sql = `SELECT ${column} as value FROM ${table} WHERE ${condition} LIMIT 1`;
|
|
1093
|
+
const result = await db.queryOne<{ value: T }>(sql, params);
|
|
1094
|
+
|
|
1095
|
+
if (!result) {
|
|
1096
|
+
throw new Error(
|
|
1097
|
+
`Expected to find a row in "${table}" matching: ${condition}`,
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
if (JSON.stringify(result.value) !== JSON.stringify(expected)) {
|
|
1102
|
+
throw new Error(
|
|
1103
|
+
`Expected ${column} to be ${JSON.stringify(expected)}, got ${JSON.stringify(result.value)}`,
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
// ============= Test Storage =============
|
|
1108
|
+
|
|
1109
|
+
import { mkdir, rm, readdir, stat as fsStat, copyFile, rename, unlink } from "node:fs/promises";
|
|
1110
|
+
import { join, resolve, relative } from "node:path";
|
|
1111
|
+
import { tmpdir } from "node:os";
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Storage operation record for testing
|
|
1115
|
+
*/
|
|
1116
|
+
export interface StorageOperation {
|
|
1117
|
+
type: "write" | "read" | "delete" | "exists" | "list" | "stat" | "copy" | "move" | "clear";
|
|
1118
|
+
path?: string;
|
|
1119
|
+
src?: string;
|
|
1120
|
+
dest?: string;
|
|
1121
|
+
size?: number;
|
|
1122
|
+
timestamp: number;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* File stats returned by TestStorage
|
|
1127
|
+
*/
|
|
1128
|
+
export interface StorageFileStats {
|
|
1129
|
+
size: number;
|
|
1130
|
+
created: number;
|
|
1131
|
+
modified: number;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Options for creating a test storage
|
|
1136
|
+
*/
|
|
1137
|
+
export interface TestStorageOptions {
|
|
1138
|
+
basePath?: string;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* TestStorage - A mock file storage for testing purposes.
|
|
1143
|
+
* Uses a temporary directory for file operations with operation tracking.
|
|
1144
|
+
*/
|
|
1145
|
+
export class TestStorage {
|
|
1146
|
+
private _basePath: string;
|
|
1147
|
+
private _operations: StorageOperation[] = [];
|
|
1148
|
+
private _initialized = false;
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Get the base path of the storage
|
|
1152
|
+
*/
|
|
1153
|
+
get basePath(): string {
|
|
1154
|
+
return this._basePath;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Get the list of all operations performed on this storage
|
|
1159
|
+
*/
|
|
1160
|
+
get operations(): ReadonlyArray<StorageOperation> {
|
|
1161
|
+
return this._operations;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
constructor(basePath: string) {
|
|
1165
|
+
this._basePath = basePath;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* Initialize the storage (create base directory)
|
|
1170
|
+
*/
|
|
1171
|
+
async init(): Promise<void> {
|
|
1172
|
+
if (this._initialized) return;
|
|
1173
|
+
await mkdir(this._basePath, { recursive: true });
|
|
1174
|
+
this._initialized = true;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Write content to a file
|
|
1179
|
+
* @param path - Relative path within storage
|
|
1180
|
+
* @param content - String or binary content
|
|
1181
|
+
*/
|
|
1182
|
+
async write(path: string, content: string | Uint8Array | ArrayBuffer): Promise<void> {
|
|
1183
|
+
await this.ensureInitialized();
|
|
1184
|
+
const fullPath = this.resolvePath(path);
|
|
1185
|
+
|
|
1186
|
+
// Ensure parent directory exists
|
|
1187
|
+
const parentDir = fullPath.substring(0, fullPath.lastIndexOf("/"));
|
|
1188
|
+
if (parentDir) {
|
|
1189
|
+
await mkdir(parentDir, { recursive: true });
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Write content using Bun.file()
|
|
1193
|
+
const file = Bun.file(fullPath);
|
|
1194
|
+
const writer = file.writer();
|
|
1195
|
+
|
|
1196
|
+
if (typeof content === "string") {
|
|
1197
|
+
writer.write(content);
|
|
1198
|
+
} else if (content instanceof Uint8Array) {
|
|
1199
|
+
writer.write(content);
|
|
1200
|
+
} else if (content instanceof ArrayBuffer) {
|
|
1201
|
+
writer.write(new Uint8Array(content));
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
await writer.end();
|
|
1205
|
+
|
|
1206
|
+
const size = typeof content === "string"
|
|
1207
|
+
? new TextEncoder().encode(content).length
|
|
1208
|
+
: content.byteLength;
|
|
1209
|
+
|
|
1210
|
+
this._operations.push({
|
|
1211
|
+
type: "write",
|
|
1212
|
+
path,
|
|
1213
|
+
size,
|
|
1214
|
+
timestamp: Date.now(),
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Read content from a file
|
|
1220
|
+
* @param path - Relative path within storage
|
|
1221
|
+
* @returns File content as string or null if not found
|
|
1222
|
+
*/
|
|
1223
|
+
async read(path: string): Promise<string | null> {
|
|
1224
|
+
await this.ensureInitialized();
|
|
1225
|
+
const fullPath = this.resolvePath(path);
|
|
1226
|
+
const file = Bun.file(fullPath);
|
|
1227
|
+
|
|
1228
|
+
this._operations.push({
|
|
1229
|
+
type: "read",
|
|
1230
|
+
path,
|
|
1231
|
+
timestamp: Date.now(),
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
if (!(await file.exists())) {
|
|
1235
|
+
return null;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
return file.text();
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Read content from a file as ArrayBuffer
|
|
1243
|
+
* @param path - Relative path within storage
|
|
1244
|
+
* @returns File content as ArrayBuffer or null if not found
|
|
1245
|
+
*/
|
|
1246
|
+
async readBytes(path: string): Promise<ArrayBuffer | null> {
|
|
1247
|
+
await this.ensureInitialized();
|
|
1248
|
+
const fullPath = this.resolvePath(path);
|
|
1249
|
+
const file = Bun.file(fullPath);
|
|
1250
|
+
|
|
1251
|
+
this._operations.push({
|
|
1252
|
+
type: "read",
|
|
1253
|
+
path,
|
|
1254
|
+
timestamp: Date.now(),
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
if (!(await file.exists())) {
|
|
1258
|
+
return null;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
return file.arrayBuffer();
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* Delete a file
|
|
1266
|
+
* @param path - Relative path within storage
|
|
1267
|
+
* @returns True if file was deleted, false if it didn't exist
|
|
1268
|
+
*/
|
|
1269
|
+
async delete(path: string): Promise<boolean> {
|
|
1270
|
+
await this.ensureInitialized();
|
|
1271
|
+
const fullPath = this.resolvePath(path);
|
|
1272
|
+
const file = Bun.file(fullPath);
|
|
1273
|
+
|
|
1274
|
+
const exists = await file.exists();
|
|
1275
|
+
if (exists) {
|
|
1276
|
+
await unlink(fullPath);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
this._operations.push({
|
|
1280
|
+
type: "delete",
|
|
1281
|
+
path,
|
|
1282
|
+
timestamp: Date.now(),
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
return exists;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* Check if a file exists
|
|
1290
|
+
* @param path - Relative path within storage
|
|
1291
|
+
*/
|
|
1292
|
+
async exists(path: string): Promise<boolean> {
|
|
1293
|
+
await this.ensureInitialized();
|
|
1294
|
+
const fullPath = this.resolvePath(path);
|
|
1295
|
+
const file = Bun.file(fullPath);
|
|
1296
|
+
const exists = await file.exists();
|
|
1297
|
+
|
|
1298
|
+
this._operations.push({
|
|
1299
|
+
type: "exists",
|
|
1300
|
+
path,
|
|
1301
|
+
timestamp: Date.now(),
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
return exists;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* List files in storage
|
|
1309
|
+
* @param prefix - Optional prefix to filter files
|
|
1310
|
+
* @returns Array of relative file paths
|
|
1311
|
+
*/
|
|
1312
|
+
async list(prefix?: string): Promise<string[]> {
|
|
1313
|
+
await this.ensureInitialized();
|
|
1314
|
+
const files: string[] = [];
|
|
1315
|
+
|
|
1316
|
+
const scanDir = async (dir: string): Promise<void> => {
|
|
1317
|
+
try {
|
|
1318
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
1319
|
+
for (const entry of entries) {
|
|
1320
|
+
const fullPath = join(dir, entry.name);
|
|
1321
|
+
if (entry.isDirectory()) {
|
|
1322
|
+
await scanDir(fullPath);
|
|
1323
|
+
} else if (entry.isFile()) {
|
|
1324
|
+
const relativePath = relative(this._basePath, fullPath);
|
|
1325
|
+
if (!prefix || relativePath.startsWith(prefix)) {
|
|
1326
|
+
files.push(relativePath);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
} catch {
|
|
1331
|
+
// Directory doesn't exist or can't be read
|
|
1332
|
+
}
|
|
1333
|
+
};
|
|
1334
|
+
|
|
1335
|
+
await scanDir(this._basePath);
|
|
1336
|
+
|
|
1337
|
+
this._operations.push({
|
|
1338
|
+
type: "list",
|
|
1339
|
+
path: prefix,
|
|
1340
|
+
timestamp: Date.now(),
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
return files.sort();
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* Get file stats
|
|
1348
|
+
* @param path - Relative path within storage
|
|
1349
|
+
* @returns File stats or null if not found
|
|
1350
|
+
*/
|
|
1351
|
+
async stat(path: string): Promise<StorageFileStats | null> {
|
|
1352
|
+
await this.ensureInitialized();
|
|
1353
|
+
const fullPath = this.resolvePath(path);
|
|
1354
|
+
const file = Bun.file(fullPath);
|
|
1355
|
+
|
|
1356
|
+
if (!(await file.exists())) {
|
|
1357
|
+
this._operations.push({
|
|
1358
|
+
type: "stat",
|
|
1359
|
+
path,
|
|
1360
|
+
timestamp: Date.now(),
|
|
1361
|
+
});
|
|
1362
|
+
return null;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
const stats = await fsStat(fullPath);
|
|
1366
|
+
|
|
1367
|
+
this._operations.push({
|
|
1368
|
+
type: "stat",
|
|
1369
|
+
path,
|
|
1370
|
+
size: stats.size,
|
|
1371
|
+
timestamp: Date.now(),
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
return {
|
|
1375
|
+
size: stats.size,
|
|
1376
|
+
created: stats.birthtimeMs,
|
|
1377
|
+
modified: stats.mtimeMs,
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* Copy a file
|
|
1383
|
+
* @param src - Source path (relative)
|
|
1384
|
+
* @param dest - Destination path (relative)
|
|
1385
|
+
*/
|
|
1386
|
+
async copy(src: string, dest: string): Promise<void> {
|
|
1387
|
+
await this.ensureInitialized();
|
|
1388
|
+
const srcPath = this.resolvePath(src);
|
|
1389
|
+
const destPath = this.resolvePath(dest);
|
|
1390
|
+
|
|
1391
|
+
// Ensure parent directory exists for destination
|
|
1392
|
+
const parentDir = destPath.substring(0, destPath.lastIndexOf("/"));
|
|
1393
|
+
if (parentDir) {
|
|
1394
|
+
await mkdir(parentDir, { recursive: true });
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
await copyFile(srcPath, destPath);
|
|
1398
|
+
|
|
1399
|
+
const stats = await fsStat(destPath);
|
|
1400
|
+
this._operations.push({
|
|
1401
|
+
type: "copy",
|
|
1402
|
+
src,
|
|
1403
|
+
dest,
|
|
1404
|
+
size: stats.size,
|
|
1405
|
+
timestamp: Date.now(),
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* Move/rename a file
|
|
1411
|
+
* @param src - Source path (relative)
|
|
1412
|
+
* @param dest - Destination path (relative)
|
|
1413
|
+
*/
|
|
1414
|
+
async move(src: string, dest: string): Promise<void> {
|
|
1415
|
+
await this.ensureInitialized();
|
|
1416
|
+
const srcPath = this.resolvePath(src);
|
|
1417
|
+
const destPath = this.resolvePath(dest);
|
|
1418
|
+
|
|
1419
|
+
// Ensure parent directory exists for destination
|
|
1420
|
+
const parentDir = destPath.substring(0, destPath.lastIndexOf("/"));
|
|
1421
|
+
if (parentDir) {
|
|
1422
|
+
await mkdir(parentDir, { recursive: true });
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
await rename(srcPath, destPath);
|
|
1426
|
+
|
|
1427
|
+
this._operations.push({
|
|
1428
|
+
type: "move",
|
|
1429
|
+
src,
|
|
1430
|
+
dest,
|
|
1431
|
+
timestamp: Date.now(),
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/**
|
|
1436
|
+
* Delete all files in storage
|
|
1437
|
+
*/
|
|
1438
|
+
async clear(): Promise<void> {
|
|
1439
|
+
await this.ensureInitialized();
|
|
1440
|
+
|
|
1441
|
+
try {
|
|
1442
|
+
const files = await this.list();
|
|
1443
|
+
for (const file of files) {
|
|
1444
|
+
const fullPath = this.resolvePath(file);
|
|
1445
|
+
await unlink(fullPath);
|
|
1446
|
+
}
|
|
1447
|
+
} catch {
|
|
1448
|
+
// Ignore errors
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
this._operations.push({
|
|
1452
|
+
type: "clear",
|
|
1453
|
+
timestamp: Date.now(),
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* Get the base path of the storage
|
|
1459
|
+
*/
|
|
1460
|
+
getBasePath(): string {
|
|
1461
|
+
return this._basePath;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/**
|
|
1465
|
+
* Reset the storage - clear all files and operations log
|
|
1466
|
+
*/
|
|
1467
|
+
async reset(): Promise<void> {
|
|
1468
|
+
await this.clear();
|
|
1469
|
+
this._operations = [];
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/**
|
|
1473
|
+
* Clean up - remove the entire base directory
|
|
1474
|
+
*/
|
|
1475
|
+
async cleanup(): Promise<void> {
|
|
1476
|
+
try {
|
|
1477
|
+
await rm(this._basePath, { recursive: true, force: true });
|
|
1478
|
+
} catch {
|
|
1479
|
+
// Ignore errors
|
|
1480
|
+
}
|
|
1481
|
+
this._operations = [];
|
|
1482
|
+
this._initialized = false;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* Resolve a relative path to full path
|
|
1487
|
+
*/
|
|
1488
|
+
private resolvePath(path: string): string {
|
|
1489
|
+
// Normalize path and remove leading slashes
|
|
1490
|
+
const normalizedPath = path.replace(/^\/+/, "");
|
|
1491
|
+
return resolve(this._basePath, normalizedPath);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* Ensure storage is initialized
|
|
1496
|
+
*/
|
|
1497
|
+
private async ensureInitialized(): Promise<void> {
|
|
1498
|
+
if (!this._initialized) {
|
|
1499
|
+
await this.init();
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/**
|
|
1505
|
+
* Create a new TestStorage instance
|
|
1506
|
+
* @param options - Optional configuration
|
|
1507
|
+
*/
|
|
1508
|
+
export async function createTestStorage(options: TestStorageOptions = {}): Promise<TestStorage> {
|
|
1509
|
+
const basePath = options.basePath ?? await createTempDir("bueno-test-storage-");
|
|
1510
|
+
const storage = new TestStorage(basePath);
|
|
1511
|
+
await storage.init();
|
|
1512
|
+
return storage;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
/**
|
|
1516
|
+
* Create a temporary directory
|
|
1517
|
+
*/
|
|
1518
|
+
async function createTempDir(prefix: string): Promise<string> {
|
|
1519
|
+
const { mkdtemp } = await import("node:fs/promises");
|
|
1520
|
+
const { tmpdir } = await import("node:os");
|
|
1521
|
+
const { join } = await import("node:path");
|
|
1522
|
+
return mkdtemp(join(tmpdir(), prefix));
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// ============= Storage Assertions =============
|
|
1526
|
+
|
|
1527
|
+
/**
|
|
1528
|
+
* Assert that a file exists in storage
|
|
1529
|
+
*/
|
|
1530
|
+
export async function assertFileExists(storage: TestStorage, path: string): Promise<void> {
|
|
1531
|
+
const exists = await storage.exists(path);
|
|
1532
|
+
if (!exists) {
|
|
1533
|
+
const files = await storage.list();
|
|
1534
|
+
throw new Error(
|
|
1535
|
+
`Expected file "${path}" to exist. Available files: [${files.join(", ")}]`,
|
|
1536
|
+
);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* Assert that a file does not exist in storage
|
|
1542
|
+
*/
|
|
1543
|
+
export async function assertFileNotExists(storage: TestStorage, path: string): Promise<void> {
|
|
1544
|
+
const exists = await storage.exists(path);
|
|
1545
|
+
if (exists) {
|
|
1546
|
+
throw new Error(`Expected file "${path}" to NOT exist`);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* Assert file content matches expected
|
|
1552
|
+
*/
|
|
1553
|
+
export async function assertFileContent(
|
|
1554
|
+
storage: TestStorage,
|
|
1555
|
+
path: string,
|
|
1556
|
+
expected: string,
|
|
1557
|
+
): Promise<void> {
|
|
1558
|
+
const content = await storage.read(path);
|
|
1559
|
+
if (content === null) {
|
|
1560
|
+
throw new Error(`Expected file "${path}" to exist`);
|
|
1561
|
+
}
|
|
1562
|
+
if (content !== expected) {
|
|
1563
|
+
throw new Error(
|
|
1564
|
+
`Expected file content for "${path}" to be:\n${expected}\n\nGot:\n${content}`,
|
|
1565
|
+
);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* Assert file size matches expected
|
|
1571
|
+
*/
|
|
1572
|
+
export async function assertFileSize(
|
|
1573
|
+
storage: TestStorage,
|
|
1574
|
+
path: string,
|
|
1575
|
+
expectedSize: number,
|
|
1576
|
+
): Promise<void> {
|
|
1577
|
+
const stats = await storage.stat(path);
|
|
1578
|
+
if (stats === null) {
|
|
1579
|
+
throw new Error(`Expected file "${path}" to exist`);
|
|
1580
|
+
}
|
|
1581
|
+
if (stats.size !== expectedSize) {
|
|
1582
|
+
throw new Error(
|
|
1583
|
+
`Expected file "${path}" to have size ${expectedSize}, got ${stats.size}`,
|
|
1584
|
+
);
|
|
1585
|
+
}
|
|
1586
|
+
}
|