@donkeylabs/server 2.0.16 → 2.0.18

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/CLAUDE.md CHANGED
@@ -139,11 +139,31 @@ throw ctx.errors.BadRequest("Invalid input"); // 400
139
139
  throw ctx.errors.Unauthorized("Login required"); // 401
140
140
  ```
141
141
 
142
+ ## Testing
143
+ ```ts
144
+ // Unit test plugins (no HTTP)
145
+ import { createTestHarness } from "@donkeylabs/server";
146
+ const { manager } = await createTestHarness(myPlugin);
147
+
148
+ // Integration test with HTTP (parallel-safe, unique ports)
149
+ import { createIntegrationHarness } from "@donkeylabs/server";
150
+ import { createApiClient } from "../lib/api";
151
+
152
+ const harness = await createIntegrationHarness({
153
+ routers: [usersRouter],
154
+ plugins: [usersPlugin],
155
+ });
156
+ const api = harness.createClient(createApiClient); // Fully typed!
157
+ await api.users.create({ name: "Test" });
158
+ await harness.shutdown();
159
+ ```
160
+
142
161
  ## Commands
143
162
  ```sh
144
163
  bun run dev # Dev server
145
164
  bunx donkeylabs generate # Regen types after changes
146
165
  bun --bun tsc --noEmit # Type check
166
+ bun test # Run tests
147
167
  ```
148
168
 
149
169
  ## MCP Tools
@@ -154,4 +174,5 @@ bun --bun tsc --noEmit # Type check
154
174
  - Core: logger, cache, events, cron, jobs, external-jobs, processes, workflows, sse, rate-limiter, errors
155
175
  - API: router, handlers, middleware
156
176
  - Server: lifecycle-hooks, services (custom services)
157
- - Infrastructure: database, plugins, testing, sveltekit-adapter, api-client
177
+ - Testing: `createTestHarness` (unit), `createIntegrationHarness` (HTTP)
178
+ - Infrastructure: database, plugins, sveltekit-adapter, api-client
package/docs/testing.md CHANGED
@@ -1,25 +1,61 @@
1
1
  # Testing
2
2
 
3
- This guide covers testing plugins and routes using the built-in test harness.
3
+ This guide covers testing plugins and routes using the built-in test harnesses.
4
4
 
5
5
  ## Table of Contents
6
6
 
7
- - [Test Harness](#test-harness)
7
+ - [Quick Start](#quick-start)
8
+ - [Test Harnesses](#test-harnesses)
9
+ - [createTestHarness](#createtestharness) - Unit testing plugins
10
+ - [createIntegrationHarness](#createintegrationharness) - Full HTTP API testing
8
11
  - [Unit Testing Plugins](#unit-testing-plugins)
9
- - [Integration Testing](#integration-testing)
12
+ - [Integration Testing with HTTP](#integration-testing-with-http)
10
13
  - [Testing Routes](#testing-routes)
14
+ - [Parallel Test Execution](#parallel-test-execution)
11
15
  - [Mocking Core Services](#mocking-core-services)
12
16
  - [Test Organization](#test-organization)
13
17
  - [Running Tests](#running-tests)
14
18
 
15
19
  ---
16
20
 
17
- ## Test Harness
21
+ ## Quick Start
18
22
 
19
- The test harness creates a fully functional in-memory testing environment with real SQLite, migrations, and all core services.
23
+ ```ts
24
+ // Unit test (no HTTP, fast)
25
+ import { createTestHarness } from "@donkeylabs/server";
26
+ const { manager } = await createTestHarness(myPlugin);
27
+ const result = await manager.getServices().myPlugin.doSomething();
28
+
29
+ // Integration test (full HTTP server)
30
+ import { createIntegrationHarness } from "@donkeylabs/server";
31
+ import { createApiClient } from "../lib/api"; // Your generated client
32
+
33
+ const harness = await createIntegrationHarness({
34
+ routers: [myRouter],
35
+ plugins: [myPlugin],
36
+ });
37
+ const api = harness.createClient(createApiClient);
38
+ const user = await api.users.create({ name: "Test" }); // Fully typed!
39
+ await harness.shutdown();
40
+ ```
41
+
42
+ ---
43
+
44
+ ## Test Harnesses
45
+
46
+ DonkeyLabs provides two test harnesses for different testing needs:
47
+
48
+ | Harness | Use Case | HTTP Server | Speed |
49
+ |---------|----------|-------------|-------|
50
+ | `createTestHarness` | Unit testing plugins | No | Fast |
51
+ | `createIntegrationHarness` | Full API testing | Yes | Medium |
52
+
53
+ ### createTestHarness
54
+
55
+ For unit testing plugin services without HTTP overhead. Creates an in-memory environment with SQLite, migrations, and all core services.
20
56
 
21
57
  ```ts
22
- import { createTestHarness } from "@donkeylabs/server/harness";
58
+ import { createTestHarness } from "@donkeylabs/server";
23
59
  import { myPlugin } from "./plugins/myPlugin";
24
60
 
25
61
  const { manager, db, core } = await createTestHarness(myPlugin);
@@ -35,12 +71,12 @@ core.logger.info("Test log");
35
71
  core.cache.set("key", "value");
36
72
  ```
37
73
 
38
- ### With Dependencies
74
+ #### With Dependencies
39
75
 
40
76
  If your plugin depends on other plugins, pass them as the second argument:
41
77
 
42
78
  ```ts
43
- import { createTestHarness } from "@donkeylabs/server/harness";
79
+ import { createTestHarness } from "@donkeylabs/server";
44
80
  import { ordersPlugin } from "./plugins/orders";
45
81
  import { usersPlugin } from "./plugins/users";
46
82
 
@@ -51,6 +87,65 @@ const orders = manager.getServices().orders;
51
87
  const users = manager.getServices().users;
52
88
  ```
53
89
 
90
+ ### createIntegrationHarness
91
+
92
+ For full HTTP API testing with your generated client. Starts a real HTTP server on a unique port for parallel test execution.
93
+
94
+ ```ts
95
+ import { createIntegrationHarness, type IntegrationHarnessResult } from "@donkeylabs/server";
96
+ import { createApiClient } from "../lib/api"; // Your generated client
97
+ import { usersRouter } from "../server/routes/users";
98
+ import { usersPlugin } from "../server/plugins/users";
99
+
100
+ describe("Users API", () => {
101
+ let harness: IntegrationHarnessResult;
102
+ let api: ReturnType<typeof createApiClient>;
103
+
104
+ beforeAll(async () => {
105
+ harness = await createIntegrationHarness({
106
+ routers: [usersRouter],
107
+ plugins: [usersPlugin],
108
+ });
109
+ api = harness.createClient(createApiClient);
110
+ });
111
+
112
+ afterAll(async () => {
113
+ await harness.shutdown();
114
+ });
115
+
116
+ it("should create a user", async () => {
117
+ const user = await api.users.create({ name: "Test", email: "test@example.com" });
118
+ expect(user.id).toBeDefined();
119
+ });
120
+ });
121
+ ```
122
+
123
+ #### Harness Result Properties
124
+
125
+ | Property | Type | Description |
126
+ |----------|------|-------------|
127
+ | `server` | `AppServer` | The running server instance |
128
+ | `baseUrl` | `string` | Base URL (e.g., `http://localhost:12345`) |
129
+ | `port` | `number` | Actual port the server is running on |
130
+ | `db` | `Kysely<any>` | Database instance for direct queries |
131
+ | `core` | `CoreServices` | Core services (logger, cache, etc.) |
132
+ | `plugins` | `Record<string, any>` | Plugin services |
133
+ | `client` | `TestApiClient` | Untyped client for quick testing |
134
+ | `createClient` | `<T>(factory) => T` | Create typed client from your factory |
135
+ | `shutdown` | `() => Promise<void>` | Cleanup function |
136
+
137
+ #### Configuration Options
138
+
139
+ ```ts
140
+ interface IntegrationHarnessOptions {
141
+ routers?: IRouter[]; // Routers to register
142
+ plugins?: Plugin[]; // Plugins to register
143
+ port?: number; // Starting port (default: random 10000-60000)
144
+ maxPortAttempts?: number; // Retry attempts (default: 10)
145
+ logLevel?: "debug" | "info" | "warn" | "error"; // Default: "error"
146
+ }
147
+ ```
148
+
54
149
  ---
55
150
 
56
151
  ## Unit Testing Plugins
@@ -138,9 +233,93 @@ describe("usersPlugin", () => {
138
233
 
139
234
  ---
140
235
 
141
- ## Integration Testing
236
+ ## Integration Testing with HTTP
237
+
238
+ Use `createIntegrationHarness` for full end-to-end API testing with your generated client.
239
+
240
+ ### Basic Example
241
+
242
+ ```ts
243
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
244
+ import { createIntegrationHarness, type IntegrationHarnessResult } from "@donkeylabs/server";
245
+ import { createApiClient } from "../lib/api";
246
+ import { usersRouter } from "../server/routes/users";
247
+
248
+ describe("Users API", () => {
249
+ let harness: IntegrationHarnessResult;
250
+ let api: ReturnType<typeof createApiClient>;
251
+
252
+ beforeAll(async () => {
253
+ harness = await createIntegrationHarness({
254
+ routers: [usersRouter],
255
+ });
256
+ api = harness.createClient(createApiClient);
257
+ });
258
+
259
+ afterAll(async () => {
260
+ await harness.shutdown();
261
+ });
262
+
263
+ it("should create and retrieve a user", async () => {
264
+ // Create
265
+ const created = await api.users.create({
266
+ name: "Test User",
267
+ email: "test@example.com",
268
+ });
269
+ expect(created.id).toBeDefined();
270
+
271
+ // Retrieve
272
+ const user = await api.users.get({ id: created.id });
273
+ expect(user.name).toBe("Test User");
274
+ });
275
+
276
+ it("should list users", async () => {
277
+ const result = await api.users.list({});
278
+ expect(result.users.length).toBeGreaterThan(0);
279
+ });
280
+ });
281
+ ```
282
+
283
+ ### With Plugins
284
+
285
+ ```ts
286
+ import { usersPlugin } from "../server/plugins/users";
287
+ import { ordersPlugin } from "../server/plugins/orders";
288
+ import { ordersRouter } from "../server/routes/orders";
289
+
290
+ const harness = await createIntegrationHarness({
291
+ routers: [ordersRouter],
292
+ plugins: [usersPlugin, ordersPlugin],
293
+ });
294
+ ```
295
+
296
+ ### Accessing Services Directly
297
+
298
+ You can access plugin services and core services for test setup:
299
+
300
+ ```ts
301
+ it("should process order for existing user", async () => {
302
+ // Use plugin service directly to create test data
303
+ const user = await harness.plugins.users.create({
304
+ email: "buyer@example.com",
305
+ name: "Buyer",
306
+ });
307
+
308
+ // Test via API
309
+ const order = await api.orders.create({
310
+ userId: user.id,
311
+ items: [{ productId: 1, quantity: 2 }],
312
+ });
313
+
314
+ expect(order.status).toBe("pending");
315
+ });
316
+ ```
317
+
318
+ ---
319
+
320
+ ## Integration Testing (Plugin-Level)
142
321
 
143
- Integration tests verify multiple plugins working together.
322
+ Integration tests verify multiple plugins working together without HTTP.
144
323
 
145
324
  ```ts
146
325
  // tests/checkout.integ.test.ts
@@ -419,12 +598,83 @@ jobs:
419
598
 
420
599
  ---
421
600
 
601
+ ## Parallel Test Execution
602
+
603
+ `createIntegrationHarness` is designed for parallel test execution:
604
+
605
+ 1. **Unique ports** - Each harness gets a random port (10000-60000 range)
606
+ 2. **Automatic retry** - If port is in use, tries next port (up to 10 attempts)
607
+ 3. **In-memory isolation** - Each test has its own SQLite `:memory:` database
608
+ 4. **No file conflicts** - Type generation is disabled during tests
609
+
610
+ ### Example: Parallel Test Suites
611
+
612
+ ```ts
613
+ // users.test.ts
614
+ describe("Users API", () => {
615
+ let harness: IntegrationHarnessResult;
616
+
617
+ beforeAll(async () => {
618
+ harness = await createIntegrationHarness({ routers: [usersRouter] });
619
+ });
620
+ afterAll(() => harness.shutdown());
621
+
622
+ it("creates users", async () => {
623
+ // This test runs on port 12345 (random)
624
+ });
625
+ });
626
+
627
+ // orders.test.ts (runs in parallel)
628
+ describe("Orders API", () => {
629
+ let harness: IntegrationHarnessResult;
630
+
631
+ beforeAll(async () => {
632
+ harness = await createIntegrationHarness({ routers: [ordersRouter] });
633
+ });
634
+ afterAll(() => harness.shutdown());
635
+
636
+ it("creates orders", async () => {
637
+ // This test runs on port 54321 (different random port)
638
+ });
639
+ });
640
+ ```
641
+
642
+ Both test files run simultaneously without conflicts.
643
+
644
+ ### Why Use the Generated Client?
645
+
646
+ The generated client (`lib/api.ts`) provides:
647
+ - Full TypeScript types for all routes
648
+ - IDE autocomplete while writing tests
649
+ - Compile-time error checking
650
+
651
+ The client is generated once during development (`bunx donkeylabs generate`) and imported in tests. No regeneration happens at test runtime.
652
+
653
+ ---
654
+
422
655
  ## Best Practices
423
656
 
424
- 1. **Use fresh harness per test** - Create a new harness in `beforeEach` to ensure test isolation
425
- 2. **Test the public API** - Focus on testing service methods, not internal implementation
426
- 3. **Use realistic data** - Create test data that resembles production data
427
- 4. **Test edge cases** - Empty inputs, null values, boundary conditions
428
- 5. **Test error cases** - Verify proper error throwing and handling
429
- 6. **Keep tests fast** - In-memory SQLite is fast; avoid unnecessary delays
430
- 7. **Run type checks** - Always run `tsc --noEmit` before committing
657
+ 1. **Choose the right harness**:
658
+ - `createTestHarness` for unit tests (fast, no HTTP)
659
+ - `createIntegrationHarness` for API tests (full HTTP, typed client)
660
+
661
+ 2. **Use fresh harness per test suite** - Create harness in `beforeAll`, shutdown in `afterAll`
662
+
663
+ 3. **Always call shutdown** - Prevent port leaks and hanging tests:
664
+ ```ts
665
+ afterAll(() => harness.shutdown());
666
+ ```
667
+
668
+ 4. **Use your generated client** - Import from `lib/api.ts` for full type safety
669
+
670
+ 5. **Test the public API** - Focus on testing service methods, not internal implementation
671
+
672
+ 6. **Use realistic data** - Create test data that resembles production data
673
+
674
+ 7. **Test edge cases** - Empty inputs, null values, boundary conditions
675
+
676
+ 8. **Test error cases** - Verify proper error throwing and handling
677
+
678
+ 9. **Keep tests fast** - In-memory SQLite is fast; avoid unnecessary delays
679
+
680
+ 10. **Run type checks** - Always run `tsc --noEmit` before committing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "2.0.16",
3
+ "version": "2.0.18",
4
4
  "type": "module",
5
5
  "description": "Type-safe plugin system for building RPC-style APIs with Bun",
6
6
  "main": "./src/index.ts",
@@ -300,6 +300,92 @@ export function groupRoutesByPrefix(routes: RouteInfo[]): Map<string, RouteInfo[
300
300
  return groups;
301
301
  }
302
302
 
303
+ /**
304
+ * Represents a node in the namespace tree
305
+ */
306
+ interface NamespaceNode {
307
+ methods: string[]; // Method definitions at this level
308
+ children: Map<string, NamespaceNode>;
309
+ }
310
+
311
+ /**
312
+ * Build a tree structure from route groups to handle multi-level prefixes
313
+ * e.g., "api.cache" and "api.counter" become:
314
+ * api -> { cache -> methods, counter -> methods }
315
+ */
316
+ export function buildNamespaceTree(
317
+ routeGroups: Map<string, RouteInfo[]>,
318
+ generateMethod: (route: RouteInfo) => string | null
319
+ ): Map<string, NamespaceNode> {
320
+ const tree = new Map<string, NamespaceNode>();
321
+
322
+ for (const [prefix, routes] of routeGroups) {
323
+ const methods = routes
324
+ .map(generateMethod)
325
+ .filter((m): m is string => m !== null);
326
+
327
+ if (methods.length === 0) continue;
328
+
329
+ if (prefix === "_root") {
330
+ // Root level methods go directly
331
+ if (!tree.has("_root")) {
332
+ tree.set("_root", { methods: [], children: new Map() });
333
+ }
334
+ tree.get("_root")!.methods.push(...methods);
335
+ continue;
336
+ }
337
+
338
+ // Split prefix into parts for nested namespaces
339
+ const parts = prefix.split(".");
340
+ let current = tree;
341
+
342
+ for (let i = 0; i < parts.length; i++) {
343
+ const part = parts[i]!;
344
+ if (!current.has(part)) {
345
+ current.set(part, { methods: [], children: new Map() });
346
+ }
347
+
348
+ if (i === parts.length - 1) {
349
+ // Last part - add methods here
350
+ current.get(part)!.methods.push(...methods);
351
+ } else {
352
+ // Intermediate part - continue traversing
353
+ current = current.get(part)!.children;
354
+ }
355
+ }
356
+ }
357
+
358
+ return tree;
359
+ }
360
+
361
+ /**
362
+ * Generate nested namespace code from a tree node
363
+ */
364
+ export function generateNestedNamespaceCode(
365
+ node: NamespaceNode,
366
+ indent: string = " "
367
+ ): string {
368
+ const parts: string[] = [];
369
+
370
+ // Add methods at this level with proper indentation
371
+ for (const method of node.methods) {
372
+ // Indent each line of the method
373
+ const indentedMethod = method
374
+ .split("\n")
375
+ .map((line, i) => (i === 0 ? `${indent}${line}` : `${indent} ${line}`))
376
+ .join("\n");
377
+ parts.push(indentedMethod);
378
+ }
379
+
380
+ // Add nested namespaces
381
+ for (const [childName, childNode] of node.children) {
382
+ const childContent = generateNestedNamespaceCode(childNode, indent + " ");
383
+ parts.push(`${indent}${childName}: {\n${childContent}\n${indent}}`);
384
+ }
385
+
386
+ return parts.join(",\n\n");
387
+ }
388
+
303
389
  // ==========================================
304
390
  // Client Code Generation
305
391
  // ==========================================
@@ -504,59 +590,60 @@ ${typeEntries.join("\n\n")}
504
590
  }`);
505
591
  }
506
592
 
507
- const methodEntries = prefixRoutes
508
- .filter((r) => r.handler === "typed")
509
- .map((r) => {
510
- const inputType = `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Input`;
511
- const outputType = `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Output`;
512
- return ` ${toCamelCase(r.routeName)}: (input: ${inputType}, options?: RequestOptions): Promise<${outputType}> =>
513
- this.request("${r.name}", input, options)`;
514
- });
515
-
516
- const rawMethodEntries = prefixRoutes
517
- .filter((r) => r.handler === "raw")
518
- .map((r) => {
519
- return ` ${toCamelCase(r.routeName)}: (init?: RequestInit): Promise<Response> =>
520
- this.rawRequest("${r.name}", init)`;
521
- });
593
+ }
522
594
 
523
- const sseMethodEntries = prefixRoutes
524
- .filter((r) => r.handler === "sse")
525
- .map((r) => {
526
- const inputType = r.inputSource
527
- ? `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Input`
528
- : "Record<string, any>";
529
- const eventsType = `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Events`;
530
- return ` ${toCamelCase(r.routeName)}: (input: ${inputType}, options?: Omit<SSEOptions, "endpoint" | "channels">): SSESubscription<${eventsType}> =>
531
- this.connectToSSERoute("${r.name}", input, options)`;
532
- });
595
+ // Build namespace tree for proper nesting (handles multi-level prefixes like "api.cache")
596
+ const generateMethodForRoute = (r: RouteInfo): string | null => {
597
+ const namespaceName = r.prefix === "_root" ? "Root" : toPascalCase(r.prefix);
533
598
 
534
- const streamMethodEntries = prefixRoutes
535
- .filter((r) => r.handler === "stream" || r.handler === "html")
536
- .map((r) => {
537
- const inputType = r.inputSource
538
- ? `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Input`
539
- : "Record<string, any>";
540
- return ` ${toCamelCase(r.routeName)}: (input: ${inputType}): Promise<Response> =>
541
- this.streamRequest("${r.name}", input)`;
542
- });
543
-
544
- const formDataMethodEntries = prefixRoutes
545
- .filter((r) => r.handler === "formData")
546
- .map((r) => {
547
- const inputType = `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Input`;
548
- const outputType = `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Output`;
549
- return ` ${toCamelCase(r.routeName)}: (fields: ${inputType}, files?: File[]): Promise<${outputType}> =>
550
- this.uploadFormData("${r.name}", fields, files)`;
551
- });
599
+ if (r.handler === "typed") {
600
+ const inputType = `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Input`;
601
+ const outputType = `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Output`;
602
+ return `${toCamelCase(r.routeName)}: (input: ${inputType}, options?: RequestOptions): Promise<${outputType}> =>
603
+ this.request("${r.name}", input, options)`;
604
+ }
605
+ if (r.handler === "raw") {
606
+ return `${toCamelCase(r.routeName)}: (init?: RequestInit): Promise<Response> =>
607
+ this.rawRequest("${r.name}", init)`;
608
+ }
609
+ if (r.handler === "sse") {
610
+ const inputType = r.inputSource
611
+ ? `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Input`
612
+ : "Record<string, any>";
613
+ const eventsType = `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Events`;
614
+ return `${toCamelCase(r.routeName)}: (input: ${inputType}, options?: Omit<SSEOptions, "endpoint" | "channels">): SSESubscription<${eventsType}> =>
615
+ this.connectToSSERoute("${r.name}", input, options)`;
616
+ }
617
+ if (r.handler === "stream" || r.handler === "html") {
618
+ const inputType = r.inputSource
619
+ ? `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Input`
620
+ : "Record<string, any>";
621
+ return `${toCamelCase(r.routeName)}: (input: ${inputType}): Promise<Response> =>
622
+ this.streamRequest("${r.name}", input)`;
623
+ }
624
+ if (r.handler === "formData") {
625
+ const inputType = `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Input`;
626
+ const outputType = `Routes.${namespaceName}.${toPascalCase(r.routeName)}.Output`;
627
+ return `${toCamelCase(r.routeName)}: (fields: ${inputType}, files?: File[]): Promise<${outputType}> =>
628
+ this.uploadFormData("${r.name}", fields, files)`;
629
+ }
630
+ return null;
631
+ };
552
632
 
553
- const allMethods = [...methodEntries, ...rawMethodEntries, ...sseMethodEntries, ...streamMethodEntries, ...formDataMethodEntries];
633
+ const namespaceTree = buildNamespaceTree(routeGroups, generateMethodForRoute);
554
634
 
555
- if (allMethods.length > 0) {
556
- routeNamespaceBlocks.push(` ${methodName} = {
557
- ${allMethods.join(",\n\n")}
558
- };`);
635
+ // Generate namespace blocks from tree
636
+ for (const [topLevel, node] of namespaceTree) {
637
+ if (topLevel === "_root") {
638
+ // Root level methods become direct class properties
639
+ for (const method of node.methods) {
640
+ routeNamespaceBlocks.push(` ${method.trim().replace(/^\s+/gm, " ")};`);
641
+ }
642
+ continue;
559
643
  }
644
+
645
+ const content = generateNestedNamespaceCode(node, " ");
646
+ routeNamespaceBlocks.push(` ${topLevel} = {\n${content}\n };`);
560
647
  }
561
648
 
562
649
  // Generate event types
package/src/harness.ts CHANGED
@@ -20,6 +20,9 @@ import {
20
20
  KyselyWorkflowAdapter,
21
21
  MemoryAuditAdapter,
22
22
  } from "./core/index";
23
+ import { AppServer, type ServerConfig } from "./server";
24
+ import type { IRouter, RouteDefinition } from "./router";
25
+ import { ApiClientBase, type ApiClientOptions } from "./client/base";
23
26
 
24
27
  /**
25
28
  * Creates a fully functional (in-memory) testing environment for a plugin.
@@ -104,3 +107,277 @@ export async function createTestHarness(targetPlugin: Plugin, dependencies: Plug
104
107
  core
105
108
  };
106
109
  }
110
+
111
+ // =============================================================================
112
+ // INTEGRATION TEST HARNESS
113
+ // =============================================================================
114
+
115
+ /**
116
+ * Configuration for the integration test harness.
117
+ */
118
+ export interface IntegrationHarnessOptions {
119
+ /** Routers to register with the server */
120
+ routers?: IRouter[];
121
+ /** Plugins to register with the server */
122
+ plugins?: Plugin[];
123
+ /** Starting port range (default: 10000-60000 random) */
124
+ port?: number;
125
+ /** Maximum port retry attempts (default: 10) */
126
+ maxPortAttempts?: number;
127
+ /** Logger level (default: "error" for quiet tests) */
128
+ logLevel?: "debug" | "info" | "warn" | "error";
129
+ }
130
+
131
+ /**
132
+ * A dynamic API client built from router definitions.
133
+ * Provides an untyped but convenient way to call routes.
134
+ */
135
+ export class TestApiClient extends ApiClientBase {
136
+ private routeMap: Map<string, RouteDefinition>;
137
+
138
+ constructor(baseUrl: string, routers: IRouter[]) {
139
+ super(baseUrl);
140
+ this.routeMap = new Map();
141
+ for (const router of routers) {
142
+ for (const route of router.getRoutes()) {
143
+ this.routeMap.set(route.name, route);
144
+ }
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Call any route by name with the given input.
150
+ * Convenient for quick testing without generated types.
151
+ *
152
+ * @example
153
+ * ```ts
154
+ * const user = await client.call("users.create", { name: "Test", email: "test@example.com" });
155
+ * ```
156
+ */
157
+ async call<TOutput = any>(route: string, input: any = {}): Promise<TOutput> {
158
+ const routeDef = this.routeMap.get(route);
159
+ if (!routeDef) {
160
+ throw new Error(`Route not found: ${route}. Available routes: ${[...this.routeMap.keys()].join(", ")}`);
161
+ }
162
+
163
+ // Handle different handler types
164
+ if (routeDef.handler === "typed" || routeDef.handler === "formData") {
165
+ return this.request(route, input);
166
+ } else if (routeDef.handler === "stream" || routeDef.handler === "html") {
167
+ const response = await this.rawRequest(route, {
168
+ method: "POST",
169
+ headers: { "Content-Type": "application/json" },
170
+ body: JSON.stringify(input),
171
+ });
172
+ return response as any;
173
+ } else if (routeDef.handler === "raw") {
174
+ const response = await this.rawRequest(route);
175
+ return response as any;
176
+ }
177
+
178
+ return this.request(route, input);
179
+ }
180
+
181
+ /**
182
+ * Get a raw Response for stream/file routes.
183
+ */
184
+ async stream(route: string, input: any = {}): Promise<Response> {
185
+ return this.rawRequest(route, {
186
+ method: "POST",
187
+ headers: { "Content-Type": "application/json" },
188
+ body: JSON.stringify(input),
189
+ });
190
+ }
191
+
192
+ /**
193
+ * List all available routes.
194
+ */
195
+ routes(): string[] {
196
+ return [...this.routeMap.keys()];
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Result from createIntegrationHarness.
202
+ */
203
+ export interface IntegrationHarnessResult {
204
+ /** The running AppServer instance */
205
+ server: AppServer;
206
+ /** Base URL for API calls (e.g., "http://localhost:12345") */
207
+ baseUrl: string;
208
+ /** The actual port the server is running on */
209
+ port: number;
210
+ /** Database instance */
211
+ db: Kysely<any>;
212
+ /** Core services */
213
+ core: CoreServices;
214
+ /** Plugin services (after initialization) */
215
+ plugins: Record<string, any>;
216
+ /**
217
+ * Untyped test client for quick testing.
218
+ * Use `client.call("route.name", input)` to call any route.
219
+ *
220
+ * @example
221
+ * ```ts
222
+ * const user = await harness.client.call("users.create", { name: "Test" });
223
+ * ```
224
+ */
225
+ client: TestApiClient;
226
+ /**
227
+ * Create a typed client using your generated client factory.
228
+ * Pass your `createApiClient` function to get full type safety.
229
+ *
230
+ * @example
231
+ * ```ts
232
+ * import { createApiClient } from "../lib/api";
233
+ * const api = harness.createClient(createApiClient);
234
+ * const user = await api.users.create({ name: "Test" }); // Fully typed!
235
+ * ```
236
+ */
237
+ createClient: <T>(factory: (config: { baseUrl: string }) => T) => T;
238
+ /** Shutdown function - call this in afterAll/afterEach */
239
+ shutdown: () => Promise<void>;
240
+ }
241
+
242
+ // Track used ports across test files running in parallel
243
+ const usedPorts = new Set<number>();
244
+
245
+ /**
246
+ * Get a unique starting port for parallel test execution.
247
+ * Uses random port in range 10000-60000 and tracks to avoid collisions.
248
+ */
249
+ function getUniquePort(): number {
250
+ const minPort = 10000;
251
+ const maxPort = 60000;
252
+ let port: number;
253
+ let attempts = 0;
254
+ const maxAttempts = 100;
255
+
256
+ do {
257
+ port = minPort + Math.floor(Math.random() * (maxPort - minPort));
258
+ attempts++;
259
+ } while (usedPorts.has(port) && attempts < maxAttempts);
260
+
261
+ usedPorts.add(port);
262
+ return port;
263
+ }
264
+
265
+ /**
266
+ * Creates a full integration testing environment with a real HTTP server.
267
+ *
268
+ * Use this when you need to test API routes end-to-end with the generated
269
+ * API client. The server runs in-memory (SQLite :memory:) and uses a random
270
+ * port for parallel test execution.
271
+ *
272
+ * @example
273
+ * ```ts
274
+ * import { describe, it, expect, beforeAll, afterAll } from "bun:test";
275
+ * import { createIntegrationHarness } from "@donkeylabs/server";
276
+ * import { createApiClient } from "../lib/api"; // Your generated client
277
+ * import { usersRouter } from "../server/routes/users";
278
+ * import { usersPlugin } from "../server/plugins/users";
279
+ *
280
+ * describe("Users API", () => {
281
+ * let harness: Awaited<ReturnType<typeof createIntegrationHarness>>;
282
+ * let api: ReturnType<typeof createApiClient>;
283
+ *
284
+ * beforeAll(async () => {
285
+ * harness = await createIntegrationHarness({
286
+ * routers: [usersRouter],
287
+ * plugins: [usersPlugin],
288
+ * });
289
+ * api = createApiClient({ baseUrl: harness.baseUrl });
290
+ * });
291
+ *
292
+ * afterAll(async () => {
293
+ * await harness.shutdown();
294
+ * });
295
+ *
296
+ * it("should create a user", async () => {
297
+ * const user = await api.users.create({ name: "Test", email: "test@example.com" });
298
+ * expect(user.id).toBeDefined();
299
+ * });
300
+ *
301
+ * it("should list users", async () => {
302
+ * const result = await api.users.list({});
303
+ * expect(result.users.length).toBeGreaterThan(0);
304
+ * });
305
+ * });
306
+ * ```
307
+ */
308
+ export async function createIntegrationHarness(
309
+ options: IntegrationHarnessOptions = {}
310
+ ): Promise<IntegrationHarnessResult> {
311
+ const {
312
+ routers = [],
313
+ plugins = [],
314
+ port = getUniquePort(),
315
+ maxPortAttempts = 10,
316
+ logLevel = "error",
317
+ } = options;
318
+
319
+ // 1. Setup In-Memory DB
320
+ const db = new Kysely<any>({
321
+ dialect: new BunSqliteDialect({ database: new Database(":memory:") }),
322
+ });
323
+
324
+ // 2. Create server with test configuration
325
+ const server = new AppServer({
326
+ db,
327
+ port,
328
+ maxPortAttempts,
329
+ logger: { level: logLevel },
330
+ // Disable file generation in tests
331
+ generateTypes: undefined,
332
+ });
333
+
334
+ // 3. Register plugins
335
+ for (const plugin of plugins) {
336
+ server.registerPlugin(plugin);
337
+ }
338
+
339
+ // 4. Register routers
340
+ for (const router of routers) {
341
+ server.use(router);
342
+ }
343
+
344
+ // 5. Start the server
345
+ await server.start();
346
+
347
+ // Get the actual port (may have changed if initial was in use)
348
+ const actualPort = (server as any).port as number;
349
+ const baseUrl = `http://localhost:${actualPort}`;
350
+
351
+ // Track this port as used
352
+ usedPorts.add(actualPort);
353
+
354
+ // Get core services and plugins for direct access in tests
355
+ const core = (server as any).coreServices as CoreServices;
356
+ const pluginServices = (server as any).manager.getServices();
357
+
358
+ // Create untyped test client
359
+ const client = new TestApiClient(baseUrl, routers);
360
+
361
+ // Factory for typed clients
362
+ const createClient = <T>(factory: (config: { baseUrl: string }) => T): T => {
363
+ return factory({ baseUrl });
364
+ };
365
+
366
+ // Shutdown function
367
+ const shutdown = async () => {
368
+ await server.shutdown();
369
+ usedPorts.delete(actualPort);
370
+ };
371
+
372
+ return {
373
+ server,
374
+ baseUrl,
375
+ port: actualPort,
376
+ db,
377
+ core,
378
+ plugins: pluginServices,
379
+ client,
380
+ createClient,
381
+ shutdown,
382
+ };
383
+ }
package/src/index.ts CHANGED
@@ -121,5 +121,11 @@ export {
121
121
  createAdminRouter,
122
122
  } from "./admin";
123
123
 
124
- // Test Harness - for plugin testing
125
- export { createTestHarness } from "./harness";
124
+ // Test Harness - for plugin and integration testing
125
+ export {
126
+ createTestHarness,
127
+ createIntegrationHarness,
128
+ TestApiClient,
129
+ type IntegrationHarnessOptions,
130
+ type IntegrationHarnessResult,
131
+ } from "./harness";