@objectstack/hono 4.0.4 → 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.
package/src/hono.test.ts DELETED
@@ -1,950 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { describe, it, expect, vi, beforeEach } from 'vitest';
4
- import { Hono } from 'hono';
5
-
6
- // Mock dispatcher instance accessible across tests
7
- const mockDispatcher = {
8
- getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', endpoints: [] }),
9
- handleAuth: vi.fn().mockResolvedValue({ handled: true, response: { body: { ok: true }, status: 200 } }),
10
- handleGraphQL: vi.fn().mockResolvedValue({ data: {} }),
11
- handleStorage: vi.fn().mockResolvedValue({ handled: true, response: { body: {}, status: 200 } }),
12
- dispatch: vi.fn().mockResolvedValue({ handled: true, response: { body: { success: true }, status: 200 } }),
13
- };
14
-
15
- vi.mock('@objectstack/runtime', () => {
16
- return {
17
- HttpDispatcher: function HttpDispatcher() {
18
- return mockDispatcher;
19
- },
20
- };
21
- });
22
-
23
- import { objectStackMiddleware, createHonoApp } from './index';
24
-
25
- const mockKernel = { name: 'test-kernel' } as any;
26
-
27
- describe('objectStackMiddleware', () => {
28
- beforeEach(() => {
29
- vi.clearAllMocks();
30
- });
31
-
32
- it('sets kernel on context via c.set', async () => {
33
- const app = new Hono();
34
- const middleware = objectStackMiddleware(mockKernel);
35
-
36
- app.use('*', middleware);
37
- app.get('/test', (c) => {
38
- const kernel = c.get('objectStack');
39
- return c.json({ hasKernel: !!kernel });
40
- });
41
-
42
- const res = await app.request('/test');
43
- expect(res.status).toBe(200);
44
- const json = await res.json();
45
- expect(json.hasKernel).toBe(true);
46
- });
47
-
48
- it('calls next middleware', async () => {
49
- const app = new Hono();
50
- const middleware = objectStackMiddleware(mockKernel);
51
- const spy = vi.fn();
52
-
53
- app.use('*', middleware);
54
- app.use('*', async (_c, next) => {
55
- spy();
56
- await next();
57
- });
58
- app.get('/test', (c) => c.json({ ok: true }));
59
-
60
- const res = await app.request('/test');
61
- expect(res.status).toBe(200);
62
- expect(spy).toHaveBeenCalled();
63
- });
64
-
65
- it('provides the correct kernel instance', async () => {
66
- const app = new Hono();
67
- const middleware = objectStackMiddleware(mockKernel);
68
-
69
- app.use('*', middleware);
70
- app.get('/test', (c) => {
71
- const kernel = c.get('objectStack');
72
- return c.json({ name: kernel.name });
73
- });
74
-
75
- const res = await app.request('/test');
76
- expect(res.status).toBe(200);
77
- const json = await res.json();
78
- expect(json.name).toBe('test-kernel');
79
- });
80
- });
81
-
82
- describe('createHonoApp', () => {
83
- let app: Hono;
84
-
85
- beforeEach(() => {
86
- vi.clearAllMocks();
87
- app = createHonoApp({ kernel: mockKernel });
88
- });
89
-
90
- describe('Discovery Endpoint', () => {
91
- it('GET /api returns discovery info', async () => {
92
- const res = await app.request('/api');
93
- expect(res.status).toBe(200);
94
- const json = await res.json();
95
- expect(json.data).toBeDefined();
96
- expect(json.data.version).toBe('1.0');
97
- expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api');
98
- });
99
-
100
- it('GET /api/discovery returns discovery info with correct prefix', async () => {
101
- const res = await app.request('/api/discovery');
102
- expect(res.status).toBe(200);
103
- const json = await res.json();
104
- expect(json.data).toBeDefined();
105
- expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api');
106
- });
107
-
108
- it('uses custom prefix for discovery', async () => {
109
- const customApp = createHonoApp({ kernel: mockKernel, prefix: '/v2' });
110
- const res = await customApp.request('/v2');
111
- expect(res.status).toBe(200);
112
- const json = await res.json();
113
- expect(json.data).toBeDefined();
114
- expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/v2');
115
- });
116
-
117
- it('uses custom prefix for /discovery route', async () => {
118
- const customApp = createHonoApp({ kernel: mockKernel, prefix: '/v2' });
119
- const res = await customApp.request('/v2/discovery');
120
- expect(res.status).toBe(200);
121
- const json = await res.json();
122
- expect(json.data).toBeDefined();
123
- expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/v2');
124
- });
125
- });
126
-
127
- describe('.well-known Endpoint', () => {
128
- it('GET /.well-known/objectstack redirects to prefix', async () => {
129
- const res = await app.request('/.well-known/objectstack', { redirect: 'manual' });
130
- expect(res.status).toBe(302);
131
- expect(res.headers.get('location')).toBe('/api');
132
- });
133
- });
134
-
135
- describe('Auth Endpoint', () => {
136
- it('POST /api/auth/login calls handleAuth', async () => {
137
- const res = await app.request('/api/auth/login', {
138
- method: 'POST',
139
- headers: { 'Content-Type': 'application/json' },
140
- body: JSON.stringify({ email: 'a@b.com' }),
141
- });
142
- expect(res.status).toBe(200);
143
- const json = await res.json();
144
- expect(json.ok).toBe(true);
145
- expect(mockDispatcher.handleAuth).toHaveBeenCalledWith(
146
- 'login',
147
- 'POST',
148
- { email: 'a@b.com' },
149
- expect.objectContaining({ request: expect.anything() }),
150
- );
151
- });
152
-
153
- it('GET /api/auth/callback calls handleAuth with empty body', async () => {
154
- const res = await app.request('/api/auth/callback');
155
- expect(res.status).toBe(200);
156
- expect(mockDispatcher.handleAuth).toHaveBeenCalledWith(
157
- 'callback',
158
- 'GET',
159
- {},
160
- expect.objectContaining({ request: expect.anything() }),
161
- );
162
- });
163
-
164
- it('returns error on handleAuth exception', async () => {
165
- mockDispatcher.handleAuth.mockRejectedValueOnce(
166
- Object.assign(new Error('Unauthorized'), { statusCode: 401 }),
167
- );
168
- const res = await app.request('/api/auth/login', {
169
- method: 'POST',
170
- headers: { 'Content-Type': 'application/json' },
171
- body: JSON.stringify({}),
172
- });
173
- expect(res.status).toBe(401);
174
- const json = await res.json();
175
- expect(json.success).toBe(false);
176
- expect(json.error.message).toBe('Unauthorized');
177
- });
178
- });
179
-
180
- describe('Auth via AuthPlugin service', () => {
181
- it('uses kernel.getService("auth") when available', async () => {
182
- const mockHandleRequest = vi.fn().mockResolvedValue(
183
- new Response(JSON.stringify({ user: { id: '1' } }), {
184
- status: 200,
185
- headers: { 'Content-Type': 'application/json' },
186
- }),
187
- );
188
- const kernelWithAuth = {
189
- ...mockKernel,
190
- getService: vi.fn().mockReturnValue({ handleRequest: mockHandleRequest }),
191
- };
192
- const authApp = createHonoApp({ kernel: kernelWithAuth });
193
- const res = await authApp.request('/api/auth/sign-in/email', {
194
- method: 'POST',
195
- headers: { 'Content-Type': 'application/json' },
196
- body: JSON.stringify({ email: 'a@b.com', password: 'pass' }),
197
- });
198
- expect(res.status).toBe(200);
199
- expect(kernelWithAuth.getService).toHaveBeenCalledWith('auth');
200
- expect(mockHandleRequest).toHaveBeenCalled();
201
- });
202
-
203
- it('falls back to dispatcher when auth service is not available', async () => {
204
- const kernelWithoutAuth = {
205
- ...mockKernel,
206
- getService: vi.fn().mockReturnValue(null),
207
- };
208
- const authApp = createHonoApp({ kernel: kernelWithoutAuth });
209
- const res = await authApp.request('/api/auth/login', {
210
- method: 'POST',
211
- headers: { 'Content-Type': 'application/json' },
212
- body: JSON.stringify({ email: 'a@b.com' }),
213
- });
214
- expect(res.status).toBe(200);
215
- expect(mockDispatcher.handleAuth).toHaveBeenCalled();
216
- });
217
-
218
- it('uses kernel.getServiceAsync("auth") when available (async factory)', async () => {
219
- const mockHandleRequest = vi.fn().mockResolvedValue(
220
- new Response(JSON.stringify({ user: { id: '2' } }), {
221
- status: 200,
222
- headers: { 'Content-Type': 'application/json' },
223
- }),
224
- );
225
- const kernelWithAsyncAuth = {
226
- ...mockKernel,
227
- getServiceAsync: vi.fn().mockResolvedValue({ handleRequest: mockHandleRequest }),
228
- };
229
- const authApp = createHonoApp({ kernel: kernelWithAsyncAuth });
230
- const res = await authApp.request('/api/auth/sign-in/email', {
231
- method: 'POST',
232
- headers: { 'Content-Type': 'application/json' },
233
- body: JSON.stringify({ email: 'a@b.com', password: 'pass' }),
234
- });
235
- expect(res.status).toBe(200);
236
- expect(kernelWithAsyncAuth.getServiceAsync).toHaveBeenCalledWith('auth');
237
- expect(mockHandleRequest).toHaveBeenCalled();
238
- });
239
-
240
- it('falls back to dispatcher when getServiceAsync throws (async factory error)', async () => {
241
- const kernelWithFailingAsync = {
242
- ...mockKernel,
243
- getServiceAsync: vi.fn().mockRejectedValue(new Error("Service 'auth' not found")),
244
- };
245
- const authApp = createHonoApp({ kernel: kernelWithFailingAsync });
246
- const res = await authApp.request('/api/auth/login', {
247
- method: 'POST',
248
- headers: { 'Content-Type': 'application/json' },
249
- body: JSON.stringify({ email: 'a@b.com' }),
250
- });
251
- expect(res.status).toBe(200);
252
- expect(mockDispatcher.handleAuth).toHaveBeenCalled();
253
- });
254
- });
255
-
256
- describe('GraphQL Endpoint', () => {
257
- it('POST /api/graphql calls handleGraphQL', async () => {
258
- const body = { query: '{ objects { name } }' };
259
- const res = await app.request('/api/graphql', {
260
- method: 'POST',
261
- headers: { 'Content-Type': 'application/json' },
262
- body: JSON.stringify(body),
263
- });
264
- expect(res.status).toBe(200);
265
- const json = await res.json();
266
- expect(json.data).toBeDefined();
267
- expect(mockDispatcher.handleGraphQL).toHaveBeenCalledWith(
268
- body,
269
- expect.objectContaining({ request: expect.anything() }),
270
- );
271
- });
272
-
273
- it('returns error on handleGraphQL exception', async () => {
274
- mockDispatcher.handleGraphQL.mockRejectedValueOnce(new Error('Parse error'));
275
- const res = await app.request('/api/graphql', {
276
- method: 'POST',
277
- headers: { 'Content-Type': 'application/json' },
278
- body: JSON.stringify({ query: 'bad' }),
279
- });
280
- expect(res.status).toBe(500);
281
- const json = await res.json();
282
- expect(json.success).toBe(false);
283
- expect(json.error.message).toBe('Parse error');
284
- });
285
- });
286
-
287
- describe('Catch-all Dispatch', () => {
288
- it('GET /api/meta/objects delegates to dispatch()', async () => {
289
- const res = await app.request('/api/meta/objects');
290
- expect(res.status).toBe(200);
291
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
292
- 'GET',
293
- '/meta/objects',
294
- undefined,
295
- expect.any(Object),
296
- expect.objectContaining({ request: expect.anything() }),
297
- '/api',
298
- );
299
- });
300
-
301
- it('PUT /api/meta/objects parses JSON body via dispatch()', async () => {
302
- const body = { name: 'test_object' };
303
- const res = await app.request('/api/meta/objects', {
304
- method: 'PUT',
305
- headers: { 'Content-Type': 'application/json' },
306
- body: JSON.stringify(body),
307
- });
308
- expect(res.status).toBe(200);
309
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
310
- 'PUT',
311
- '/meta/objects',
312
- body,
313
- expect.any(Object),
314
- expect.objectContaining({ request: expect.anything() }),
315
- '/api',
316
- );
317
- });
318
-
319
- it('GET /api/meta with no trailing path', async () => {
320
- const res = await app.request('/api/meta');
321
- expect(res.status).toBe(200);
322
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
323
- 'GET',
324
- '/meta',
325
- undefined,
326
- expect.any(Object),
327
- expect.objectContaining({ request: expect.anything() }),
328
- '/api',
329
- );
330
- });
331
-
332
- it('forwards query parameters through dispatch()', async () => {
333
- const res = await app.request('/api/meta/objects?package=com.acme.crm');
334
- expect(res.status).toBe(200);
335
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
336
- 'GET',
337
- '/meta/objects',
338
- undefined,
339
- expect.objectContaining({ package: 'com.acme.crm' }),
340
- expect.objectContaining({ request: expect.anything() }),
341
- '/api',
342
- );
343
- });
344
-
345
- it('GET /api/data/account delegates to dispatch()', async () => {
346
- const res = await app.request('/api/data/account');
347
- expect(res.status).toBe(200);
348
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
349
- 'GET',
350
- '/data/account',
351
- undefined,
352
- expect.any(Object),
353
- expect.objectContaining({ request: expect.anything() }),
354
- '/api',
355
- );
356
- });
357
-
358
- it('POST /api/data/account parses JSON body', async () => {
359
- const body = { name: 'Acme' };
360
- const res = await app.request('/api/data/account', {
361
- method: 'POST',
362
- headers: { 'Content-Type': 'application/json' },
363
- body: JSON.stringify(body),
364
- });
365
- expect(res.status).toBe(200);
366
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
367
- 'POST',
368
- '/data/account',
369
- body,
370
- expect.any(Object),
371
- expect.objectContaining({ request: expect.anything() }),
372
- '/api',
373
- );
374
- });
375
-
376
- it('PATCH /api/data/account parses JSON body', async () => {
377
- const body = { name: 'Updated' };
378
- const res = await app.request('/api/data/account', {
379
- method: 'PATCH',
380
- headers: { 'Content-Type': 'application/json' },
381
- body: JSON.stringify(body),
382
- });
383
- expect(res.status).toBe(200);
384
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
385
- 'PATCH',
386
- '/data/account',
387
- body,
388
- expect.any(Object),
389
- expect.objectContaining({ request: expect.anything() }),
390
- '/api',
391
- );
392
- });
393
-
394
- it('returns 404 when dispatch result is not handled', async () => {
395
- mockDispatcher.dispatch.mockResolvedValueOnce({ handled: false });
396
- const res = await app.request('/api/data/missing');
397
- expect(res.status).toBe(404);
398
- const json = await res.json();
399
- expect(json.success).toBe(false);
400
- });
401
-
402
- it('GET /api/packages delegates to dispatch()', async () => {
403
- const res = await app.request('/api/packages');
404
- expect(res.status).toBe(200);
405
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
406
- 'GET',
407
- '/packages',
408
- undefined,
409
- expect.any(Object),
410
- expect.objectContaining({ request: expect.anything() }),
411
- '/api',
412
- );
413
- });
414
-
415
- it('GET /api/packages/:id delegates to dispatch()', async () => {
416
- const res = await app.request('/api/packages/com.acme.crm');
417
- expect(res.status).toBe(200);
418
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
419
- 'GET',
420
- '/packages/com.acme.crm',
421
- undefined,
422
- expect.any(Object),
423
- expect.objectContaining({ request: expect.anything() }),
424
- '/api',
425
- );
426
- });
427
-
428
- it('POST /api/packages parses JSON body', async () => {
429
- const body = { manifest: { name: 'test-pkg' } };
430
- const res = await app.request('/api/packages', {
431
- method: 'POST',
432
- headers: { 'Content-Type': 'application/json' },
433
- body: JSON.stringify(body),
434
- });
435
- expect(res.status).toBe(200);
436
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
437
- 'POST',
438
- '/packages',
439
- body,
440
- expect.any(Object),
441
- expect.objectContaining({ request: expect.anything() }),
442
- '/api',
443
- );
444
- });
445
-
446
- it('GET /api/packages?status=active forwards query params', async () => {
447
- const res = await app.request('/api/packages?status=active');
448
- expect(res.status).toBe(200);
449
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
450
- 'GET',
451
- '/packages',
452
- undefined,
453
- expect.objectContaining({ status: 'active' }),
454
- expect.objectContaining({ request: expect.anything() }),
455
- '/api',
456
- );
457
- });
458
-
459
- it('returns error on dispatch exception', async () => {
460
- mockDispatcher.dispatch.mockRejectedValueOnce(new Error('Service unavailable'));
461
- const res = await app.request('/api/packages');
462
- expect(res.status).toBe(500);
463
- const json = await res.json();
464
- expect(json.success).toBe(false);
465
- expect(json.error.message).toBe('Service unavailable');
466
- });
467
-
468
- it('GET /api/analytics delegates to dispatch()', async () => {
469
- const res = await app.request('/api/analytics');
470
- expect(res.status).toBe(200);
471
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
472
- 'GET',
473
- '/analytics',
474
- undefined,
475
- expect.any(Object),
476
- expect.objectContaining({ request: expect.anything() }),
477
- '/api',
478
- );
479
- });
480
-
481
- it('GET /api/automation delegates to dispatch()', async () => {
482
- const res = await app.request('/api/automation');
483
- expect(res.status).toBe(200);
484
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
485
- 'GET',
486
- '/automation',
487
- undefined,
488
- expect.any(Object),
489
- expect.objectContaining({ request: expect.anything() }),
490
- '/api',
491
- );
492
- });
493
-
494
- it('GET /api/i18n delegates to dispatch()', async () => {
495
- const res = await app.request('/api/i18n');
496
- expect(res.status).toBe(200);
497
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
498
- 'GET',
499
- '/i18n',
500
- undefined,
501
- expect.any(Object),
502
- expect.objectContaining({ request: expect.anything() }),
503
- '/api',
504
- );
505
- });
506
-
507
- it('GET /api/ui/view/account delegates to dispatch()', async () => {
508
- const res = await app.request('/api/ui/view/account');
509
- expect(res.status).toBe(200);
510
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
511
- 'GET',
512
- '/ui/view/account',
513
- undefined,
514
- expect.any(Object),
515
- expect.objectContaining({ request: expect.anything() }),
516
- '/api',
517
- );
518
- });
519
- });
520
-
521
- describe('Error Handling', () => {
522
- it('returns 500 with default message on generic error', async () => {
523
- mockDispatcher.dispatch.mockRejectedValueOnce(new Error());
524
- const res = await app.request('/api/data/account');
525
- expect(res.status).toBe(500);
526
- const json = await res.json();
527
- expect(json.error.message).toBe('Internal Server Error');
528
- });
529
-
530
- it('uses custom statusCode from error', async () => {
531
- mockDispatcher.dispatch.mockRejectedValueOnce(
532
- Object.assign(new Error('Forbidden'), { statusCode: 403 }),
533
- );
534
- const res = await app.request('/api/data/account');
535
- expect(res.status).toBe(403);
536
- const json = await res.json();
537
- expect(json.error.message).toBe('Forbidden');
538
- });
539
- });
540
-
541
- describe('toResponse', () => {
542
- it('handles redirect result', async () => {
543
- mockDispatcher.dispatch.mockResolvedValueOnce({
544
- handled: true,
545
- result: { type: 'redirect', url: 'https://example.com' },
546
- });
547
- const res = await app.request('/api/data/redir', { redirect: 'manual' });
548
- expect(res.status).toBe(302);
549
- expect(res.headers.get('location')).toBe('https://example.com');
550
- });
551
-
552
- it('handles generic result objects with 200 status', async () => {
553
- mockDispatcher.dispatch.mockResolvedValueOnce({
554
- handled: true,
555
- result: { foo: 'bar' },
556
- });
557
- const res = await app.request('/api/data/custom');
558
- expect(res.status).toBe(200);
559
- const json = await res.json();
560
- expect(json.foo).toBe('bar');
561
- });
562
-
563
- it('sets custom headers from response', async () => {
564
- mockDispatcher.dispatch.mockResolvedValueOnce({
565
- handled: true,
566
- response: { status: 201, body: { id: 1 }, headers: { 'X-Custom': 'yes' } },
567
- });
568
- const res = await app.request('/api/data/account', {
569
- method: 'POST',
570
- headers: { 'Content-Type': 'application/json' },
571
- body: JSON.stringify({ name: 'test' }),
572
- });
573
- expect(res.status).toBe(201);
574
- expect(res.headers.get('X-Custom')).toBe('yes');
575
- const json = await res.json();
576
- expect(json.id).toBe(1);
577
- });
578
- });
579
-
580
- describe('Vercel Delegation Pattern (api/index.ts → inner.fetch)', () => {
581
- /**
582
- * Helper: creates the same outer→inner delegation pattern used by
583
- * `apps/studio/api/index.ts`. The outer Hono app delegates all
584
- * requests to the inner ObjectStack Hono app via `inner.fetch()`.
585
- */
586
- function createVercelApp() {
587
- const innerApp = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });
588
- const outerApp = new Hono();
589
- outerApp.all('*', async (c) => {
590
- return innerApp.fetch(c.req.raw);
591
- });
592
- return outerApp;
593
- }
594
-
595
- it('works when an outer Hono app delegates via inner.fetch(c.req.raw)', async () => {
596
- const outerApp = createVercelApp();
597
-
598
- const res = await outerApp.request('/api/v1/meta');
599
- expect(res.status).toBe(200);
600
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
601
- 'GET',
602
- '/meta',
603
- undefined,
604
- expect.any(Object),
605
- expect.objectContaining({ request: expect.anything() }),
606
- '/api/v1',
607
- );
608
- });
609
-
610
- it('routes /api/v1/packages through outer→inner delegation', async () => {
611
- const outerApp = createVercelApp();
612
-
613
- const res = await outerApp.request('/api/v1/packages');
614
- expect(res.status).toBe(200);
615
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
616
- 'GET',
617
- '/packages',
618
- undefined,
619
- expect.any(Object),
620
- expect.objectContaining({ request: expect.anything() }),
621
- '/api/v1',
622
- );
623
- });
624
-
625
- it('routes /api/v1 discovery through outer→inner delegation', async () => {
626
- const outerApp = createVercelApp();
627
-
628
- const res = await outerApp.request('/api/v1');
629
- expect(res.status).toBe(200);
630
- const json = await res.json();
631
- expect(json.data).toBeDefined();
632
- expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api/v1');
633
- });
634
-
635
- it('routes /api/v1/discovery through outer→inner delegation with correct prefix', async () => {
636
- const outerApp = createVercelApp();
637
-
638
- const res = await outerApp.request('/api/v1/discovery');
639
- expect(res.status).toBe(200);
640
- const json = await res.json();
641
- expect(json.data).toBeDefined();
642
- expect(mockDispatcher.getDiscoveryInfo).toHaveBeenCalledWith('/api/v1');
643
- });
644
-
645
- it('routes /api/v1/data/account through outer→inner delegation', async () => {
646
- const outerApp = createVercelApp();
647
-
648
- const res = await outerApp.request('/api/v1/data/account');
649
- expect(res.status).toBe(200);
650
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
651
- 'GET',
652
- '/data/account',
653
- undefined,
654
- expect.any(Object),
655
- expect.objectContaining({ request: expect.anything() }),
656
- '/api/v1',
657
- );
658
- });
659
-
660
- it('POST /api/v1/data/account parses JSON body through outer→inner delegation', async () => {
661
- const outerApp = createVercelApp();
662
- const body = { name: 'Acme' };
663
-
664
- const res = await outerApp.request('/api/v1/data/account', {
665
- method: 'POST',
666
- headers: { 'Content-Type': 'application/json' },
667
- body: JSON.stringify(body),
668
- });
669
- expect(res.status).toBe(200);
670
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
671
- 'POST',
672
- '/data/account',
673
- body,
674
- expect.any(Object),
675
- expect.objectContaining({ request: expect.anything() }),
676
- '/api/v1',
677
- );
678
- });
679
-
680
- it('PUT /api/v1/data/account parses JSON body through outer→inner delegation', async () => {
681
- const outerApp = createVercelApp();
682
- const body = { name: 'Updated' };
683
-
684
- const res = await outerApp.request('/api/v1/data/account', {
685
- method: 'PUT',
686
- headers: { 'Content-Type': 'application/json' },
687
- body: JSON.stringify(body),
688
- });
689
- expect(res.status).toBe(200);
690
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
691
- 'PUT',
692
- '/data/account',
693
- body,
694
- expect.any(Object),
695
- expect.objectContaining({ request: expect.anything() }),
696
- '/api/v1',
697
- );
698
- });
699
-
700
- it('PATCH /api/v1/data/account parses JSON body through outer→inner delegation', async () => {
701
- const outerApp = createVercelApp();
702
- const body = { name: 'Patched' };
703
-
704
- const res = await outerApp.request('/api/v1/data/account', {
705
- method: 'PATCH',
706
- headers: { 'Content-Type': 'application/json' },
707
- body: JSON.stringify(body),
708
- });
709
- expect(res.status).toBe(200);
710
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
711
- 'PATCH',
712
- '/data/account',
713
- body,
714
- expect.any(Object),
715
- expect.objectContaining({ request: expect.anything() }),
716
- '/api/v1',
717
- );
718
- });
719
-
720
- it('DELETE /api/v1/data/account routes through outer→inner delegation', async () => {
721
- const outerApp = createVercelApp();
722
-
723
- const res = await outerApp.request('/api/v1/data/account', {
724
- method: 'DELETE',
725
- });
726
- expect(res.status).toBe(200);
727
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
728
- 'DELETE',
729
- '/data/account',
730
- undefined,
731
- expect.any(Object),
732
- expect.objectContaining({ request: expect.anything() }),
733
- '/api/v1',
734
- );
735
- });
736
-
737
- it('returns 500 with error details when inner app throws', async () => {
738
- const outerApp = new Hono();
739
-
740
- outerApp.all('*', async (c) => {
741
- try {
742
- // Simulate a kernel boot failure
743
- throw new Error('Kernel boot failed');
744
- } catch (err: any) {
745
- return c.json(
746
- { success: false, error: { message: err.message, code: 500 } },
747
- 500,
748
- );
749
- }
750
- });
751
-
752
- const res = await outerApp.request('/api/v1/meta');
753
- expect(res.status).toBe(500);
754
- const json = await res.json();
755
- expect(json.success).toBe(false);
756
- expect(json.error.message).toBe('Kernel boot failed');
757
- });
758
- });
759
-
760
- describe('Body-safe Vercel delegation (buffered body forwarding)', () => {
761
- /**
762
- * Validates the body-safe delegation pattern used in
763
- * `apps/studio/server/index.ts` where the outer handler buffers
764
- * POST/PUT/PATCH bodies and creates a fresh `Request` for the inner app.
765
- * This avoids @hono/node-server's lazy body materialisation which can
766
- * hang on Vercel when the IncomingMessage stream state has changed.
767
- */
768
- function createBodySafeVercelApp() {
769
- const innerApp = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });
770
- const outerApp = new Hono();
771
-
772
- outerApp.all('*', async (c) => {
773
- const method = c.req.method;
774
-
775
- // GET/HEAD have no body — pass through directly
776
- if (method === 'GET' || method === 'HEAD') {
777
- return innerApp.fetch(c.req.raw);
778
- }
779
-
780
- // Buffer body and create a fresh Request
781
- const rawReq = c.req.raw;
782
- const body = await rawReq.arrayBuffer();
783
- const forwarded = new Request(rawReq.url, {
784
- method,
785
- headers: rawReq.headers,
786
- body,
787
- });
788
- return innerApp.fetch(forwarded);
789
- });
790
-
791
- return outerApp;
792
- }
793
-
794
- it('GET requests work without body buffering', async () => {
795
- const outerApp = createBodySafeVercelApp();
796
-
797
- const res = await outerApp.request('/api/v1/data/account');
798
- expect(res.status).toBe(200);
799
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
800
- 'GET',
801
- '/data/account',
802
- undefined,
803
- expect.any(Object),
804
- expect.objectContaining({ request: expect.anything() }),
805
- '/api/v1',
806
- );
807
- });
808
-
809
- it('POST body is forwarded correctly via buffered delegation', async () => {
810
- const outerApp = createBodySafeVercelApp();
811
- const body = { name: 'Acme Corp' };
812
-
813
- const res = await outerApp.request('/api/v1/data/account', {
814
- method: 'POST',
815
- headers: { 'Content-Type': 'application/json' },
816
- body: JSON.stringify(body),
817
- });
818
- expect(res.status).toBe(200);
819
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
820
- 'POST',
821
- '/data/account',
822
- body,
823
- expect.any(Object),
824
- expect.objectContaining({ request: expect.anything() }),
825
- '/api/v1',
826
- );
827
- });
828
-
829
- it('PUT body is forwarded correctly via buffered delegation', async () => {
830
- const outerApp = createBodySafeVercelApp();
831
- const body = { name: 'Updated Corp' };
832
-
833
- const res = await outerApp.request('/api/v1/data/account', {
834
- method: 'PUT',
835
- headers: { 'Content-Type': 'application/json' },
836
- body: JSON.stringify(body),
837
- });
838
- expect(res.status).toBe(200);
839
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
840
- 'PUT',
841
- '/data/account',
842
- body,
843
- expect.any(Object),
844
- expect.objectContaining({ request: expect.anything() }),
845
- '/api/v1',
846
- );
847
- });
848
-
849
- it('PATCH body is forwarded correctly via buffered delegation', async () => {
850
- const outerApp = createBodySafeVercelApp();
851
- const body = { status: 'active' };
852
-
853
- const res = await outerApp.request('/api/v1/data/account', {
854
- method: 'PATCH',
855
- headers: { 'Content-Type': 'application/json' },
856
- body: JSON.stringify(body),
857
- });
858
- expect(res.status).toBe(200);
859
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
860
- 'PATCH',
861
- '/data/account',
862
- body,
863
- expect.any(Object),
864
- expect.objectContaining({ request: expect.anything() }),
865
- '/api/v1',
866
- );
867
- });
868
-
869
- it('DELETE without body works via buffered delegation', async () => {
870
- const outerApp = createBodySafeVercelApp();
871
-
872
- const res = await outerApp.request('/api/v1/data/account', {
873
- method: 'DELETE',
874
- });
875
- expect(res.status).toBe(200);
876
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
877
- 'DELETE',
878
- '/data/account',
879
- undefined,
880
- expect.any(Object),
881
- expect.objectContaining({ request: expect.anything() }),
882
- '/api/v1',
883
- );
884
- });
885
-
886
- it('POST with empty body defaults to {} via buffered delegation', async () => {
887
- const outerApp = createBodySafeVercelApp();
888
-
889
- const res = await outerApp.request('/api/v1/data/account', {
890
- method: 'POST',
891
- headers: { 'Content-Type': 'application/json' },
892
- body: '',
893
- });
894
- expect(res.status).toBe(200);
895
- // Empty body falls back to {} via .catch(() => ({})) in the adapter
896
- expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
897
- 'POST',
898
- '/data/account',
899
- {},
900
- expect.any(Object),
901
- expect.objectContaining({ request: expect.anything() }),
902
- '/api/v1',
903
- );
904
- });
905
- });
906
-
907
- describe('Vercel deployment endpoint smoke tests', () => {
908
- /**
909
- * These tests validate that the two key deployment-health endpoints
910
- * `/api/v1/meta` and `/api/v1/packages` return 200 OK when routed
911
- * through the Vercel adapter pattern (outer Hono → inner ObjectStack Hono).
912
- */
913
- let outerApp: Hono;
914
-
915
- beforeEach(() => {
916
- vi.clearAllMocks();
917
- const innerApp = createHonoApp({ kernel: mockKernel, prefix: '/api/v1' });
918
- outerApp = new Hono();
919
- outerApp.all('*', async (c) => innerApp.fetch(c.req.raw));
920
- });
921
-
922
- it('GET /api/v1/meta returns 200 OK', async () => {
923
- const res = await outerApp.request('/api/v1/meta');
924
- expect(res.status).toBe(200);
925
- const json = await res.json();
926
- expect(json.success).toBe(true);
927
- });
928
-
929
- it('GET /api/v1/meta/object returns 200 OK', async () => {
930
- const res = await outerApp.request('/api/v1/meta/object');
931
- expect(res.status).toBe(200);
932
- const json = await res.json();
933
- expect(json.success).toBe(true);
934
- });
935
-
936
- it('GET /api/v1/packages returns 200 OK', async () => {
937
- const res = await outerApp.request('/api/v1/packages');
938
- expect(res.status).toBe(200);
939
- const json = await res.json();
940
- expect(json.success).toBe(true);
941
- });
942
-
943
- it('GET /api/v1/packages/:id returns 200 OK', async () => {
944
- const res = await outerApp.request('/api/v1/packages/com.acme.crm');
945
- expect(res.status).toBe(200);
946
- const json = await res.json();
947
- expect(json.success).toBe(true);
948
- });
949
- });
950
- });