@donkeylabs/server 2.0.17 → 2.0.19

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.17",
3
+ "version": "2.0.19",
4
4
  "type": "module",
5
5
  "description": "Type-safe plugin system for building RPC-style APIs with Bun",
6
6
  "main": "./src/index.ts",
@@ -30,6 +30,10 @@
30
30
  "types": "./src/process-client.ts",
31
31
  "import": "./src/process-client.ts"
32
32
  },
33
+ "./testing": {
34
+ "types": "./src/testing/index.ts",
35
+ "import": "./src/testing/index.ts"
36
+ },
33
37
  "./context": {
34
38
  "types": "./context.d.ts"
35
39
  },
@@ -68,7 +72,10 @@
68
72
  "kysely": "^0.27.0 || ^0.28.0",
69
73
  "zod": "^3.20.0",
70
74
  "@aws-sdk/client-s3": "^3.0.0",
71
- "@aws-sdk/s3-request-presigner": "^3.0.0"
75
+ "@aws-sdk/s3-request-presigner": "^3.0.0",
76
+ "@playwright/test": "^1.40.0",
77
+ "pg": "^8.0.0",
78
+ "mysql2": "^3.0.0"
72
79
  },
73
80
  "peerDependenciesMeta": {
74
81
  "@aws-sdk/client-s3": {
@@ -76,6 +83,15 @@
76
83
  },
77
84
  "@aws-sdk/s3-request-presigner": {
78
85
  "optional": true
86
+ },
87
+ "@playwright/test": {
88
+ "optional": true
89
+ },
90
+ "pg": {
91
+ "optional": true
92
+ },
93
+ "mysql2": {
94
+ "optional": true
79
95
  }
80
96
  },
81
97
  "dependencies": {
package/src/core/cron.ts CHANGED
@@ -109,21 +109,109 @@ class CronExpression {
109
109
  );
110
110
  }
111
111
 
112
+ /**
113
+ * Get the next run time using an optimized jump algorithm.
114
+ * Instead of iterating second-by-second (which could be 31M iterations),
115
+ * this jumps directly to the next valid value for each field.
116
+ */
112
117
  getNextRun(from: Date = new Date()): Date {
113
118
  const next = new Date(from);
114
119
  next.setMilliseconds(0);
115
120
  next.setSeconds(next.getSeconds() + 1);
116
121
 
117
- // Search up to 1 year ahead
118
- const maxIterations = 366 * 24 * 60 * 60;
119
- for (let i = 0; i < maxIterations; i++) {
120
- if (this.matches(next)) {
121
- return next;
122
+ const [seconds, minutes, hours, daysOfMonth, months, daysOfWeek] = this.fields;
123
+
124
+ // Maximum iterations to prevent infinite loops (covers 4 years to handle leap years)
125
+ const maxYearIterations = 4;
126
+ const startYear = next.getFullYear();
127
+
128
+ // Iterate through potential dates (worst case: a few hundred iterations)
129
+ for (let yearOffset = 0; yearOffset <= maxYearIterations; yearOffset++) {
130
+ // Try each valid month
131
+ for (const month of months) {
132
+ const targetMonth = month - 1; // JS months are 0-indexed
133
+
134
+ // Skip months in the past
135
+ if (next.getFullYear() === startYear + yearOffset) {
136
+ if (targetMonth < next.getMonth()) continue;
137
+ }
138
+
139
+ // Set to this month
140
+ if (targetMonth !== next.getMonth() || next.getFullYear() !== startYear + yearOffset) {
141
+ next.setFullYear(startYear + yearOffset, targetMonth, 1);
142
+ next.setHours(0, 0, 0, 0);
143
+ }
144
+
145
+ // Get days in this month
146
+ const daysInMonth = new Date(next.getFullYear(), targetMonth + 1, 0).getDate();
147
+
148
+ // Try each valid day of month
149
+ for (const dayOfMonth of daysOfMonth) {
150
+ if (dayOfMonth > daysInMonth) continue; // Skip invalid days for this month
151
+
152
+ // Check if this day matches day-of-week constraint
153
+ const testDate = new Date(next.getFullYear(), targetMonth, dayOfMonth);
154
+ const dayOfWeek = testDate.getDay();
155
+ if (!daysOfWeek.includes(dayOfWeek)) continue;
156
+
157
+ // Skip days in the past
158
+ if (testDate < new Date(from.getFullYear(), from.getMonth(), from.getDate())) continue;
159
+
160
+ // Set to this day
161
+ if (dayOfMonth !== next.getDate()) {
162
+ next.setDate(dayOfMonth);
163
+ next.setHours(0, 0, 0, 0);
164
+ }
165
+
166
+ // Try each valid hour
167
+ for (const hour of hours) {
168
+ // Skip hours in the past for today
169
+ if (next.getFullYear() === from.getFullYear() &&
170
+ next.getMonth() === from.getMonth() &&
171
+ next.getDate() === from.getDate() &&
172
+ hour < from.getHours()) continue;
173
+
174
+ if (hour !== next.getHours()) {
175
+ next.setHours(hour, 0, 0, 0);
176
+ }
177
+
178
+ // Try each valid minute
179
+ for (const minute of minutes) {
180
+ // Skip minutes in the past for this hour
181
+ if (next.getFullYear() === from.getFullYear() &&
182
+ next.getMonth() === from.getMonth() &&
183
+ next.getDate() === from.getDate() &&
184
+ next.getHours() === from.getHours() &&
185
+ minute < from.getMinutes()) continue;
186
+
187
+ if (minute !== next.getMinutes()) {
188
+ next.setMinutes(minute, 0, 0);
189
+ }
190
+
191
+ // Try each valid second
192
+ for (const second of seconds) {
193
+ // Skip seconds in the past for this minute
194
+ if (next.getFullYear() === from.getFullYear() &&
195
+ next.getMonth() === from.getMonth() &&
196
+ next.getDate() === from.getDate() &&
197
+ next.getHours() === from.getHours() &&
198
+ next.getMinutes() === from.getMinutes() &&
199
+ second <= from.getSeconds()) continue;
200
+
201
+ next.setSeconds(second);
202
+
203
+ // Verify the date is still valid (handles edge cases like month rollover)
204
+ if (next > from && this.matches(next)) {
205
+ return next;
206
+ }
207
+ }
208
+ }
209
+ }
210
+ }
122
211
  }
123
- next.setSeconds(next.getSeconds() + 1);
124
212
  }
125
213
 
126
- throw new Error("Could not find next run time within 1 year");
214
+ throw new Error("Could not find next run time within 4 years");
127
215
  }
128
216
  }
129
217
 
@@ -195,6 +195,8 @@ export interface WorkflowContext {
195
195
  getStepResult<T = any>(stepName: string): T | undefined;
196
196
  /** Core services (logger, events, cache, etc.) */
197
197
  core: CoreServices;
198
+ /** Plugin services - available for business logic in workflow handlers */
199
+ plugins: Record<string, any>;
198
200
  }
199
201
 
200
202
  // ============================================
@@ -558,6 +560,8 @@ export interface Workflows {
558
560
  stop(): Promise<void>;
559
561
  /** Set core services (called after initialization to resolve circular dependency) */
560
562
  setCore(core: CoreServices): void;
563
+ /** Set plugin services (called after plugins are initialized) */
564
+ setPlugins(plugins: Record<string, any>): void;
561
565
  }
562
566
 
563
567
  // ============================================
@@ -570,6 +574,7 @@ class WorkflowsImpl implements Workflows {
570
574
  private jobs?: Jobs;
571
575
  private sse?: SSE;
572
576
  private core?: CoreServices;
577
+ private plugins: Record<string, any> = {};
573
578
  private definitions = new Map<string, WorkflowDefinition>();
574
579
  private running = new Map<string, { timeout?: ReturnType<typeof setTimeout> }>();
575
580
  private pollInterval: number;
@@ -587,6 +592,10 @@ class WorkflowsImpl implements Workflows {
587
592
  this.core = core;
588
593
  }
589
594
 
595
+ setPlugins(plugins: Record<string, any>): void {
596
+ this.plugins = plugins;
597
+ }
598
+
590
599
  register(definition: WorkflowDefinition): void {
591
600
  if (this.definitions.has(definition.name)) {
592
601
  throw new Error(`Workflow "${definition.name}" is already registered`);
@@ -1150,6 +1159,7 @@ class WorkflowsImpl implements Workflows {
1150
1159
  return steps[stepName] as T | undefined;
1151
1160
  },
1152
1161
  core: this.core!,
1162
+ plugins: this.plugins,
1153
1163
  };
1154
1164
  }
1155
1165
 
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";
package/src/server.ts CHANGED
@@ -205,6 +205,8 @@ export class AppServer {
205
205
  private shutdownHandlers: OnShutdownHandler[] = [];
206
206
  private errorHandlers: OnErrorHandler[] = [];
207
207
  private isShuttingDown = false;
208
+ private isInitialized = false;
209
+ private initializationPromise: Promise<void> | null = null;
208
210
  private generateModeSetup = false;
209
211
 
210
212
  // Custom services registry
@@ -955,6 +957,27 @@ ${factoryFunction}
955
957
  process.exit(0);
956
958
  }
957
959
 
960
+ // Guard against multiple initializations using promise-based mutex
961
+ // This prevents race conditions when multiple requests arrive concurrently
962
+ if (this.isInitialized) {
963
+ this.coreServices.logger.debug("Server already initialized, skipping");
964
+ return;
965
+ }
966
+ if (this.initializationPromise) {
967
+ this.coreServices.logger.debug("Server initialization in progress, waiting...");
968
+ await this.initializationPromise;
969
+ return;
970
+ }
971
+
972
+ // Create the initialization promise - all concurrent callers will await this same promise
973
+ this.initializationPromise = this.doInitialize();
974
+ await this.initializationPromise;
975
+ }
976
+
977
+ /**
978
+ * Internal initialization logic - only called once via the promise mutex
979
+ */
980
+ private async doInitialize(): Promise<void> {
958
981
  const { logger } = this.coreServices;
959
982
 
960
983
  // Auto-generate types in dev mode if configured
@@ -963,6 +986,11 @@ ${factoryFunction}
963
986
  await this.manager.migrate();
964
987
  await this.manager.init();
965
988
 
989
+ // Pass plugins to workflows so handlers can access ctx.plugins
990
+ this.coreServices.workflows.setPlugins(this.manager.getServices());
991
+
992
+ this.isInitialized = true;
993
+
966
994
  this.coreServices.cron.start();
967
995
  this.coreServices.jobs.start();
968
996
  await this.coreServices.workflows.resume();
@@ -1185,34 +1213,20 @@ ${factoryFunction}
1185
1213
  process.exit(0);
1186
1214
  }
1187
1215
 
1188
- const { logger } = this.coreServices;
1189
-
1190
- // Auto-generate types in dev mode if configured
1191
- await this.generateTypes();
1192
-
1193
- // 1. Run migrations
1194
- await this.manager.migrate();
1195
-
1196
- // 2. Initialize plugins
1197
- await this.manager.init();
1198
-
1199
- // 3. Start background services
1200
- this.coreServices.cron.start();
1201
- this.coreServices.jobs.start();
1202
- await this.coreServices.workflows.resume();
1203
- this.coreServices.processes.start();
1204
- logger.info("Background services started (cron, jobs, workflows, processes)");
1205
-
1206
- // 4. Build route map
1207
- for (const router of this.routers) {
1208
- for (const route of router.getRoutes()) {
1209
- if (this.routeMap.has(route.name)) {
1210
- logger.warn(`Duplicate route detected`, { route: route.name });
1211
- }
1212
- this.routeMap.set(route.name, route);
1216
+ // Guard against multiple initializations using promise-based mutex
1217
+ // This prevents race conditions when multiple requests arrive concurrently
1218
+ if (!this.isInitialized) {
1219
+ if (this.initializationPromise) {
1220
+ this.coreServices.logger.debug("Server initialization in progress, waiting...");
1221
+ await this.initializationPromise;
1222
+ } else {
1223
+ // Create the initialization promise - all concurrent callers will await this same promise
1224
+ this.initializationPromise = this.doInitialize();
1225
+ await this.initializationPromise;
1213
1226
  }
1214
1227
  }
1215
- logger.info(`Loaded ${this.routeMap.size} RPC routes`);
1228
+
1229
+ const { logger } = this.coreServices;
1216
1230
 
1217
1231
  // 5. Start HTTP server with port retry logic
1218
1232
  const fetchHandler = async (req: Request, server: ReturnType<typeof Bun.serve>) => {