@objectstack/runtime 4.0.3 → 4.0.5

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.
@@ -1,503 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { Plugin, PluginContext, IHttpServer } from '@objectstack/core';
4
- import { HttpDispatcher, HttpDispatcherResult } from './http-dispatcher.js';
5
-
6
- export interface DispatcherPluginConfig {
7
- /**
8
- * API path prefix for all endpoints.
9
- * @default '/api/v1'
10
- */
11
- prefix?: string;
12
- }
13
-
14
- /**
15
- * Route definition emitted by service plugins (e.g. AIServicePlugin) via hooks.
16
- * Minimal interface — matches the shape produced by `buildAIRoutes()`.
17
- */
18
- interface RouteDefinition {
19
- method: 'GET' | 'POST' | 'DELETE';
20
- path: string;
21
- description: string;
22
- handler: (req: any) => Promise<any>;
23
- }
24
-
25
- /**
26
- * Register a single RouteDefinition on the HTTP server.
27
- * Returns true if the route was successfully registered.
28
- */
29
- function mountRouteOnServer(route: RouteDefinition, server: IHttpServer, routePath: string): boolean {
30
- const handler = async (req: any, res: any) => {
31
- try {
32
- const result = await route.handler({
33
- body: req.body,
34
- params: req.params,
35
- query: req.query,
36
- });
37
-
38
- if (result.stream && result.events) {
39
- // SSE streaming response
40
- res.status(result.status);
41
-
42
- // Apply headers from the route result if available
43
- if (result.headers) {
44
- for (const [k, v] of Object.entries(result.headers)) {
45
- res.header(k, String(v));
46
- }
47
- } else {
48
- res.header('Content-Type', 'text/event-stream');
49
- res.header('Cache-Control', 'no-cache');
50
- res.header('Connection', 'keep-alive');
51
- }
52
-
53
- // Write the stream — events are pre-encoded SSE strings
54
- if (typeof res.write === 'function' && typeof res.end === 'function') {
55
- for await (const event of result.events) {
56
- res.write(typeof event === 'string' ? event : `data: ${JSON.stringify(event)}\n\n`);
57
- }
58
- res.end();
59
- } else {
60
- // Fallback: collect events into array
61
- const events = [];
62
- for await (const event of result.events) {
63
- events.push(event);
64
- }
65
- res.json({ events });
66
- }
67
- } else {
68
- res.status(result.status);
69
- if (result.body !== undefined) {
70
- res.json(result.body);
71
- } else {
72
- res.end();
73
- }
74
- }
75
- } catch (err: any) {
76
- errorResponse(err, res);
77
- }
78
- };
79
-
80
- const m = route.method.toLowerCase();
81
- if (m === 'get' && typeof server.get === 'function') {
82
- server.get(routePath, handler);
83
- return true;
84
- } else if (m === 'post' && typeof server.post === 'function') {
85
- server.post(routePath, handler);
86
- return true;
87
- } else if (m === 'delete' && typeof server.delete === 'function') {
88
- server.delete(routePath, handler);
89
- return true;
90
- }
91
- return false;
92
- }
93
-
94
- /**
95
- * Send an HttpDispatcherResult through IHttpResponse.
96
- * Differentiates between handled, unhandled (404), and special results.
97
- */
98
- function sendResult(result: HttpDispatcherResult, res: any): void {
99
- if (result.handled) {
100
- if (result.response) {
101
- res.status(result.response.status);
102
- if (result.response.headers) {
103
- for (const [k, v] of Object.entries(result.response.headers)) {
104
- res.header(k, v);
105
- }
106
- }
107
- res.json(result.response.body);
108
- return;
109
- }
110
- if (result.result) {
111
- // Special results (redirect, stream) — pass through as JSON for now
112
- res.status(200).json(result.result);
113
- return;
114
- }
115
- }
116
- // Semantic 404: no route matched — include diagnostic info
117
- res.status(404).json({
118
- success: false,
119
- error: {
120
- message: 'Not Found',
121
- code: 404,
122
- type: 'ROUTE_NOT_FOUND',
123
- hint: 'No handler matched this request. Check the API discovery endpoint for available routes.',
124
- },
125
- });
126
- }
127
-
128
- function errorResponse(err: any, res: any): void {
129
- const code = err.statusCode || 500;
130
- res.status(code).json({
131
- success: false,
132
- error: { message: err.message || 'Internal Server Error', code },
133
- });
134
- }
135
-
136
- /**
137
- * Dispatcher Plugin
138
- *
139
- * Bridges legacy HttpDispatcher handlers to the IHttpServer route-registration model.
140
- * Registers routes for domains NOT covered by @objectstack/rest:
141
- * - /.well-known/objectstack (discovery)
142
- * - /auth (authentication)
143
- * - /graphql (GraphQL)
144
- * - /analytics (BI queries)
145
- * - /packages (package management)
146
- * - /i18n (internationalization — locales, translations, field labels)
147
- * - /storage (file storage)
148
- * - /automation (CRUD + triggers + runs)
149
- *
150
- * Usage:
151
- * ```ts
152
- * import { createDispatcherPlugin } from '@objectstack/runtime';
153
- * runtime.use(createDispatcherPlugin({ prefix: '/api/v1' }));
154
- * ```
155
- */
156
- export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plugin {
157
- return {
158
- name: 'com.objectstack.runtime.dispatcher',
159
- version: '1.0.0',
160
-
161
- init: async (_ctx: PluginContext) => {
162
- // Consumer-only plugin — no services registered
163
- },
164
-
165
- start: async (ctx: PluginContext) => {
166
- let server: IHttpServer | undefined;
167
- try {
168
- server = ctx.getService<IHttpServer>('http.server');
169
- } catch {
170
- // No HTTP server available — skip silently
171
- return;
172
- }
173
- if (!server) return;
174
-
175
- const kernel = ctx.getKernel();
176
- const dispatcher = new HttpDispatcher(kernel);
177
- const prefix = config.prefix || '/api/v1';
178
-
179
- // ── Discovery (.well-known) ─────────────────────────────────
180
- server.get('/.well-known/objectstack', async (_req: any, res: any) => {
181
- res.json({ data: await dispatcher.getDiscoveryInfo(prefix) });
182
- });
183
-
184
- // ── Discovery (versioned API path) ──────────────────────────
185
- server.get(`${prefix}/discovery`, async (_req: any, res: any) => {
186
- res.json({ data: await dispatcher.getDiscoveryInfo(prefix) });
187
- });
188
-
189
- // ── Health ──────────────────────────────────────────────────
190
- server.get(`${prefix}/health`, async (_req: any, res: any) => {
191
- try {
192
- const result = await dispatcher.dispatch('GET', '/health', undefined, {}, { request: _req });
193
- sendResult(result, res);
194
- } catch (err: any) {
195
- errorResponse(err, res);
196
- }
197
- });
198
-
199
- // ── Auth ────────────────────────────────────────────────────
200
- server.post(`${prefix}/auth/login`, async (req: any, res: any) => {
201
- try {
202
- const result = await dispatcher.handleAuth('login', 'POST', req.body, { request: req });
203
- sendResult(result, res);
204
- } catch (err: any) {
205
- errorResponse(err, res);
206
- }
207
- });
208
-
209
- // ── GraphQL ─────────────────────────────────────────────────
210
- server.post(`${prefix}/graphql`, async (req: any, res: any) => {
211
- try {
212
- const result = await dispatcher.handleGraphQL(req.body, { request: req });
213
- res.json(result);
214
- } catch (err: any) {
215
- errorResponse(err, res);
216
- }
217
- });
218
-
219
- // ── Analytics ───────────────────────────────────────────────
220
- server.post(`${prefix}/analytics/query`, async (req: any, res: any) => {
221
- try {
222
- const result = await dispatcher.handleAnalytics('query', 'POST', req.body, { request: req });
223
- sendResult(result, res);
224
- } catch (err: any) {
225
- errorResponse(err, res);
226
- }
227
- });
228
-
229
- server.get(`${prefix}/analytics/meta`, async (req: any, res: any) => {
230
- try {
231
- const result = await dispatcher.handleAnalytics('meta', 'GET', {}, { request: req });
232
- sendResult(result, res);
233
- } catch (err: any) {
234
- errorResponse(err, res);
235
- }
236
- });
237
-
238
- server.post(`${prefix}/analytics/sql`, async (req: any, res: any) => {
239
- try {
240
- const result = await dispatcher.handleAnalytics('sql', 'POST', req.body, { request: req });
241
- sendResult(result, res);
242
- } catch (err: any) {
243
- errorResponse(err, res);
244
- }
245
- });
246
-
247
- // ── Packages ────────────────────────────────────────────────
248
- server.get(`${prefix}/packages`, async (req: any, res: any) => {
249
- try {
250
- const result = await dispatcher.handlePackages('', 'GET', {}, req.query, { request: req });
251
- sendResult(result, res);
252
- } catch (err: any) {
253
- errorResponse(err, res);
254
- }
255
- });
256
-
257
- server.post(`${prefix}/packages`, async (req: any, res: any) => {
258
- try {
259
- const result = await dispatcher.handlePackages('', 'POST', req.body, {}, { request: req });
260
- sendResult(result, res);
261
- } catch (err: any) {
262
- errorResponse(err, res);
263
- }
264
- });
265
-
266
- server.get(`${prefix}/packages/:id`, async (req: any, res: any) => {
267
- try {
268
- const result = await dispatcher.handlePackages(`/${req.params.id}`, 'GET', {}, req.query, { request: req });
269
- sendResult(result, res);
270
- } catch (err: any) {
271
- errorResponse(err, res);
272
- }
273
- });
274
-
275
- server.delete(`${prefix}/packages/:id`, async (req: any, res: any) => {
276
- try {
277
- const result = await dispatcher.handlePackages(`/${req.params.id}`, 'DELETE', {}, {}, { request: req });
278
- sendResult(result, res);
279
- } catch (err: any) {
280
- errorResponse(err, res);
281
- }
282
- });
283
-
284
- server.patch(`${prefix}/packages/:id/enable`, async (req: any, res: any) => {
285
- try {
286
- const result = await dispatcher.handlePackages(`/${req.params.id}/enable`, 'PATCH', {}, {}, { request: req });
287
- sendResult(result, res);
288
- } catch (err: any) {
289
- errorResponse(err, res);
290
- }
291
- });
292
-
293
- server.patch(`${prefix}/packages/:id/disable`, async (req: any, res: any) => {
294
- try {
295
- const result = await dispatcher.handlePackages(`/${req.params.id}/disable`, 'PATCH', {}, {}, { request: req });
296
- sendResult(result, res);
297
- } catch (err: any) {
298
- errorResponse(err, res);
299
- }
300
- });
301
-
302
- server.post(`${prefix}/packages/:id/publish`, async (req: any, res: any) => {
303
- try {
304
- const result = await dispatcher.handlePackages(`/${req.params.id}/publish`, 'POST', req.body, {}, { request: req });
305
- sendResult(result, res);
306
- } catch (err: any) {
307
- errorResponse(err, res);
308
- }
309
- });
310
-
311
- server.post(`${prefix}/packages/:id/revert`, async (req: any, res: any) => {
312
- try {
313
- const result = await dispatcher.handlePackages(`/${req.params.id}/revert`, 'POST', req.body, {}, { request: req });
314
- sendResult(result, res);
315
- } catch (err: any) {
316
- errorResponse(err, res);
317
- }
318
- });
319
-
320
- // ── Storage ─────────────────────────────────────────────────
321
- server.post(`${prefix}/storage/upload`, async (req: any, res: any) => {
322
- try {
323
- // For file uploads the body *is* the file (parsed by adapter)
324
- const result = await dispatcher.handleStorage('upload', 'POST', req.body, { request: req });
325
- sendResult(result, res);
326
- } catch (err: any) {
327
- errorResponse(err, res);
328
- }
329
- });
330
-
331
- server.get(`${prefix}/storage/file/:id`, async (req: any, res: any) => {
332
- try {
333
- const result = await dispatcher.handleStorage(`file/${req.params.id}`, 'GET', undefined, { request: req });
334
- sendResult(result, res);
335
- } catch (err: any) {
336
- errorResponse(err, res);
337
- }
338
- });
339
-
340
- // ── i18n ────────────────────────────────────────────────────
341
- // Bridges to HttpDispatcher.handleI18n() which resolves the i18n
342
- // service from the kernel (either I18nServicePlugin or memory fallback).
343
- server.get(`${prefix}/i18n/locales`, async (req: any, res: any) => {
344
- try {
345
- const result = await dispatcher.handleI18n('/locales', 'GET', req.query, { request: req });
346
- sendResult(result, res);
347
- } catch (err: any) {
348
- errorResponse(err, res);
349
- }
350
- });
351
-
352
- server.get(`${prefix}/i18n/translations/:locale`, async (req: any, res: any) => {
353
- try {
354
- const result = await dispatcher.handleI18n(`/translations/${req.params.locale}`, 'GET', req.query, { request: req });
355
- sendResult(result, res);
356
- } catch (err: any) {
357
- errorResponse(err, res);
358
- }
359
- });
360
-
361
- server.get(`${prefix}/i18n/labels/:object/:locale`, async (req: any, res: any) => {
362
- try {
363
- const result = await dispatcher.handleI18n(`/labels/${req.params.object}/${req.params.locale}`, 'GET', req.query, { request: req });
364
- sendResult(result, res);
365
- } catch (err: any) {
366
- errorResponse(err, res);
367
- }
368
- });
369
-
370
- // ── Automation ──────────────────────────────────────────────
371
- server.get(`${prefix}/automation`, async (req: any, res: any) => {
372
- try {
373
- const result = await dispatcher.handleAutomation('', 'GET', {}, { request: req });
374
- sendResult(result, res);
375
- } catch (err: any) {
376
- errorResponse(err, res);
377
- }
378
- });
379
-
380
- server.post(`${prefix}/automation`, async (req: any, res: any) => {
381
- try {
382
- const result = await dispatcher.handleAutomation('', 'POST', req.body, { request: req });
383
- sendResult(result, res);
384
- } catch (err: any) {
385
- errorResponse(err, res);
386
- }
387
- });
388
-
389
- server.get(`${prefix}/automation/:name`, async (req: any, res: any) => {
390
- try {
391
- const result = await dispatcher.handleAutomation(`${req.params.name}`, 'GET', {}, { request: req });
392
- sendResult(result, res);
393
- } catch (err: any) {
394
- errorResponse(err, res);
395
- }
396
- });
397
-
398
- server.put(`${prefix}/automation/:name`, async (req: any, res: any) => {
399
- try {
400
- const result = await dispatcher.handleAutomation(`${req.params.name}`, 'PUT', req.body, { request: req });
401
- sendResult(result, res);
402
- } catch (err: any) {
403
- errorResponse(err, res);
404
- }
405
- });
406
-
407
- server.delete(`${prefix}/automation/:name`, async (req: any, res: any) => {
408
- try {
409
- const result = await dispatcher.handleAutomation(`${req.params.name}`, 'DELETE', {}, { request: req });
410
- sendResult(result, res);
411
- } catch (err: any) {
412
- errorResponse(err, res);
413
- }
414
- });
415
-
416
- server.post(`${prefix}/automation/trigger/:name`, async (req: any, res: any) => {
417
- try {
418
- const result = await dispatcher.handleAutomation(`trigger/${req.params.name}`, 'POST', req.body, { request: req });
419
- sendResult(result, res);
420
- } catch (err: any) {
421
- errorResponse(err, res);
422
- }
423
- });
424
-
425
- server.post(`${prefix}/automation/:name/trigger`, async (req: any, res: any) => {
426
- try {
427
- const result = await dispatcher.handleAutomation(`${req.params.name}/trigger`, 'POST', req.body, { request: req });
428
- sendResult(result, res);
429
- } catch (err: any) {
430
- errorResponse(err, res);
431
- }
432
- });
433
-
434
- server.post(`${prefix}/automation/:name/toggle`, async (req: any, res: any) => {
435
- try {
436
- const result = await dispatcher.handleAutomation(`${req.params.name}/toggle`, 'POST', req.body, { request: req });
437
- sendResult(result, res);
438
- } catch (err: any) {
439
- errorResponse(err, res);
440
- }
441
- });
442
-
443
- server.get(`${prefix}/automation/:name/runs`, async (req: any, res: any) => {
444
- try {
445
- const result = await dispatcher.handleAutomation(`${req.params.name}/runs`, 'GET', {}, { request: req }, req.query);
446
- sendResult(result, res);
447
- } catch (err: any) {
448
- errorResponse(err, res);
449
- }
450
- });
451
-
452
- server.get(`${prefix}/automation/:name/runs/:runId`, async (req: any, res: any) => {
453
- try {
454
- const result = await dispatcher.handleAutomation(`${req.params.name}/runs/${req.params.runId}`, 'GET', {}, { request: req });
455
- sendResult(result, res);
456
- } catch (err: any) {
457
- errorResponse(err, res);
458
- }
459
- });
460
-
461
- ctx.logger.info('Dispatcher bridge routes registered', { prefix });
462
-
463
- // ── Dynamic service routes (AI, etc.) ───────────────────
464
- // Listen for route definitions emitted by service plugins.
465
- // The AIServicePlugin emits 'ai:routes' with RouteDefinition[].
466
- ctx.hook('ai:routes', async (routes: RouteDefinition[]) => {
467
- if (!server) return;
468
- for (const route of routes) {
469
- // Strip the /api/v1 prefix if present (it's already in the path)
470
- // and register on the HTTP server with the configured prefix.
471
- const routePath = route.path.startsWith('/api/v1')
472
- ? route.path
473
- : `${prefix}${route.path}`;
474
- mountRouteOnServer(route, server, routePath);
475
- }
476
- ctx.logger.info(`[Dispatcher] Registered ${routes.length} AI routes`);
477
- });
478
-
479
- // ── Fallback: recover routes cached before hook was registered ──
480
- // If AIServicePlugin.start() ran before DispatcherPlugin.start()
481
- // (possible when plugin start order differs from registration order),
482
- // the 'ai:routes' trigger fires with no listener. The AIServicePlugin
483
- // caches the routes on the kernel as __aiRoutes (see AIServicePlugin.start())
484
- // as an internal cross-plugin protocol so we can recover them here.
485
- // TODO: replace with a formal kernel.getCachedRoutes('ai') API in a future release.
486
- const cachedRoutes = (kernel as any).__aiRoutes as RouteDefinition[] | undefined;
487
- if (cachedRoutes && Array.isArray(cachedRoutes) && cachedRoutes.length > 0) {
488
- let registered = 0;
489
- for (const route of cachedRoutes) {
490
- const routePath = route.path.startsWith('/api/v1')
491
- ? route.path
492
- : `${prefix}${route.path}`;
493
- if (mountRouteOnServer(route, server, routePath)) {
494
- registered++;
495
- }
496
- }
497
- if (registered > 0) {
498
- ctx.logger.info(`[Dispatcher] Recovered ${registered} cached AI routes (hook timing fallback)`);
499
- }
500
- }
501
- },
502
- };
503
- }
@@ -1,76 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { Plugin, PluginContext } from '@objectstack/core';
4
-
5
- /**
6
- * Driver Plugin
7
- *
8
- * Generic plugin wrapper for ObjectQL drivers.
9
- * Registers a driver with the ObjectQL engine.
10
- *
11
- * Dependencies: None (Registers service for ObjectQL to discover)
12
- * Services: driver.{name}
13
- *
14
- * @example
15
- * const memoryDriver = new InMemoryDriver();
16
- * const driverPlugin = new DriverPlugin(memoryDriver, 'memory');
17
- * kernel.use(driverPlugin);
18
- */
19
- export class DriverPlugin implements Plugin {
20
- name: string;
21
- type = 'driver';
22
- version = '1.0.0';
23
- // dependencies = ['com.objectstack.engine.objectql']; // Removed: Driver is a producer, not strictly a consumer during init
24
-
25
- private driver: any;
26
-
27
- constructor(driver: any, driverName?: string) {
28
- this.driver = driver;
29
- this.name = `com.objectstack.driver.${driverName || driver.name || 'unknown'}`;
30
- }
31
-
32
- init = async (ctx: PluginContext) => {
33
- // Register driver as a service instead of directly to objectql
34
- const serviceName = `driver.${this.driver.name || 'unknown'}`;
35
- ctx.registerService(serviceName, this.driver);
36
- ctx.logger.info('Driver service registered', {
37
- serviceName,
38
- driverName: this.driver.name,
39
- driverVersion: this.driver.version
40
- });
41
- }
42
-
43
- start = async (ctx: PluginContext) => {
44
- // Drivers don't need start phase, initialization happens in init
45
- // Auto-configure alias for shorter access if it follows reverse domain standard
46
- if (this.name.startsWith('com.objectstack.driver.')) {
47
- // const shortName = this.name.split('.').pop();
48
- // Optional: ctx.registerService(`driver.${shortName}`, this.driver);
49
- }
50
-
51
- // Auto-configure 'default' datasource if none exists
52
- // We do this in 'start' phase to ensure metadata service is likely available
53
- try {
54
- const metadata = ctx.getService<any>('metadata');
55
- if (metadata && metadata.addDatasource) {
56
- // Check if default datasource exists
57
- const datasources = metadata.getDatasources ? metadata.getDatasources() : [];
58
- const hasDefault = datasources.some((ds: any) => ds.name === 'default');
59
-
60
- if (!hasDefault) {
61
- ctx.logger.info(`[DriverPlugin] No 'default' datasource found. Auto-configuring '${this.driver.name}' as default.`);
62
- await metadata.addDatasource({
63
- name: 'default',
64
- driver: this.driver.name, // The driver's internal name (e.g. com.objectstack.driver.memory)
65
- });
66
- }
67
- }
68
- } catch (e) {
69
- // Metadata service might not be ready or available, which is fine
70
- // We just skip auto-configuration
71
- ctx.logger.debug('[DriverPlugin] Failed to auto-configure default datasource (Metadata service missing?)', { error: e });
72
- }
73
-
74
- ctx.logger.debug('Driver plugin started', { driverName: this.driver.name || 'unknown' });
75
- }
76
- }
@@ -1,76 +0,0 @@
1
-
2
- import { describe, it, expect, vi, beforeEach } from 'vitest';
3
- import { HttpDispatcher } from './http-dispatcher';
4
- import { ObjectKernel } from '@objectstack/core';
5
-
6
- describe('HttpDispatcher Root Handling', () => {
7
- let kernel: ObjectKernel;
8
- let dispatcher: HttpDispatcher;
9
-
10
- beforeEach(() => {
11
- // Mock minimal Kernel structure
12
- kernel = {
13
- services: {},
14
- broker: {
15
- call: vi.fn(),
16
- },
17
- context: {
18
- getService: vi.fn(),
19
- }
20
- } as any;
21
-
22
- dispatcher = new HttpDispatcher(kernel);
23
- });
24
-
25
- it('should handled GET request to root path ("") correctly', async () => {
26
- const context = { request: {} };
27
- const method = 'GET';
28
- // MSW passes empty string when stripping base URL
29
- const path = '';
30
- const body = undefined;
31
- const query = {};
32
-
33
- const result = await dispatcher.dispatch(method, path, body, query, context);
34
-
35
- expect(result.handled).toBe(true);
36
- expect(result.response).toBeDefined();
37
- expect(result.response?.status).toBe(200);
38
-
39
- const data = result.response?.body?.data;
40
- expect(data).toBeDefined();
41
- // getDiscoveryInfo returns 'name' not 'apiName'
42
- expect(data.name).toBe('ObjectOS');
43
- expect(data.version).toBe('1.0.0');
44
- expect(data.routes).toBeDefined();
45
- // Since we passed empty prefix in dispatch code (hardcoded), routes should be relative
46
- expect(data.routes.metadata).toBe('/meta');
47
- });
48
-
49
- it('should handle GET /discovery (protocol-standard route)', async () => {
50
- const context = { request: {} };
51
- const result = await dispatcher.dispatch('GET', '/discovery', undefined, {}, context);
52
-
53
- expect(result.handled).toBe(true);
54
- expect(result.response).toBeDefined();
55
- expect(result.response?.status).toBe(200);
56
-
57
- const data = result.response?.body?.data;
58
- expect(data).toBeDefined();
59
- expect(data.name).toBe('ObjectOS');
60
- expect(data.version).toBe('1.0.0');
61
- expect(data.routes).toBeDefined();
62
- });
63
-
64
- it('should return semantic 404 for POST request to root path ("")', async () => {
65
- const context = { request: {} };
66
- const method = 'POST';
67
- const path = '';
68
-
69
- const result = await dispatcher.dispatch(method, path, {}, {}, context);
70
-
71
- // The dispatcher now returns a typed 404 (ROUTE_NOT_FOUND) instead of { handled: false }
72
- expect(result.handled).toBe(true);
73
- expect(result.response?.status).toBe(404);
74
- expect(result.response?.body?.error?.type).toBe('ROUTE_NOT_FOUND');
75
- });
76
- });