@objectstack/runtime 1.0.4 → 1.0.6

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,539 +0,0 @@
1
- import { getEnv } from '@objectstack/core';
2
- import { CoreServiceName } from '@objectstack/spec/system';
3
- export class HttpDispatcher {
4
- constructor(kernel) {
5
- this.kernel = kernel;
6
- }
7
- success(data, meta) {
8
- return {
9
- status: 200,
10
- body: { success: true, data, meta }
11
- };
12
- }
13
- error(message, code = 500, details) {
14
- return {
15
- status: code,
16
- body: { success: false, error: { message, code, details } }
17
- };
18
- }
19
- ensureBroker() {
20
- if (!this.kernel.broker) {
21
- throw { statusCode: 500, message: 'Kernel Broker not available' };
22
- }
23
- return this.kernel.broker;
24
- }
25
- /**
26
- * Generates the discovery JSON response for the API root
27
- */
28
- getDiscoveryInfo(prefix) {
29
- const services = this.getServicesMap();
30
- const hasGraphQL = !!(services[CoreServiceName.enum.graphql] || this.kernel.graphql);
31
- const hasSearch = !!services[CoreServiceName.enum.search];
32
- const hasWebSockets = !!services[CoreServiceName.enum.realtime];
33
- const hasFiles = !!(services[CoreServiceName.enum['file-storage']] || services['storage']?.supportsFiles);
34
- const hasAnalytics = !!services[CoreServiceName.enum.analytics];
35
- const hasHub = !!services[CoreServiceName.enum.hub];
36
- return {
37
- name: 'ObjectOS',
38
- version: '1.0.0',
39
- environment: getEnv('NODE_ENV', 'development'),
40
- routes: {
41
- data: `${prefix}/data`,
42
- metadata: `${prefix}/metadata`,
43
- auth: `${prefix}/auth`,
44
- graphql: hasGraphQL ? `${prefix}/graphql` : undefined,
45
- storage: hasFiles ? `${prefix}/storage` : undefined,
46
- analytics: hasAnalytics ? `${prefix}/analytics` : undefined,
47
- hub: hasHub ? `${prefix}/hub` : undefined,
48
- },
49
- features: {
50
- graphql: hasGraphQL,
51
- search: hasSearch,
52
- websockets: hasWebSockets,
53
- files: hasFiles,
54
- analytics: hasAnalytics,
55
- hub: hasHub,
56
- },
57
- locale: {
58
- default: 'en',
59
- supported: ['en', 'zh-CN'],
60
- timezone: 'UTC'
61
- }
62
- };
63
- }
64
- /**
65
- * Handles GraphQL requests
66
- */
67
- async handleGraphQL(body, context) {
68
- if (!body || !body.query) {
69
- throw { statusCode: 400, message: 'Missing query in request body' };
70
- }
71
- if (typeof this.kernel.graphql !== 'function') {
72
- throw { statusCode: 501, message: 'GraphQL service not available' };
73
- }
74
- return this.kernel.graphql(body.query, body.variables, {
75
- request: context.request
76
- });
77
- }
78
- /**
79
- * Handles Auth requests
80
- * path: sub-path after /auth/
81
- */
82
- async handleAuth(path, method, body, context) {
83
- // 1. Try generic Auth Service
84
- const authService = this.getService(CoreServiceName.enum.auth);
85
- if (authService && typeof authService.handler === 'function') {
86
- const response = await authService.handler(context.request, context.response);
87
- return { handled: true, result: response };
88
- }
89
- // 2. Legacy Login
90
- const normalizedPath = path.replace(/^\/+/, '');
91
- if (normalizedPath === 'login' && method.toUpperCase() === 'POST') {
92
- const broker = this.ensureBroker();
93
- const data = await broker.call('auth.login', body, { request: context.request });
94
- return { handled: true, response: { status: 200, body: data } };
95
- }
96
- return { handled: false };
97
- }
98
- /**
99
- * Handles Metadata requests
100
- * Standard: /metadata/:type/:name
101
- * Fallback for backward compat: /metadata (all objects), /metadata/:objectName (get object)
102
- */
103
- async handleMetadata(path, context, method, body) {
104
- const broker = this.ensureBroker();
105
- const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
106
- // GET /metadata/types
107
- if (parts[0] === 'types') {
108
- // This would normally come from a registry service
109
- // For now we mock the types supported by core
110
- return { handled: true, response: this.success({ types: ['objects', 'apps', 'plugins'] }) };
111
- }
112
- // /metadata/:type/:name
113
- if (parts.length === 2) {
114
- const [type, name] = parts;
115
- // PUT /metadata/:type/:name (Save)
116
- if (method === 'PUT' && body) {
117
- // Try to get the protocol service directly
118
- const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
119
- if (protocol && typeof protocol.saveMetaItem === 'function') {
120
- try {
121
- const result = await protocol.saveMetaItem({ type, name, item: body });
122
- return { handled: true, response: this.success(result) };
123
- }
124
- catch (e) {
125
- return { handled: true, response: this.error(e.message, 400) };
126
- }
127
- }
128
- // Fallback to broker if protocol not available (legacy)
129
- try {
130
- const data = await broker.call('metadata.saveItem', { type, name, item: body }, { request: context.request });
131
- return { handled: true, response: this.success(data) };
132
- }
133
- catch (e) {
134
- // If broker doesn't support it either
135
- return { handled: true, response: this.error(e.message || 'Save not supported', 501) };
136
- }
137
- }
138
- try {
139
- // Try specific calls based on type
140
- if (type === 'objects') {
141
- const data = await broker.call('metadata.getObject', { objectName: name }, { request: context.request });
142
- return { handled: true, response: this.success(data) };
143
- }
144
- // Generic call for other types if supported
145
- const data = await broker.call(`metadata.get${this.capitalize(type.slice(0, -1))}`, { name }, { request: context.request });
146
- return { handled: true, response: this.success(data) };
147
- }
148
- catch (e) {
149
- // Fallback: treat first part as object name if only 1 part (handled below)
150
- // But here we are deep in 2 parts. Must be an error.
151
- return { handled: true, response: this.error(e.message, 404) };
152
- }
153
- }
154
- // GET /metadata/:type (List items of type) OR /metadata/:objectName (Legacy)
155
- if (parts.length === 1) {
156
- const typeOrName = parts[0];
157
- // Heuristic: if it maps to a known type, list it. Else treat as object name.
158
- if (['objects', 'apps', 'plugins'].includes(typeOrName)) {
159
- if (typeOrName === 'objects') {
160
- const data = await broker.call('metadata.objects', {}, { request: context.request });
161
- return { handled: true, response: this.success(data) };
162
- }
163
- // Try generic list
164
- const data = await broker.call(`metadata.${typeOrName}`, {}, { request: context.request });
165
- return { handled: true, response: this.success(data) };
166
- }
167
- // Legacy: /metadata/:objectName
168
- const data = await broker.call('metadata.getObject', { objectName: typeOrName }, { request: context.request });
169
- return { handled: true, response: this.success(data) };
170
- }
171
- // GET /metadata (List Objects - Default)
172
- if (parts.length === 0) {
173
- const data = await broker.call('metadata.objects', {}, { request: context.request });
174
- return { handled: true, response: this.success(data) };
175
- }
176
- return { handled: false };
177
- }
178
- /**
179
- * Handles Data requests
180
- * path: sub-path after /data/ (e.g. "contacts", "contacts/123", "contacts/query")
181
- */
182
- async handleData(path, method, body, query, context) {
183
- const broker = this.ensureBroker();
184
- const parts = path.replace(/^\/+/, '').split('/');
185
- const objectName = parts[0];
186
- if (!objectName) {
187
- return { handled: true, response: this.error('Object name required', 400) };
188
- }
189
- const m = method.toUpperCase();
190
- // 1. Custom Actions (query, batch)
191
- if (parts.length > 1) {
192
- const action = parts[1];
193
- // POST /data/:object/query
194
- if (action === 'query' && m === 'POST') {
195
- const result = await broker.call('data.query', { object: objectName, ...body }, { request: context.request });
196
- return { handled: true, response: this.success(result.data, { count: result.count, limit: body.limit, skip: body.skip }) };
197
- }
198
- // POST /data/:object/batch
199
- if (action === 'batch' && m === 'POST') {
200
- // Spec complaint: forward the whole body { operation, records, options }
201
- // Implementation in Kernel should handle the 'operation' field
202
- const result = await broker.call('data.batch', { object: objectName, ...body }, { request: context.request });
203
- return { handled: true, response: this.success(result) };
204
- }
205
- // GET /data/:object/:id
206
- if (parts.length === 2 && m === 'GET') {
207
- const id = parts[1];
208
- const data = await broker.call('data.get', { object: objectName, id, ...query }, { request: context.request });
209
- return { handled: true, response: this.success(data) };
210
- }
211
- // PATCH /data/:object/:id
212
- if (parts.length === 2 && m === 'PATCH') {
213
- const id = parts[1];
214
- const data = await broker.call('data.update', { object: objectName, id, data: body }, { request: context.request });
215
- return { handled: true, response: this.success(data) };
216
- }
217
- // DELETE /data/:object/:id
218
- if (parts.length === 2 && m === 'DELETE') {
219
- const id = parts[1];
220
- await broker.call('data.delete', { object: objectName, id }, { request: context.request });
221
- return { handled: true, response: this.success({ id, deleted: true }) };
222
- }
223
- }
224
- else {
225
- // GET /data/:object (List)
226
- if (m === 'GET') {
227
- const result = await broker.call('data.query', { object: objectName, filters: query }, { request: context.request });
228
- return { handled: true, response: this.success(result.data, { count: result.count }) };
229
- }
230
- // POST /data/:object (Create)
231
- if (m === 'POST') {
232
- const data = await broker.call('data.create', { object: objectName, data: body }, { request: context.request });
233
- // Note: ideally 201
234
- const res = this.success(data);
235
- res.status = 201;
236
- return { handled: true, response: res };
237
- }
238
- }
239
- return { handled: false };
240
- }
241
- /**
242
- * Handles Analytics requests
243
- * path: sub-path after /analytics/
244
- */
245
- async handleAnalytics(path, method, body, context) {
246
- const analyticsService = this.getService(CoreServiceName.enum.analytics);
247
- if (!analyticsService)
248
- return { handled: false }; // 404 handled by caller if unhandled
249
- const m = method.toUpperCase();
250
- const subPath = path.replace(/^\/+/, '');
251
- // POST /analytics/query
252
- if (subPath === 'query' && m === 'POST') {
253
- const result = await analyticsService.query(body, { request: context.request });
254
- return { handled: true, response: this.success(result) };
255
- }
256
- // GET /analytics/meta
257
- if (subPath === 'meta' && m === 'GET') {
258
- const result = await analyticsService.getMetadata({ request: context.request });
259
- return { handled: true, response: this.success(result) };
260
- }
261
- // POST /analytics/sql (Dry-run or debug)
262
- if (subPath === 'sql' && m === 'POST') {
263
- // Assuming service has generateSql method
264
- const result = await analyticsService.generateSql(body, { request: context.request });
265
- return { handled: true, response: this.success(result) };
266
- }
267
- return { handled: false };
268
- }
269
- /**
270
- * Handles Hub requests
271
- * path: sub-path after /hub/
272
- */
273
- async handleHub(path, method, body, query, context) {
274
- const hubService = this.getService(CoreServiceName.enum.hub);
275
- if (!hubService)
276
- return { handled: false };
277
- const m = method.toUpperCase();
278
- const parts = path.replace(/^\/+/, '').split('/');
279
- // Resource-based routing: /hub/:resource/:id
280
- if (parts.length > 0) {
281
- const resource = parts[0]; // spaces, plugins, etc.
282
- // Allow mapping "spaces" -> "createSpace", "listSpaces" etc.
283
- // Convention:
284
- // GET /spaces -> listSpaces
285
- // POST /spaces -> createSpace
286
- // GET /spaces/:id -> getSpace
287
- // PATCH /spaces/:id -> updateSpace
288
- // DELETE /spaces/:id -> deleteSpace
289
- const actionBase = resource.endsWith('s') ? resource.slice(0, -1) : resource; // space
290
- const id = parts[1];
291
- try {
292
- if (parts.length === 1) {
293
- // Collection Operations
294
- if (m === 'GET') {
295
- const capitalizedAction = 'list' + this.capitalize(resource); // listSpaces
296
- if (typeof hubService[capitalizedAction] === 'function') {
297
- const result = await hubService[capitalizedAction](query, { request: context.request });
298
- return { handled: true, response: this.success(result) };
299
- }
300
- }
301
- if (m === 'POST') {
302
- const capitalizedAction = 'create' + this.capitalize(actionBase); // createSpace
303
- if (typeof hubService[capitalizedAction] === 'function') {
304
- const result = await hubService[capitalizedAction](body, { request: context.request });
305
- return { handled: true, response: this.success(result) };
306
- }
307
- }
308
- }
309
- else if (parts.length === 2) {
310
- // Item Operations
311
- if (m === 'GET') {
312
- const capitalizedAction = 'get' + this.capitalize(actionBase); // getSpace
313
- if (typeof hubService[capitalizedAction] === 'function') {
314
- const result = await hubService[capitalizedAction](id, { request: context.request });
315
- return { handled: true, response: this.success(result) };
316
- }
317
- }
318
- if (m === 'PATCH' || m === 'PUT') {
319
- const capitalizedAction = 'update' + this.capitalize(actionBase); // updateSpace
320
- if (typeof hubService[capitalizedAction] === 'function') {
321
- const result = await hubService[capitalizedAction](id, body, { request: context.request });
322
- return { handled: true, response: this.success(result) };
323
- }
324
- }
325
- if (m === 'DELETE') {
326
- const capitalizedAction = 'delete' + this.capitalize(actionBase); // deleteSpace
327
- if (typeof hubService[capitalizedAction] === 'function') {
328
- const result = await hubService[capitalizedAction](id, { request: context.request });
329
- return { handled: true, response: this.success(result) };
330
- }
331
- }
332
- }
333
- }
334
- catch (e) {
335
- return { handled: true, response: this.error(e.message, 500) };
336
- }
337
- }
338
- return { handled: false };
339
- }
340
- /**
341
- * Handles Storage requests
342
- * path: sub-path after /storage/
343
- */
344
- async handleStorage(path, method, file, context) {
345
- const storageService = this.getService(CoreServiceName.enum['file-storage']) || this.kernel.services?.['file-storage'];
346
- if (!storageService) {
347
- return { handled: true, response: this.error('File storage not configured', 501) };
348
- }
349
- const m = method.toUpperCase();
350
- const parts = path.replace(/^\/+/, '').split('/');
351
- // POST /storage/upload
352
- if (parts[0] === 'upload' && m === 'POST') {
353
- if (!file) {
354
- return { handled: true, response: this.error('No file provided', 400) };
355
- }
356
- const result = await storageService.upload(file, { request: context.request });
357
- return { handled: true, response: this.success(result) };
358
- }
359
- // GET /storage/file/:id
360
- if (parts[0] === 'file' && parts[1] && m === 'GET') {
361
- const id = parts[1];
362
- const result = await storageService.download(id, { request: context.request });
363
- // Result can be URL (redirect), Stream/Blob, or metadata
364
- if (result.url && result.redirect) {
365
- // Must be handled by adapter to do actual redirect
366
- return { handled: true, result: { type: 'redirect', url: result.url } };
367
- }
368
- if (result.stream) {
369
- // Must be handled by adapter to pipe stream
370
- return {
371
- handled: true,
372
- result: {
373
- type: 'stream',
374
- stream: result.stream,
375
- headers: {
376
- 'Content-Type': result.mimeType || 'application/octet-stream',
377
- 'Content-Length': result.size
378
- }
379
- }
380
- };
381
- }
382
- return { handled: true, response: this.success(result) };
383
- }
384
- return { handled: false };
385
- }
386
- /**
387
- * Handles Automation requests
388
- * path: sub-path after /automation/
389
- */
390
- async handleAutomation(path, method, body, context) {
391
- const automationService = this.getService(CoreServiceName.enum.automation);
392
- if (!automationService)
393
- return { handled: false };
394
- const m = method.toUpperCase();
395
- const parts = path.replace(/^\/+/, '').split('/');
396
- // POST /automation/trigger/:name
397
- if (parts[0] === 'trigger' && parts[1] && m === 'POST') {
398
- const triggerName = parts[1];
399
- if (typeof automationService.trigger === 'function') {
400
- const result = await automationService.trigger(triggerName, body, { request: context.request });
401
- return { handled: true, response: this.success(result) };
402
- }
403
- }
404
- return { handled: false };
405
- }
406
- getServicesMap() {
407
- if (this.kernel.services instanceof Map) {
408
- return Object.fromEntries(this.kernel.services);
409
- }
410
- return this.kernel.services || {};
411
- }
412
- getService(name) {
413
- if (typeof this.kernel.getService === 'function') {
414
- return this.kernel.getService(name);
415
- }
416
- const services = this.getServicesMap();
417
- return services[name];
418
- }
419
- capitalize(s) {
420
- return s.charAt(0).toUpperCase() + s.slice(1);
421
- }
422
- /**
423
- * Main Dispatcher Entry Point
424
- * Routes the request to the appropriate handler based on path and precedence
425
- */
426
- async dispatch(method, path, body, query, context) {
427
- const cleanPath = path.replace(/\/$/, ''); // Remove trailing slash if present, but strict on clean paths
428
- // 1. System Protocols (Prefix-based)
429
- if (cleanPath.startsWith('/auth')) {
430
- return this.handleAuth(cleanPath.substring(5), method, body, context);
431
- }
432
- if (cleanPath.startsWith('/metadata')) {
433
- return this.handleMetadata(cleanPath.substring(9), context);
434
- }
435
- if (cleanPath.startsWith('/data')) {
436
- return this.handleData(cleanPath.substring(5), method, body, query, context);
437
- }
438
- if (cleanPath.startsWith('/graphql')) {
439
- if (method === 'POST')
440
- return this.handleGraphQL(body, context);
441
- // GraphQL usually GET for Playground is handled by middleware but we can return 405 or handle it
442
- }
443
- if (cleanPath.startsWith('/storage')) {
444
- return this.handleStorage(cleanPath.substring(8), method, body, context); // body here is file/stream for upload
445
- }
446
- if (cleanPath.startsWith('/automation')) {
447
- return this.handleAutomation(cleanPath.substring(11), method, body, context);
448
- }
449
- if (cleanPath.startsWith('/analytics')) {
450
- return this.handleAnalytics(cleanPath.substring(10), method, body, context);
451
- }
452
- if (cleanPath.startsWith('/hub')) {
453
- return this.handleHub(cleanPath.substring(4), method, body, query, context);
454
- }
455
- // OpenAPI Specification
456
- if (cleanPath === '/openapi.json' && method === 'GET') {
457
- const broker = this.ensureBroker();
458
- try {
459
- const result = await broker.call('metadata.generateOpenApi', {}, { request: context.request });
460
- return { handled: true, response: this.success(result) };
461
- }
462
- catch (e) {
463
- // If not implemented, fall through or return 404
464
- }
465
- }
466
- // 2. Custom API Endpoints (Registry lookup)
467
- // Check if there is a custom endpoint defined for this path
468
- const result = await this.handleApiEndpoint(cleanPath, method, body, query, context);
469
- if (result.handled)
470
- return result;
471
- // 3. Fallback (404)
472
- return { handled: false };
473
- }
474
- /**
475
- * Handles Custom API Endpoints defined in metadata
476
- */
477
- async handleApiEndpoint(path, method, body, query, context) {
478
- const broker = this.ensureBroker();
479
- try {
480
- // Attempt to find a matching endpoint in the registry
481
- // This assumes a 'metadata.matchEndpoint' action exists in the kernel/registry
482
- // path should include initial slash e.g. /api/v1/customers
483
- const endpoint = await broker.call('metadata.matchEndpoint', { path, method });
484
- if (endpoint) {
485
- // Execute the endpoint target logic
486
- if (endpoint.type === 'flow') {
487
- const result = await broker.call('automation.runFlow', {
488
- flowId: endpoint.target,
489
- inputs: { ...query, ...body, _request: context.request }
490
- });
491
- return { handled: true, response: this.success(result) };
492
- }
493
- if (endpoint.type === 'script') {
494
- const result = await broker.call('automation.runScript', {
495
- scriptName: endpoint.target,
496
- context: { ...query, ...body, request: context.request }
497
- }, { request: context.request });
498
- return { handled: true, response: this.success(result) };
499
- }
500
- if (endpoint.type === 'object_operation') {
501
- // e.g. Proxy to an object action
502
- if (endpoint.objectParams) {
503
- const { object, operation } = endpoint.objectParams;
504
- // Map standard CRUD operations
505
- if (operation === 'find') {
506
- const result = await broker.call('data.query', { object, filters: query }, { request: context.request });
507
- return { handled: true, response: this.success(result.data, { count: result.count }) };
508
- }
509
- if (operation === 'get' && query.id) {
510
- const result = await broker.call('data.get', { object, id: query.id }, { request: context.request });
511
- return { handled: true, response: this.success(result) };
512
- }
513
- if (operation === 'create') {
514
- const result = await broker.call('data.create', { object, data: body }, { request: context.request });
515
- return { handled: true, response: this.success(result) };
516
- }
517
- }
518
- }
519
- if (endpoint.type === 'proxy') {
520
- // Simple proxy implementation (requires a network call, which usually is done by a service but here we can stub return)
521
- // In real implementation this might fetch(endpoint.target)
522
- // For now, return target info
523
- return {
524
- handled: true,
525
- response: {
526
- status: 200,
527
- body: { proxy: true, target: endpoint.target, note: 'Proxy execution requires http-client service' }
528
- }
529
- };
530
- }
531
- }
532
- }
533
- catch (e) {
534
- // If matchEndpoint fails (e.g. not found), we just return not handled
535
- // so we can fallback to 404 or other handlers
536
- }
537
- return { handled: false };
538
- }
539
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,79 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { HttpDispatcher } from './http-dispatcher.js';
3
- describe('HttpDispatcher', () => {
4
- let kernel;
5
- let dispatcher;
6
- let mockProtocol;
7
- let mockBroker;
8
- beforeEach(() => {
9
- // Mock Kernel
10
- mockProtocol = {
11
- saveMetaItem: vi.fn().mockResolvedValue({ success: true, message: 'Saved' }),
12
- getMetaItem: vi.fn().mockResolvedValue({ success: true, item: { foo: 'bar' } })
13
- };
14
- mockBroker = {
15
- call: vi.fn(),
16
- };
17
- kernel = {
18
- broker: mockBroker,
19
- context: {
20
- getService: (name) => {
21
- if (name === 'protocol')
22
- return mockProtocol;
23
- return null;
24
- }
25
- }
26
- };
27
- dispatcher = new HttpDispatcher(kernel);
28
- });
29
- describe('handleMetadata', () => {
30
- it('should handle PUT /metadata/:type/:name by calling protocol.saveMetaItem', async () => {
31
- const context = { request: {} };
32
- const body = { label: 'New Label' };
33
- const path = '/objects/my_obj';
34
- const result = await dispatcher.handleMetadata(path, context, 'PUT', body);
35
- expect(result.handled).toBe(true);
36
- expect(result.response?.status).toBe(200);
37
- expect(mockProtocol.saveMetaItem).toHaveBeenCalledWith({
38
- type: 'objects',
39
- name: 'my_obj',
40
- item: body
41
- });
42
- expect(result.response?.body).toEqual({
43
- success: true,
44
- data: { success: true, message: 'Saved' },
45
- meta: undefined
46
- });
47
- });
48
- it('should fallback to broker call if protocol is missing saveMetaItem', async () => {
49
- // Mock protocol without saveMetaItem
50
- kernel.context.getService = () => ({});
51
- // Mock broker success
52
- mockBroker.call.mockResolvedValue({ success: true, fromBroker: true });
53
- const context = { request: {} };
54
- const body = { label: 'Fallback' };
55
- const path = '/objects/my_obj';
56
- const result = await dispatcher.handleMetadata(path, context, 'PUT', body);
57
- expect(result.handled).toBe(true);
58
- expect(mockBroker.call).toHaveBeenCalledWith('metadata.saveItem', { type: 'objects', name: 'my_obj', item: body }, { request: context.request });
59
- expect(result.response?.body?.data).toEqual({ success: true, fromBroker: true });
60
- });
61
- it('should return error if save fails', async () => {
62
- mockProtocol.saveMetaItem.mockRejectedValue(new Error('Save failed'));
63
- const context = { request: {} };
64
- const body = {};
65
- const path = '/objects/bad_obj';
66
- const result = await dispatcher.handleMetadata(path, context, 'PUT', body);
67
- expect(result.handled).toBe(true);
68
- expect(result.response?.status).toBe(400);
69
- expect(result.response?.body?.error?.message).toBe('Save failed');
70
- });
71
- it('should handle READ operations as before', async () => {
72
- mockBroker.call.mockResolvedValue({ name: 'my_obj' });
73
- const context = { request: {} };
74
- const result = await dispatcher.handleMetadata('/objects/my_obj', context, 'GET');
75
- expect(result.handled).toBe(true);
76
- expect(mockBroker.call).toHaveBeenCalledWith('metadata.getObject', { objectName: 'my_obj' }, { request: context.request });
77
- });
78
- });
79
- });