@objectstack/nestjs 2.0.7 → 3.0.1

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