@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.
Files changed (120) hide show
  1. package/.env.example +109 -0
  2. package/.github/workflows/ci.yml +31 -0
  3. package/LICENSE +21 -0
  4. package/README.md +892 -0
  5. package/architecture.md +652 -0
  6. package/bun.lock +70 -0
  7. package/dist/cli/index.js +3233 -0
  8. package/dist/index.js +9014 -0
  9. package/package.json +77 -0
  10. package/src/cache/index.ts +795 -0
  11. package/src/cli/ARCHITECTURE.md +837 -0
  12. package/src/cli/bin.ts +10 -0
  13. package/src/cli/commands/build.ts +425 -0
  14. package/src/cli/commands/dev.ts +248 -0
  15. package/src/cli/commands/generate.ts +541 -0
  16. package/src/cli/commands/help.ts +55 -0
  17. package/src/cli/commands/index.ts +112 -0
  18. package/src/cli/commands/migration.ts +355 -0
  19. package/src/cli/commands/new.ts +804 -0
  20. package/src/cli/commands/start.ts +208 -0
  21. package/src/cli/core/args.ts +283 -0
  22. package/src/cli/core/console.ts +349 -0
  23. package/src/cli/core/index.ts +60 -0
  24. package/src/cli/core/prompt.ts +424 -0
  25. package/src/cli/core/spinner.ts +265 -0
  26. package/src/cli/index.ts +135 -0
  27. package/src/cli/templates/deploy.ts +295 -0
  28. package/src/cli/templates/docker.ts +307 -0
  29. package/src/cli/templates/index.ts +24 -0
  30. package/src/cli/utils/fs.ts +428 -0
  31. package/src/cli/utils/index.ts +8 -0
  32. package/src/cli/utils/strings.ts +197 -0
  33. package/src/config/env.ts +408 -0
  34. package/src/config/index.ts +506 -0
  35. package/src/config/loader.ts +329 -0
  36. package/src/config/merge.ts +285 -0
  37. package/src/config/types.ts +320 -0
  38. package/src/config/validation.ts +441 -0
  39. package/src/container/forward-ref.ts +143 -0
  40. package/src/container/index.ts +386 -0
  41. package/src/context/index.ts +360 -0
  42. package/src/database/index.ts +1142 -0
  43. package/src/database/migrations/index.ts +371 -0
  44. package/src/database/schema/index.ts +619 -0
  45. package/src/frontend/api-routes.ts +640 -0
  46. package/src/frontend/bundler.ts +643 -0
  47. package/src/frontend/console-client.ts +419 -0
  48. package/src/frontend/console-stream.ts +587 -0
  49. package/src/frontend/dev-server.ts +846 -0
  50. package/src/frontend/file-router.ts +611 -0
  51. package/src/frontend/frameworks/index.ts +106 -0
  52. package/src/frontend/frameworks/react.ts +85 -0
  53. package/src/frontend/frameworks/solid.ts +104 -0
  54. package/src/frontend/frameworks/svelte.ts +110 -0
  55. package/src/frontend/frameworks/vue.ts +92 -0
  56. package/src/frontend/hmr-client.ts +663 -0
  57. package/src/frontend/hmr.ts +728 -0
  58. package/src/frontend/index.ts +342 -0
  59. package/src/frontend/islands.ts +552 -0
  60. package/src/frontend/isr.ts +555 -0
  61. package/src/frontend/layout.ts +475 -0
  62. package/src/frontend/ssr/react.ts +446 -0
  63. package/src/frontend/ssr/solid.ts +523 -0
  64. package/src/frontend/ssr/svelte.ts +546 -0
  65. package/src/frontend/ssr/vue.ts +504 -0
  66. package/src/frontend/ssr.ts +699 -0
  67. package/src/frontend/types.ts +2274 -0
  68. package/src/health/index.ts +604 -0
  69. package/src/index.ts +410 -0
  70. package/src/lock/index.ts +587 -0
  71. package/src/logger/index.ts +444 -0
  72. package/src/logger/transports/index.ts +969 -0
  73. package/src/metrics/index.ts +494 -0
  74. package/src/middleware/built-in.ts +360 -0
  75. package/src/middleware/index.ts +94 -0
  76. package/src/modules/filters.ts +458 -0
  77. package/src/modules/guards.ts +405 -0
  78. package/src/modules/index.ts +1256 -0
  79. package/src/modules/interceptors.ts +574 -0
  80. package/src/modules/lazy.ts +418 -0
  81. package/src/modules/lifecycle.ts +478 -0
  82. package/src/modules/metadata.ts +90 -0
  83. package/src/modules/pipes.ts +626 -0
  84. package/src/router/index.ts +339 -0
  85. package/src/router/linear.ts +371 -0
  86. package/src/router/regex.ts +292 -0
  87. package/src/router/tree.ts +562 -0
  88. package/src/rpc/index.ts +1263 -0
  89. package/src/security/index.ts +436 -0
  90. package/src/ssg/index.ts +631 -0
  91. package/src/storage/index.ts +456 -0
  92. package/src/telemetry/index.ts +1097 -0
  93. package/src/testing/index.ts +1586 -0
  94. package/src/types/index.ts +236 -0
  95. package/src/types/optional-deps.d.ts +219 -0
  96. package/src/validation/index.ts +276 -0
  97. package/src/websocket/index.ts +1004 -0
  98. package/tests/integration/cli.test.ts +1016 -0
  99. package/tests/integration/fullstack.test.ts +234 -0
  100. package/tests/unit/cache.test.ts +174 -0
  101. package/tests/unit/cli-commands.test.ts +892 -0
  102. package/tests/unit/cli.test.ts +1258 -0
  103. package/tests/unit/container.test.ts +279 -0
  104. package/tests/unit/context.test.ts +221 -0
  105. package/tests/unit/database.test.ts +183 -0
  106. package/tests/unit/linear-router.test.ts +280 -0
  107. package/tests/unit/lock.test.ts +336 -0
  108. package/tests/unit/middleware.test.ts +184 -0
  109. package/tests/unit/modules.test.ts +142 -0
  110. package/tests/unit/pubsub.test.ts +257 -0
  111. package/tests/unit/regex-router.test.ts +265 -0
  112. package/tests/unit/router.test.ts +373 -0
  113. package/tests/unit/rpc.test.ts +1248 -0
  114. package/tests/unit/security.test.ts +174 -0
  115. package/tests/unit/telemetry.test.ts +371 -0
  116. package/tests/unit/test-cache.test.ts +110 -0
  117. package/tests/unit/test-database.test.ts +282 -0
  118. package/tests/unit/tree-router.test.ts +325 -0
  119. package/tests/unit/validation.test.ts +794 -0
  120. 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
+ }