@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.
- package/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +17 -0
- package/package.json +3 -3
- package/src/metadata-api.test.ts +705 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @objectstack/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
|
[34mCLI[39m Building entry: src/index.ts
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
[34mCJS[39m Build start
|
|
13
13
|
[33mESM[39m [33mYou have emitDecoratorMetadata enabled but @swc/core was not installed, skipping swc plugin[39m
|
|
14
14
|
[33mCJS[39m [33mYou have emitDecoratorMetadata enabled but @swc/core was not installed, skipping swc plugin[39m
|
|
15
|
-
[32mCJS[39m [1mdist/index.js [22m[32m9.76 KB[39m
|
|
16
|
-
[32mCJS[39m [1mdist/index.js.map [22m[32m13.97 KB[39m
|
|
17
|
-
[32mCJS[39m ⚡️ Build success in 44ms
|
|
18
15
|
[32mESM[39m [1mdist/index.mjs [22m[32m8.15 KB[39m
|
|
19
16
|
[32mESM[39m [1mdist/index.mjs.map [22m[32m13.94 KB[39m
|
|
20
|
-
[32mESM[39m ⚡️ Build success in
|
|
17
|
+
[32mESM[39m ⚡️ Build success in 40ms
|
|
18
|
+
[32mCJS[39m [1mdist/index.js [22m[32m9.76 KB[39m
|
|
19
|
+
[32mCJS[39m [1mdist/index.js.map [22m[32m13.97 KB[39m
|
|
20
|
+
[32mCJS[39m ⚡️ Build success in 40ms
|
|
21
21
|
[34mDTS[39m Build start
|
|
22
|
-
[32mDTS[39m ⚡️ Build success in
|
|
22
|
+
[32mDTS[39m ⚡️ Build success in 7845ms
|
|
23
23
|
[32mDTS[39m [1mdist/index.d.mts [22m[32m8.75 KB[39m
|
|
24
24
|
[32mDTS[39m [1mdist/index.d.ts [22m[32m8.75 KB[39m
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|
+
});
|