@objectstack/nextjs 2.0.6 → 3.0.0

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,5 +1,5 @@
1
1
 
2
- > @objectstack/nextjs@2.0.6 build /home/runner/work/spec/spec/packages/adapters/nextjs
2
+ > @objectstack/nextjs@3.0.0 build /home/runner/work/spec/spec/packages/adapters/nextjs
3
3
  > tsup --config ../../../tsup.config.ts
4
4
 
5
5
  CLI Building entry: src/index.ts
@@ -12,11 +12,11 @@
12
12
  CJS Build start
13
13
  ESM dist/index.mjs 4.21 KB
14
14
  ESM dist/index.mjs.map 9.13 KB
15
- ESM ⚡️ Build success in 35ms
15
+ ESM ⚡️ Build success in 48ms
16
16
  CJS dist/index.js 5.39 KB
17
17
  CJS dist/index.js.map 9.17 KB
18
- CJS ⚡️ Build success in 37ms
18
+ CJS ⚡️ Build success in 52ms
19
19
  DTS Build start
20
- DTS ⚡️ Build success in 6556ms
20
+ DTS ⚡️ Build success in 7986ms
21
21
  DTS dist/index.d.mts 764.00 B
22
22
  DTS dist/index.d.ts 764.00 B
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @objectstack/nextjs
2
2
 
3
+ ## 3.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - Release v3.0.0 — unified version bump for all ObjectStack packages.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies
12
+ - @objectstack/runtime@3.0.0
13
+
14
+ ## 2.0.7
15
+
16
+ ### Patch Changes
17
+
18
+ - @objectstack/runtime@2.0.7
19
+
3
20
  ## 2.0.6
4
21
 
5
22
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/nextjs",
3
- "version": "2.0.6",
3
+ "version": "3.0.0",
4
4
  "license": "Apache-2.0",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -8,7 +8,7 @@
8
8
  "next": "^16.1.6",
9
9
  "react": "^19.2.4",
10
10
  "react-dom": "^19.2.4",
11
- "@objectstack/runtime": "2.0.6"
11
+ "@objectstack/runtime": "3.0.0"
12
12
  },
13
13
  "devDependencies": {
14
14
  "next": "^16.1.6",
@@ -16,7 +16,7 @@
16
16
  "react-dom": "^19.2.4",
17
17
  "typescript": "^5.0.0",
18
18
  "vitest": "^4.0.18",
19
- "@objectstack/runtime": "2.0.6"
19
+ "@objectstack/runtime": "3.0.0"
20
20
  },
21
21
  "scripts": {
22
22
  "build": "tsup --config ../../../tsup.config.ts",
@@ -0,0 +1,705 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * @objectstack/nextjs — Comprehensive Metadata API Integration Tests
5
+ *
6
+ * Validates that the Next.js adapter correctly routes ALL metadata API operations
7
+ * defined by the @objectstack/metadata package through the HttpDispatcher.
8
+ *
9
+ * Covers: CRUD, Query, Bulk, Overlay, Import/Export, Validation, Type Registry, Dependencies
10
+ */
11
+
12
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
13
+
14
+ // Mock dispatcher instance
15
+ const mockDispatcher = {
16
+ getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', endpoints: [] }),
17
+ handleAuth: vi.fn().mockResolvedValue({ handled: true, response: { body: { ok: true }, status: 200 } }),
18
+ handleGraphQL: vi.fn().mockResolvedValue({ data: {} }),
19
+ handleMetadata: vi.fn().mockResolvedValue({ handled: true, response: { body: { success: true }, status: 200 } }),
20
+ handleData: vi.fn().mockResolvedValue({ handled: true, response: { body: { records: [] }, status: 200 } }),
21
+ handleStorage: vi.fn().mockResolvedValue({ handled: true, response: { body: {}, status: 200 } }),
22
+ };
23
+
24
+ vi.mock('@objectstack/runtime', () => {
25
+ return {
26
+ HttpDispatcher: function HttpDispatcher() {
27
+ return mockDispatcher;
28
+ },
29
+ };
30
+ });
31
+
32
+ vi.mock('next/server', () => {
33
+ class MockNextRequest {
34
+ url: string;
35
+ method: string;
36
+ private _body: any;
37
+
38
+ constructor(url: string, init?: any) {
39
+ this.url = url;
40
+ this.method = init?.method || 'GET';
41
+ this._body = init?.body;
42
+ }
43
+
44
+ async json() {
45
+ return this._body ? JSON.parse(this._body) : {};
46
+ }
47
+
48
+ async formData() {
49
+ const map = new Map();
50
+ map.set('file', { name: 'test.txt', type: 'text/plain' });
51
+ return { get: (key: string) => map.get(key) };
52
+ }
53
+ }
54
+
55
+ class MockNextResponse {
56
+ body: any;
57
+ status: number;
58
+ headers: Record<string, string>;
59
+
60
+ constructor(body?: any, init?: any) {
61
+ this.body = body;
62
+ this.status = init?.status || 200;
63
+ this.headers = init?.headers || {};
64
+ }
65
+
66
+ async json() {
67
+ return typeof this.body === 'string' ? JSON.parse(this.body) : this.body;
68
+ }
69
+
70
+ static json(body: any, init?: any) {
71
+ return new MockNextResponse(body, init);
72
+ }
73
+
74
+ static redirect(url: string | URL) {
75
+ const res = new MockNextResponse(null, { status: 307 });
76
+ (res as any).redirectUrl = typeof url === 'string' ? url : url.toString();
77
+ return res;
78
+ }
79
+ }
80
+
81
+ return { NextRequest: MockNextRequest, NextResponse: MockNextResponse };
82
+ });
83
+
84
+ import { NextRequest } from 'next/server';
85
+ import { createRouteHandler } from './index';
86
+
87
+ const mockKernel = { name: 'test-kernel' } as any;
88
+
89
+ function makeReq(url: string, method = 'GET', body?: any) {
90
+ const init: any = { method };
91
+ if (body) init.body = JSON.stringify(body);
92
+ return new (NextRequest as any)(url, init);
93
+ }
94
+
95
+ describe('Next.js Metadata API Integration Tests', () => {
96
+ let handler: ReturnType<typeof createRouteHandler>;
97
+
98
+ beforeEach(() => {
99
+ vi.clearAllMocks();
100
+ handler = createRouteHandler({ kernel: mockKernel });
101
+ });
102
+
103
+ // ==========================================
104
+ // CRUD Operations
105
+ // ==========================================
106
+
107
+ describe('CRUD Operations', () => {
108
+ describe('GET meta/objects — List all objects', () => {
109
+ it('dispatches to handleMetadata with correct path', async () => {
110
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({
111
+ handled: true,
112
+ response: {
113
+ body: {
114
+ success: true,
115
+ data: [
116
+ { name: 'account', label: 'Account' },
117
+ { name: 'contact', label: 'Contact' },
118
+ ],
119
+ },
120
+ status: 200,
121
+ },
122
+ });
123
+
124
+ const req = makeReq('http://localhost/api/meta/objects');
125
+ const res = await handler(req, { params: { objectstack: ['meta', 'objects'] } });
126
+ expect(res.status).toBe(200);
127
+ expect(res.body.data).toHaveLength(2);
128
+ expect(mockDispatcher.handleMetadata).toHaveBeenCalledWith(
129
+ 'objects',
130
+ expect.objectContaining({ request: expect.anything() }),
131
+ 'GET',
132
+ undefined,
133
+ );
134
+ });
135
+ });
136
+
137
+ describe('GET meta/objects/account — Get single object', () => {
138
+ it('dispatches to handleMetadata with item-level path', async () => {
139
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({
140
+ handled: true,
141
+ response: {
142
+ body: {
143
+ success: true,
144
+ data: { type: 'object', name: 'account', definition: { label: 'Account' } },
145
+ },
146
+ status: 200,
147
+ },
148
+ });
149
+
150
+ const req = makeReq('http://localhost/api/meta/objects/account');
151
+ const res = await handler(req, { params: { objectstack: ['meta', 'objects', 'account'] } });
152
+ expect(res.status).toBe(200);
153
+ expect(res.body.data.name).toBe('account');
154
+ expect(mockDispatcher.handleMetadata).toHaveBeenCalledWith(
155
+ 'objects/account',
156
+ expect.objectContaining({ request: expect.anything() }),
157
+ 'GET',
158
+ undefined,
159
+ );
160
+ });
161
+ });
162
+
163
+ describe('POST meta/objects — Register metadata', () => {
164
+ it('dispatches POST with JSON body', async () => {
165
+ const body = {
166
+ type: 'object',
167
+ name: 'project_task',
168
+ data: { label: 'Project Task', fields: {} },
169
+ };
170
+
171
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({
172
+ handled: true,
173
+ response: { body: { success: true }, status: 201 },
174
+ });
175
+
176
+ const req = makeReq('http://localhost/api/meta/objects', 'POST', body);
177
+ const res = await handler(req, { params: { objectstack: ['meta', 'objects'] } });
178
+ expect(res.status).toBe(201);
179
+ expect(mockDispatcher.handleMetadata).toHaveBeenCalledWith(
180
+ 'objects',
181
+ expect.objectContaining({ request: expect.anything() }),
182
+ 'POST',
183
+ body,
184
+ );
185
+ });
186
+ });
187
+
188
+ describe('PUT meta/objects/account — Update metadata', () => {
189
+ it('dispatches PUT with JSON body', async () => {
190
+ const body = { label: 'Updated Account' };
191
+
192
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({
193
+ handled: true,
194
+ response: { body: { success: true }, status: 200 },
195
+ });
196
+
197
+ const req = makeReq('http://localhost/api/meta/objects/account', 'PUT', body);
198
+ const res = await handler(req, { params: { objectstack: ['meta', 'objects', 'account'] } });
199
+ expect(res.status).toBe(200);
200
+ expect(mockDispatcher.handleMetadata).toHaveBeenCalledWith(
201
+ 'objects/account',
202
+ expect.objectContaining({ request: expect.anything() }),
203
+ 'PUT',
204
+ body,
205
+ );
206
+ });
207
+ });
208
+
209
+ describe('DELETE meta/objects/old_entity — Delete metadata', () => {
210
+ it('dispatches DELETE to handleMetadata', async () => {
211
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({
212
+ handled: true,
213
+ response: {
214
+ body: { success: true, data: { type: 'object', name: 'old_entity' } },
215
+ status: 200,
216
+ },
217
+ });
218
+
219
+ const req = makeReq('http://localhost/api/meta/objects/old_entity', 'DELETE');
220
+ const res = await handler(req, { params: { objectstack: ['meta', 'objects', 'old_entity'] } });
221
+ expect(res.status).toBe(200);
222
+ expect(res.body.data.name).toBe('old_entity');
223
+ });
224
+ });
225
+
226
+ describe('Multiple metadata types', () => {
227
+ it('dispatches for views', async () => {
228
+ const req = makeReq('http://localhost/api/meta/views');
229
+ await handler(req, { params: { objectstack: ['meta', 'views'] } });
230
+ expect(mockDispatcher.handleMetadata).toHaveBeenCalledWith(
231
+ 'views',
232
+ expect.objectContaining({ request: expect.anything() }),
233
+ 'GET',
234
+ undefined,
235
+ );
236
+ });
237
+
238
+ it('dispatches for flows', async () => {
239
+ const req = makeReq('http://localhost/api/meta/flows');
240
+ await handler(req, { params: { objectstack: ['meta', 'flows'] } });
241
+ expect(mockDispatcher.handleMetadata).toHaveBeenCalledWith(
242
+ 'flows',
243
+ expect.objectContaining({ request: expect.anything() }),
244
+ 'GET',
245
+ undefined,
246
+ );
247
+ });
248
+
249
+ it('dispatches for agents', async () => {
250
+ const req = makeReq('http://localhost/api/meta/agents');
251
+ await handler(req, { params: { objectstack: ['meta', 'agents'] } });
252
+ expect(mockDispatcher.handleMetadata).toHaveBeenCalledWith(
253
+ 'agents',
254
+ expect.objectContaining({ request: expect.anything() }),
255
+ 'GET',
256
+ undefined,
257
+ );
258
+ });
259
+ });
260
+ });
261
+
262
+ // ==========================================
263
+ // Query / Search
264
+ // ==========================================
265
+
266
+ describe('Query / Search', () => {
267
+ describe('POST meta/query — Advanced search', () => {
268
+ it('dispatches query with full filter payload', async () => {
269
+ const queryBody = {
270
+ types: ['object', 'view'],
271
+ search: 'account',
272
+ page: 1,
273
+ pageSize: 25,
274
+ };
275
+
276
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({
277
+ handled: true,
278
+ response: {
279
+ body: {
280
+ success: true,
281
+ data: {
282
+ items: [{ type: 'object', name: 'account' }],
283
+ total: 1,
284
+ page: 1,
285
+ pageSize: 25,
286
+ },
287
+ },
288
+ status: 200,
289
+ },
290
+ });
291
+
292
+ const req = makeReq('http://localhost/api/meta/query', 'POST', queryBody);
293
+ const res = await handler(req, { params: { objectstack: ['meta', 'query'] } });
294
+
295
+ expect(res.status).toBe(200);
296
+ expect(res.body.data.items).toHaveLength(1);
297
+ });
298
+ });
299
+ });
300
+
301
+ // ==========================================
302
+ // Bulk Operations
303
+ // ==========================================
304
+
305
+ describe('Bulk Operations', () => {
306
+ describe('POST meta/bulk/register — Bulk register', () => {
307
+ it('dispatches bulk register', async () => {
308
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({
309
+ handled: true,
310
+ response: {
311
+ body: { success: true, data: { total: 2, succeeded: 2, failed: 0 } },
312
+ status: 200,
313
+ },
314
+ });
315
+
316
+ const req = makeReq('http://localhost/api/meta/bulk/register', 'POST', {
317
+ items: [
318
+ { type: 'object', name: 'customer', data: {} },
319
+ { type: 'view', name: 'customer_list', data: {} },
320
+ ],
321
+ });
322
+ const res = await handler(req, { params: { objectstack: ['meta', 'bulk', 'register'] } });
323
+
324
+ expect(res.status).toBe(200);
325
+ expect(res.body.data.succeeded).toBe(2);
326
+ });
327
+ });
328
+
329
+ describe('POST meta/bulk/unregister — Bulk unregister', () => {
330
+ it('dispatches bulk unregister', async () => {
331
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({
332
+ handled: true,
333
+ response: {
334
+ body: { success: true, data: { total: 2, succeeded: 2, failed: 0 } },
335
+ status: 200,
336
+ },
337
+ });
338
+
339
+ const req = makeReq('http://localhost/api/meta/bulk/unregister', 'POST', {
340
+ items: [{ type: 'object', name: 'old' }, { type: 'view', name: 'old_view' }],
341
+ });
342
+ const res = await handler(req, { params: { objectstack: ['meta', 'bulk', 'unregister'] } });
343
+
344
+ expect(res.status).toBe(200);
345
+ expect(res.body.data.succeeded).toBe(2);
346
+ });
347
+ });
348
+
349
+ describe('Bulk operation with partial failures', () => {
350
+ it('returns error details', async () => {
351
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({
352
+ handled: true,
353
+ response: {
354
+ body: {
355
+ success: true,
356
+ data: {
357
+ total: 3,
358
+ succeeded: 2,
359
+ failed: 1,
360
+ errors: [{ type: 'object', name: 'bad', error: 'Validation failed' }],
361
+ },
362
+ },
363
+ status: 200,
364
+ },
365
+ });
366
+
367
+ const req = makeReq('http://localhost/api/meta/bulk/register', 'POST', {
368
+ items: [
369
+ { type: 'object', name: 'good', data: {} },
370
+ { type: 'object', name: 'good2', data: {} },
371
+ { type: 'object', name: 'bad', data: {} },
372
+ ],
373
+ continueOnError: true,
374
+ });
375
+ const res = await handler(req, { params: { objectstack: ['meta', 'bulk', 'register'] } });
376
+
377
+ expect(res.body.data.failed).toBe(1);
378
+ expect(res.body.data.errors[0].name).toBe('bad');
379
+ });
380
+ });
381
+ });
382
+
383
+ // ==========================================
384
+ // Overlay / Customization
385
+ // ==========================================
386
+
387
+ describe('Overlay / Customization', () => {
388
+ describe('GET meta/objects/account/overlay — Get overlay', () => {
389
+ it('dispatches overlay retrieval', async () => {
390
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({
391
+ handled: true,
392
+ response: {
393
+ body: {
394
+ success: true,
395
+ data: {
396
+ id: 'overlay-001',
397
+ baseType: 'object',
398
+ baseName: 'account',
399
+ scope: 'platform',
400
+ patch: {},
401
+ },
402
+ },
403
+ status: 200,
404
+ },
405
+ });
406
+
407
+ const req = makeReq('http://localhost/api/meta/objects/account/overlay');
408
+ const res = await handler(req, { params: { objectstack: ['meta', 'objects', 'account', 'overlay'] } });
409
+ expect(res.status).toBe(200);
410
+ expect(res.body.data.scope).toBe('platform');
411
+ });
412
+ });
413
+
414
+ describe('PUT meta/objects/account/overlay — Save overlay', () => {
415
+ it('dispatches overlay save', async () => {
416
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({
417
+ handled: true,
418
+ response: { body: { success: true }, status: 200 },
419
+ });
420
+
421
+ const req = makeReq('http://localhost/api/meta/objects/account/overlay', 'PUT', {
422
+ id: 'overlay-002',
423
+ baseType: 'object',
424
+ baseName: 'account',
425
+ patch: { fields: { status: { label: 'Custom' } } },
426
+ });
427
+ const res = await handler(req, { params: { objectstack: ['meta', 'objects', 'account', 'overlay'] } });
428
+ expect(res.status).toBe(200);
429
+ });
430
+ });
431
+
432
+ describe('GET meta/objects/account/effective — Get effective metadata', () => {
433
+ it('dispatches effective metadata retrieval', async () => {
434
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({
435
+ handled: true,
436
+ response: {
437
+ body: {
438
+ success: true,
439
+ data: { name: 'account', fields: { status: { label: 'Custom Status' } } },
440
+ },
441
+ status: 200,
442
+ },
443
+ });
444
+
445
+ const req = makeReq('http://localhost/api/meta/objects/account/effective');
446
+ const res = await handler(req, { params: { objectstack: ['meta', 'objects', 'account', 'effective'] } });
447
+ expect(res.status).toBe(200);
448
+ expect(res.body.data.fields.status.label).toBe('Custom Status');
449
+ });
450
+ });
451
+ });
452
+
453
+ // ==========================================
454
+ // Import / Export
455
+ // ==========================================
456
+
457
+ describe('Import / Export', () => {
458
+ describe('POST meta/export — Export metadata', () => {
459
+ it('dispatches export request', async () => {
460
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({
461
+ handled: true,
462
+ response: {
463
+ body: { success: true, data: { version: '1.0', objects: {} } },
464
+ status: 200,
465
+ },
466
+ });
467
+
468
+ const req = makeReq('http://localhost/api/meta/export', 'POST', { types: ['object'], format: 'json' });
469
+ const res = await handler(req, { params: { objectstack: ['meta', 'export'] } });
470
+ expect(res.status).toBe(200);
471
+ expect(res.body.data.version).toBe('1.0');
472
+ });
473
+ });
474
+
475
+ describe('POST meta/import — Import metadata', () => {
476
+ it('dispatches import request', async () => {
477
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({
478
+ handled: true,
479
+ response: {
480
+ body: { success: true, data: { total: 3, imported: 3, skipped: 0, failed: 0 } },
481
+ status: 200,
482
+ },
483
+ });
484
+
485
+ const req = makeReq('http://localhost/api/meta/import', 'POST', {
486
+ data: { objects: { a: {} } },
487
+ conflictResolution: 'merge',
488
+ });
489
+ const res = await handler(req, { params: { objectstack: ['meta', 'import'] } });
490
+ expect(res.status).toBe(200);
491
+ expect(res.body.data.imported).toBe(3);
492
+ });
493
+ });
494
+ });
495
+
496
+ // ==========================================
497
+ // Validation
498
+ // ==========================================
499
+
500
+ describe('Validation', () => {
501
+ describe('POST meta/validate — Validate metadata', () => {
502
+ it('dispatches validation', async () => {
503
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({
504
+ handled: true,
505
+ response: {
506
+ body: { success: true, data: { valid: true } },
507
+ status: 200,
508
+ },
509
+ });
510
+
511
+ const req = makeReq('http://localhost/api/meta/validate', 'POST', { type: 'object', data: {} });
512
+ const res = await handler(req, { params: { objectstack: ['meta', 'validate'] } });
513
+ expect(res.status).toBe(200);
514
+ expect(res.body.data.valid).toBe(true);
515
+ });
516
+
517
+ it('returns errors for invalid metadata', async () => {
518
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({
519
+ handled: true,
520
+ response: {
521
+ body: {
522
+ success: true,
523
+ data: {
524
+ valid: false,
525
+ errors: [{ path: 'name', message: 'Required', code: 'required' }],
526
+ },
527
+ },
528
+ status: 200,
529
+ },
530
+ });
531
+
532
+ const req = makeReq('http://localhost/api/meta/validate', 'POST', { type: 'object', data: {} });
533
+ const res = await handler(req, { params: { objectstack: ['meta', 'validate'] } });
534
+ expect(res.body.data.valid).toBe(false);
535
+ expect(res.body.data.errors).toHaveLength(1);
536
+ });
537
+ });
538
+ });
539
+
540
+ // ==========================================
541
+ // Type Registry
542
+ // ==========================================
543
+
544
+ describe('Type Registry', () => {
545
+ describe('GET meta/types — List types', () => {
546
+ it('returns all registered types', async () => {
547
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({
548
+ handled: true,
549
+ response: {
550
+ body: { success: true, data: ['object', 'view', 'flow', 'agent'] },
551
+ status: 200,
552
+ },
553
+ });
554
+
555
+ const req = makeReq('http://localhost/api/meta/types');
556
+ const res = await handler(req, { params: { objectstack: ['meta', 'types'] } });
557
+ expect(res.status).toBe(200);
558
+ expect(res.body.data).toContain('object');
559
+ });
560
+ });
561
+
562
+ describe('GET meta/types/object — Get type info', () => {
563
+ it('returns type metadata', async () => {
564
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({
565
+ handled: true,
566
+ response: {
567
+ body: {
568
+ success: true,
569
+ data: {
570
+ type: 'object',
571
+ label: 'Object',
572
+ filePatterns: ['**/*.object.ts'],
573
+ supportsOverlay: true,
574
+ domain: 'data',
575
+ },
576
+ },
577
+ status: 200,
578
+ },
579
+ });
580
+
581
+ const req = makeReq('http://localhost/api/meta/types/object');
582
+ const res = await handler(req, { params: { objectstack: ['meta', 'types', 'object'] } });
583
+ expect(res.status).toBe(200);
584
+ expect(res.body.data.domain).toBe('data');
585
+ });
586
+ });
587
+ });
588
+
589
+ // ==========================================
590
+ // Dependency Tracking
591
+ // ==========================================
592
+
593
+ describe('Dependency Tracking', () => {
594
+ describe('GET meta/objects/account/dependencies — Get dependencies', () => {
595
+ it('returns dependencies', async () => {
596
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({
597
+ handled: true,
598
+ response: {
599
+ body: {
600
+ success: true,
601
+ data: [{
602
+ sourceType: 'object',
603
+ sourceName: 'account',
604
+ targetType: 'object',
605
+ targetName: 'organization',
606
+ kind: 'reference',
607
+ }],
608
+ },
609
+ status: 200,
610
+ },
611
+ });
612
+
613
+ const req = makeReq('http://localhost/api/meta/objects/account/dependencies');
614
+ const res = await handler(req, { params: { objectstack: ['meta', 'objects', 'account', 'dependencies'] } });
615
+ expect(res.status).toBe(200);
616
+ expect(res.body.data).toHaveLength(1);
617
+ });
618
+ });
619
+
620
+ describe('GET meta/objects/account/dependents — Get dependents', () => {
621
+ it('returns dependents', async () => {
622
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({
623
+ handled: true,
624
+ response: {
625
+ body: {
626
+ success: true,
627
+ data: [
628
+ { sourceType: 'view', sourceName: 'account_list', targetType: 'object', targetName: 'account', kind: 'reference' },
629
+ { sourceType: 'flow', sourceName: 'new_account', targetType: 'object', targetName: 'account', kind: 'triggers' },
630
+ ],
631
+ },
632
+ status: 200,
633
+ },
634
+ });
635
+
636
+ const req = makeReq('http://localhost/api/meta/objects/account/dependents');
637
+ const res = await handler(req, { params: { objectstack: ['meta', 'objects', 'account', 'dependents'] } });
638
+ expect(res.status).toBe(200);
639
+ expect(res.body.data).toHaveLength(2);
640
+ });
641
+ });
642
+ });
643
+
644
+ // ==========================================
645
+ // Error Handling
646
+ // ==========================================
647
+
648
+ describe('Error Handling', () => {
649
+ it('returns 404 when metadata not found', async () => {
650
+ mockDispatcher.handleMetadata.mockResolvedValueOnce({ handled: false });
651
+
652
+ const req = makeReq('http://localhost/api/meta/objects/nonexistent');
653
+ const res = await handler(req, { params: { objectstack: ['meta', 'objects', 'nonexistent'] } });
654
+ expect(res.status).toBe(404);
655
+ });
656
+
657
+ it('returns 500 on dispatcher exception', async () => {
658
+ mockDispatcher.handleMetadata.mockRejectedValueOnce(new Error('Internal error'));
659
+
660
+ const req = makeReq('http://localhost/api/meta/objects');
661
+ const res = await handler(req, { params: { objectstack: ['meta', 'objects'] } });
662
+ expect(res.status).toBe(500);
663
+ expect(res.body.error.message).toBe('Internal error');
664
+ });
665
+
666
+ it('returns custom status code from error', async () => {
667
+ mockDispatcher.handleMetadata.mockRejectedValueOnce(
668
+ Object.assign(new Error('Forbidden'), { statusCode: 403 }),
669
+ );
670
+
671
+ const req = makeReq('http://localhost/api/meta/objects');
672
+ const res = await handler(req, { params: { objectstack: ['meta', 'objects'] } });
673
+ expect(res.status).toBe(403);
674
+ });
675
+ });
676
+
677
+ // ==========================================
678
+ // Path Parsing
679
+ // ==========================================
680
+
681
+ describe('Path Parsing', () => {
682
+ it('correctly joins nested segments', async () => {
683
+ const req = makeReq('http://localhost/api/meta/objects/account/fields/name');
684
+ await handler(req, { params: { objectstack: ['meta', 'objects', 'account', 'fields', 'name'] } });
685
+ expect(mockDispatcher.handleMetadata).toHaveBeenCalledWith(
686
+ 'objects/account/fields/name',
687
+ expect.any(Object),
688
+ 'GET',
689
+ undefined,
690
+ );
691
+ });
692
+
693
+ it('handles single segment meta path', async () => {
694
+ const req = makeReq('http://localhost/api/meta');
695
+ // With just ['meta'], subPath becomes empty after slice(1)
696
+ await handler(req, { params: { objectstack: ['meta'] } });
697
+ expect(mockDispatcher.handleMetadata).toHaveBeenCalledWith(
698
+ '',
699
+ expect.any(Object),
700
+ 'GET',
701
+ undefined,
702
+ );
703
+ });
704
+ });
705
+ });