@donkeylabs/server 2.0.17 → 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 +22 -1
- package/docs/testing.md +267 -17
- package/package.json +1 -1
- package/src/harness.ts +277 -0
- package/src/index.ts +8 -2
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
|
-
-
|
|
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
|
|
3
|
+
This guide covers testing plugins and routes using the built-in test harnesses.
|
|
4
4
|
|
|
5
5
|
## Table of Contents
|
|
6
6
|
|
|
7
|
-
- [
|
|
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
|
-
##
|
|
21
|
+
## Quick Start
|
|
18
22
|
|
|
19
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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. **
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
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 {
|
|
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";
|