@unrdf/kgc-runtime 26.4.2
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/IMPLEMENTATION_SUMMARY.json +150 -0
- package/PLUGIN_SYSTEM_SUMMARY.json +149 -0
- package/README.md +98 -0
- package/TRANSACTION_IMPLEMENTATION.json +119 -0
- package/capability-map.md +93 -0
- package/docs/api-stability.md +269 -0
- package/docs/extensions/plugin-development.md +382 -0
- package/package.json +40 -0
- package/plugins/registry.json +35 -0
- package/src/admission-gate.mjs +414 -0
- package/src/api-version.mjs +373 -0
- package/src/atomic-admission.mjs +310 -0
- package/src/bounds.mjs +289 -0
- package/src/bulkhead-manager.mjs +280 -0
- package/src/capsule.mjs +524 -0
- package/src/crdt.mjs +361 -0
- package/src/enhanced-bounds.mjs +614 -0
- package/src/executor.mjs +73 -0
- package/src/freeze-restore.mjs +521 -0
- package/src/index.mjs +62 -0
- package/src/materialized-views.mjs +371 -0
- package/src/merge.mjs +472 -0
- package/src/plugin-isolation.mjs +392 -0
- package/src/plugin-manager.mjs +441 -0
- package/src/projections-api.mjs +336 -0
- package/src/projections-cli.mjs +238 -0
- package/src/projections-docs.mjs +300 -0
- package/src/projections-ide.mjs +278 -0
- package/src/receipt.mjs +340 -0
- package/src/rollback.mjs +258 -0
- package/src/saga-orchestrator.mjs +355 -0
- package/src/schemas.mjs +1330 -0
- package/src/storage-optimization.mjs +359 -0
- package/src/tool-registry.mjs +272 -0
- package/src/transaction.mjs +466 -0
- package/src/validators.mjs +485 -0
- package/src/work-item.mjs +449 -0
- package/templates/plugin-template/README.md +58 -0
- package/templates/plugin-template/index.mjs +162 -0
- package/templates/plugin-template/plugin.json +19 -0
- package/test/admission-gate.test.mjs +583 -0
- package/test/api-version.test.mjs +74 -0
- package/test/atomic-admission.test.mjs +155 -0
- package/test/bounds.test.mjs +341 -0
- package/test/bulkhead-manager.test.mjs +236 -0
- package/test/capsule.test.mjs +625 -0
- package/test/crdt.test.mjs +215 -0
- package/test/enhanced-bounds.test.mjs +487 -0
- package/test/freeze-restore.test.mjs +472 -0
- package/test/materialized-views.test.mjs +243 -0
- package/test/merge.test.mjs +665 -0
- package/test/plugin-isolation.test.mjs +109 -0
- package/test/plugin-manager.test.mjs +208 -0
- package/test/projections-api.test.mjs +293 -0
- package/test/projections-cli.test.mjs +204 -0
- package/test/projections-docs.test.mjs +173 -0
- package/test/projections-ide.test.mjs +230 -0
- package/test/receipt.test.mjs +295 -0
- package/test/rollback.test.mjs +132 -0
- package/test/saga-orchestrator.test.mjs +279 -0
- package/test/schemas.test.mjs +716 -0
- package/test/storage-optimization.test.mjs +503 -0
- package/test/tool-registry.test.mjs +341 -0
- package/test/transaction.test.mjs +189 -0
- package/test/validators.test.mjs +463 -0
- package/test/work-item.test.mjs +548 -0
- package/test/work-item.test.mjs.bak +548 -0
- package/var/kgc/test-atomic-log.json +519 -0
- package/var/kgc/test-cascading-log.json +145 -0
- package/vitest.config.mjs +18 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Isolation Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
6
|
+
import { PluginIsolation, createPublicAPI } from '../src/plugin-isolation.mjs';
|
|
7
|
+
|
|
8
|
+
describe('PluginIsolation', () => {
|
|
9
|
+
let isolation;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
isolation = new PluginIsolation();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Test 11: Capability Checking
|
|
16
|
+
it('should allow whitelisted capabilities', () => {
|
|
17
|
+
const allowed = isolation.checkCapability('receipt:generate');
|
|
18
|
+
expect(allowed).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should block non-whitelisted capabilities in strict mode', () => {
|
|
22
|
+
const denied = isolation.checkCapability('filesystem:write');
|
|
23
|
+
expect(denied).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should always block dangerous capabilities', () => {
|
|
27
|
+
isolation.grantCapability('custom:action'); // This works
|
|
28
|
+
|
|
29
|
+
// But blocked capabilities are never allowed
|
|
30
|
+
expect(() => {
|
|
31
|
+
isolation.grantCapability('process:spawn');
|
|
32
|
+
}).toThrow(/Cannot grant blocked capability/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Test 12: Isolated Execution
|
|
36
|
+
it('should execute functions with required capabilities', async () => {
|
|
37
|
+
const result = await isolation.executeIsolated(
|
|
38
|
+
async () => {
|
|
39
|
+
return { data: 42 };
|
|
40
|
+
},
|
|
41
|
+
['receipt:generate']
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
expect(result.data).toBe(42);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should reject execution if capabilities are denied', async () => {
|
|
48
|
+
await expect(
|
|
49
|
+
isolation.executeIsolated(
|
|
50
|
+
async () => {
|
|
51
|
+
return { data: 42 };
|
|
52
|
+
},
|
|
53
|
+
['filesystem:write']
|
|
54
|
+
)
|
|
55
|
+
).rejects.toThrow(/Capability denied/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Test 13: Access Logging
|
|
59
|
+
it('should log all capability access attempts', () => {
|
|
60
|
+
isolation.checkCapability('receipt:generate');
|
|
61
|
+
isolation.checkCapability('filesystem:write');
|
|
62
|
+
|
|
63
|
+
const accessLog = isolation.getAccessLog();
|
|
64
|
+
|
|
65
|
+
expect(accessLog.length).toBeGreaterThanOrEqual(2);
|
|
66
|
+
expect(accessLog.some(entry => entry.capability === 'receipt:generate')).toBe(true);
|
|
67
|
+
expect(accessLog.some(entry => entry.capability === 'filesystem:write')).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should track usage statistics', () => {
|
|
71
|
+
isolation.checkCapability('receipt:generate');
|
|
72
|
+
isolation.checkCapability('receipt:generate');
|
|
73
|
+
isolation.checkCapability('receipt:validate');
|
|
74
|
+
|
|
75
|
+
const stats = isolation.getUsageStats();
|
|
76
|
+
|
|
77
|
+
expect(stats['receipt:generate']).toBe(2);
|
|
78
|
+
expect(stats['receipt:validate']).toBe(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Test 14: Plugin Capability Validation
|
|
82
|
+
it('should validate plugin manifest capabilities', () => {
|
|
83
|
+
const validation = isolation.validatePluginCapabilities([
|
|
84
|
+
'receipt:generate',
|
|
85
|
+
'custom:action',
|
|
86
|
+
'filesystem:write',
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
expect(validation.allowed).toContain('receipt:generate');
|
|
90
|
+
expect(validation.denied).toContain('custom:action');
|
|
91
|
+
expect(validation.blocked).toContain('filesystem:write');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('createPublicAPI', () => {
|
|
96
|
+
it('should create proxy with only whitelisted methods', () => {
|
|
97
|
+
const fullAPI = {
|
|
98
|
+
publicMethod: () => 'public',
|
|
99
|
+
privateMethod: () => 'private',
|
|
100
|
+
data: { value: 42 },
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const publicAPI = createPublicAPI(fullAPI, ['publicMethod', 'data']);
|
|
104
|
+
|
|
105
|
+
expect(publicAPI.publicMethod()).toBe('public');
|
|
106
|
+
expect(publicAPI.privateMethod).toBeUndefined();
|
|
107
|
+
expect(publicAPI.data.value).toBe(42);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Manager Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
6
|
+
import { PluginManager, PLUGIN_STATES } from '../src/plugin-manager.mjs';
|
|
7
|
+
|
|
8
|
+
describe('PluginManager', () => {
|
|
9
|
+
let manager;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
manager = new PluginManager();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Test 1: Plugin Registration
|
|
16
|
+
it('should register a valid plugin', async () => {
|
|
17
|
+
const manifest = {
|
|
18
|
+
name: 'test-plugin',
|
|
19
|
+
version: '1.0.0',
|
|
20
|
+
description: 'Test plugin',
|
|
21
|
+
entryPoint: './test.mjs',
|
|
22
|
+
capabilities: ['receipt:generate'],
|
|
23
|
+
api_version: '5.0.1',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const pluginId = await manager.registerPlugin(manifest);
|
|
27
|
+
|
|
28
|
+
expect(pluginId).toBe('test-plugin@1.0.0');
|
|
29
|
+
expect(manager.getPluginState(pluginId)).toBe(PLUGIN_STATES.REGISTERED);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Test 2: Plugin Loading
|
|
33
|
+
it('should load a registered plugin', async () => {
|
|
34
|
+
const manifest = {
|
|
35
|
+
name: 'test-plugin',
|
|
36
|
+
version: '1.0.0',
|
|
37
|
+
entryPoint: './test.mjs',
|
|
38
|
+
capabilities: [],
|
|
39
|
+
api_version: '5.0.1',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const pluginId = await manager.registerPlugin(manifest);
|
|
43
|
+
await manager.loadPlugin(pluginId);
|
|
44
|
+
|
|
45
|
+
expect(manager.getPluginState(pluginId)).toBe(PLUGIN_STATES.LOADED);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Test 3: Plugin Activation
|
|
49
|
+
it('should activate a loaded plugin', async () => {
|
|
50
|
+
const manifest = {
|
|
51
|
+
name: 'test-plugin',
|
|
52
|
+
version: '1.0.0',
|
|
53
|
+
entryPoint: './test.mjs',
|
|
54
|
+
capabilities: [],
|
|
55
|
+
api_version: '5.0.1',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const pluginId = await manager.registerPlugin(manifest);
|
|
59
|
+
await manager.loadPlugin(pluginId);
|
|
60
|
+
await manager.activatePlugin(pluginId);
|
|
61
|
+
|
|
62
|
+
expect(manager.getPluginState(pluginId)).toBe(PLUGIN_STATES.EXECUTING);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Test 4: Plugin Deactivation
|
|
66
|
+
it('should deactivate an executing plugin', async () => {
|
|
67
|
+
const manifest = {
|
|
68
|
+
name: 'test-plugin',
|
|
69
|
+
version: '1.0.0',
|
|
70
|
+
entryPoint: './test.mjs',
|
|
71
|
+
capabilities: [],
|
|
72
|
+
api_version: '5.0.1',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const pluginId = await manager.registerPlugin(manifest);
|
|
76
|
+
await manager.loadPlugin(pluginId);
|
|
77
|
+
await manager.activatePlugin(pluginId);
|
|
78
|
+
await manager.deactivatePlugin(pluginId);
|
|
79
|
+
|
|
80
|
+
expect(manager.getPluginState(pluginId)).toBe(PLUGIN_STATES.LOADED);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Test 5: Invalid State Transition
|
|
84
|
+
it('should reject invalid state transitions', async () => {
|
|
85
|
+
const manifest = {
|
|
86
|
+
name: 'test-plugin',
|
|
87
|
+
version: '1.0.0',
|
|
88
|
+
entryPoint: './test.mjs',
|
|
89
|
+
capabilities: [],
|
|
90
|
+
api_version: '5.0.1',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const pluginId = await manager.registerPlugin(manifest);
|
|
94
|
+
|
|
95
|
+
// Cannot activate without loading
|
|
96
|
+
await expect(manager.activatePlugin(pluginId)).rejects.toThrow(
|
|
97
|
+
/Cannot activate plugin in state/
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Test 6: Parallel Loading
|
|
102
|
+
it('should support parallel plugin loading', async () => {
|
|
103
|
+
const manager = new PluginManager({ parallelLoading: true });
|
|
104
|
+
|
|
105
|
+
const plugins = ['plugin-1', 'plugin-2', 'plugin-3'];
|
|
106
|
+
|
|
107
|
+
for (const name of plugins) {
|
|
108
|
+
await manager.registerPlugin({
|
|
109
|
+
name,
|
|
110
|
+
version: '1.0.0',
|
|
111
|
+
entryPoint: './test.mjs',
|
|
112
|
+
capabilities: [],
|
|
113
|
+
api_version: '5.0.1',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const pluginIds = plugins.map(name => `${name}@1.0.0`);
|
|
118
|
+
const result = await manager.loadPluginsParallel(pluginIds);
|
|
119
|
+
|
|
120
|
+
expect(result.success).toHaveLength(3);
|
|
121
|
+
expect(result.failed).toHaveLength(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Test 7: Audit Log
|
|
125
|
+
it('should maintain audit log of all operations', async () => {
|
|
126
|
+
const manifest = {
|
|
127
|
+
name: 'test-plugin',
|
|
128
|
+
version: '1.0.0',
|
|
129
|
+
entryPoint: './test.mjs',
|
|
130
|
+
capabilities: [],
|
|
131
|
+
api_version: '5.0.1',
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const pluginId = await manager.registerPlugin(manifest);
|
|
135
|
+
await manager.loadPlugin(pluginId);
|
|
136
|
+
|
|
137
|
+
const auditLog = manager.getAuditLog({ pluginId });
|
|
138
|
+
|
|
139
|
+
expect(auditLog.length).toBeGreaterThan(0);
|
|
140
|
+
expect(auditLog.some(entry => entry.action === 'register')).toBe(true);
|
|
141
|
+
expect(auditLog.some(entry => entry.action === 'load')).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Test 8: State Transition Count
|
|
145
|
+
it('should track state transition count', async () => {
|
|
146
|
+
const manifest = {
|
|
147
|
+
name: 'test-plugin',
|
|
148
|
+
version: '1.0.0',
|
|
149
|
+
entryPoint: './test.mjs',
|
|
150
|
+
capabilities: [],
|
|
151
|
+
api_version: '5.0.1',
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const initialCount = manager.getTransitionCount();
|
|
155
|
+
|
|
156
|
+
const pluginId = await manager.registerPlugin(manifest);
|
|
157
|
+
await manager.loadPlugin(pluginId);
|
|
158
|
+
await manager.activatePlugin(pluginId);
|
|
159
|
+
|
|
160
|
+
const finalCount = manager.getTransitionCount();
|
|
161
|
+
|
|
162
|
+
expect(finalCount).toBe(initialCount + 3); // register, load, activate
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Test 9: List Plugins by State
|
|
166
|
+
it('should list plugins by state', async () => {
|
|
167
|
+
await manager.registerPlugin({
|
|
168
|
+
name: 'plugin-1',
|
|
169
|
+
version: '1.0.0',
|
|
170
|
+
entryPoint: './test.mjs',
|
|
171
|
+
capabilities: [],
|
|
172
|
+
api_version: '5.0.1',
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await manager.registerPlugin({
|
|
176
|
+
name: 'plugin-2',
|
|
177
|
+
version: '1.0.0',
|
|
178
|
+
entryPoint: './test.mjs',
|
|
179
|
+
capabilities: [],
|
|
180
|
+
api_version: '5.0.1',
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await manager.loadPlugin('plugin-1@1.0.0');
|
|
184
|
+
|
|
185
|
+
const registered = manager.listPluginsByState(PLUGIN_STATES.REGISTERED);
|
|
186
|
+
const loaded = manager.listPluginsByState(PLUGIN_STATES.LOADED);
|
|
187
|
+
|
|
188
|
+
expect(registered).toHaveLength(1);
|
|
189
|
+
expect(loaded).toHaveLength(1);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Test 10: Plugin Uninstall
|
|
193
|
+
it('should uninstall a plugin completely', async () => {
|
|
194
|
+
const manifest = {
|
|
195
|
+
name: 'test-plugin',
|
|
196
|
+
version: '1.0.0',
|
|
197
|
+
entryPoint: './test.mjs',
|
|
198
|
+
capabilities: [],
|
|
199
|
+
api_version: '5.0.1',
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const pluginId = await manager.registerPlugin(manifest);
|
|
203
|
+
await manager.uninstallPlugin(pluginId);
|
|
204
|
+
|
|
205
|
+
expect(manager.getPlugin(pluginId)).toBeNull();
|
|
206
|
+
expect(manager.getPluginState(pluginId)).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for API Projections (Π_api)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
projectReceiptsToREST,
|
|
8
|
+
projectWorkItemsToGraphQL,
|
|
9
|
+
projectResourceToJSONAPI,
|
|
10
|
+
applyFilters,
|
|
11
|
+
applySorting,
|
|
12
|
+
applyPagination,
|
|
13
|
+
buildHATEOASLinks,
|
|
14
|
+
APIProjectionSchema,
|
|
15
|
+
} from '../src/projections-api.mjs';
|
|
16
|
+
|
|
17
|
+
describe('Π_api - API Projections', () => {
|
|
18
|
+
describe('projectReceiptsToREST', () => {
|
|
19
|
+
it('should project receipts to paginated REST format', () => {
|
|
20
|
+
const receipts = [
|
|
21
|
+
{ id: '1', timestamp: '2024-01-01', operation: 'op1', hash: 'a'.repeat(64) },
|
|
22
|
+
{ id: '2', timestamp: '2024-01-02', operation: 'op2', hash: 'b'.repeat(64) },
|
|
23
|
+
{ id: '3', timestamp: '2024-01-03', operation: 'op3', hash: 'c'.repeat(64) },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const projection = projectReceiptsToREST(
|
|
27
|
+
receipts,
|
|
28
|
+
{ page: 1, pageSize: 2 }
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
expect(projection.type).toBe('api');
|
|
32
|
+
expect(projection.format).toBe('rest');
|
|
33
|
+
expect(projection.data).toHaveLength(2);
|
|
34
|
+
expect(projection.meta.pagination.total).toBe(3);
|
|
35
|
+
expect(projection.meta.pagination.totalPages).toBe(2);
|
|
36
|
+
expect(projection.links.next).toContain('page=2');
|
|
37
|
+
|
|
38
|
+
// Validate schema
|
|
39
|
+
APIProjectionSchema.parse(projection);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should apply filters to receipts', () => {
|
|
43
|
+
const receipts = [
|
|
44
|
+
{ id: '1', operation: 'create', hash: 'a'.repeat(64) },
|
|
45
|
+
{ id: '2', operation: 'update', hash: 'b'.repeat(64) },
|
|
46
|
+
{ id: '3', operation: 'create', hash: 'c'.repeat(64) },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const projection = projectReceiptsToREST(
|
|
50
|
+
receipts,
|
|
51
|
+
{ page: 1, pageSize: 10 },
|
|
52
|
+
{ operation: 'create' }
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(projection.data).toHaveLength(2);
|
|
56
|
+
expect(projection.meta.filters.operation).toBe('create');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should apply sorting to receipts', () => {
|
|
60
|
+
const receipts = [
|
|
61
|
+
{ id: '3', timestamp: '2024-01-03', hash: 'c'.repeat(64) },
|
|
62
|
+
{ id: '1', timestamp: '2024-01-01', hash: 'a'.repeat(64) },
|
|
63
|
+
{ id: '2', timestamp: '2024-01-02', hash: 'b'.repeat(64) },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const projection = projectReceiptsToREST(
|
|
67
|
+
receipts,
|
|
68
|
+
{ page: 1, pageSize: 10 },
|
|
69
|
+
{},
|
|
70
|
+
[{ field: 'timestamp', direction: 'asc' }]
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(projection.data[0].id).toBe('1');
|
|
74
|
+
expect(projection.data[1].id).toBe('2');
|
|
75
|
+
expect(projection.data[2].id).toBe('3');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('projectWorkItemsToGraphQL', () => {
|
|
80
|
+
it('should project work items to GraphQL format', () => {
|
|
81
|
+
const workItems = [
|
|
82
|
+
{ id: '1', goal: 'Task 1', state: 'pending', priority: 1 },
|
|
83
|
+
{ id: '2', goal: 'Task 2', state: 'running', priority: 2 },
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
const projection = projectWorkItemsToGraphQL(workItems);
|
|
87
|
+
|
|
88
|
+
expect(projection.type).toBe('api');
|
|
89
|
+
expect(projection.format).toBe('graphql');
|
|
90
|
+
expect(projection.data.workItems).toHaveLength(2);
|
|
91
|
+
expect(projection.data.totalCount).toBe(2);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should apply GraphQL where filters', () => {
|
|
95
|
+
const workItems = [
|
|
96
|
+
{ id: '1', goal: 'Task 1', state: 'pending' },
|
|
97
|
+
{ id: '2', goal: 'Task 2', state: 'completed' },
|
|
98
|
+
{ id: '3', goal: 'Task 3', state: 'pending' },
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
const projection = projectWorkItemsToGraphQL(workItems, {
|
|
102
|
+
where: { state: 'pending' },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(projection.data.workItems).toHaveLength(2);
|
|
106
|
+
expect(projection.data.filteredCount).toBe(2);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should apply GraphQL ordering', () => {
|
|
110
|
+
const workItems = [
|
|
111
|
+
{ id: '3', goal: 'Task 3', priority: 3 },
|
|
112
|
+
{ id: '1', goal: 'Task 1', priority: 1 },
|
|
113
|
+
{ id: '2', goal: 'Task 2', priority: 2 },
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const projection = projectWorkItemsToGraphQL(workItems, {
|
|
117
|
+
orderBy: { field: 'priority', direction: 'ASC' },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(projection.data.workItems[0].id).toBe('1');
|
|
121
|
+
expect(projection.data.workItems[2].id).toBe('3');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should apply skip and take pagination', () => {
|
|
125
|
+
const workItems = Array.from({ length: 10 }, (_, i) => ({
|
|
126
|
+
id: String(i + 1),
|
|
127
|
+
goal: `Task ${i + 1}`,
|
|
128
|
+
state: 'pending',
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
const projection = projectWorkItemsToGraphQL(workItems, {
|
|
132
|
+
skip: 2,
|
|
133
|
+
take: 3,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(projection.data.workItems).toHaveLength(3);
|
|
137
|
+
expect(projection.data.workItems[0].id).toBe('3');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('projectResourceToJSONAPI', () => {
|
|
142
|
+
it('should project resource to JSON:API format', () => {
|
|
143
|
+
const resource = {
|
|
144
|
+
id: 'user-123',
|
|
145
|
+
name: 'Alice',
|
|
146
|
+
email: 'alice@example.com',
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const projection = projectResourceToJSONAPI(resource, 'user');
|
|
150
|
+
|
|
151
|
+
expect(projection.type).toBe('api');
|
|
152
|
+
expect(projection.format).toBe('json-api');
|
|
153
|
+
expect(projection.data.type).toBe('user');
|
|
154
|
+
expect(projection.data.id).toBe('user-123');
|
|
155
|
+
expect(projection.data.attributes.name).toBe('Alice');
|
|
156
|
+
expect(projection.links.self).toBe('/user/user-123');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should include relationships', () => {
|
|
160
|
+
const resource = { id: '1', title: 'Post' };
|
|
161
|
+
const relationships = {
|
|
162
|
+
author: { type: 'user', id: 'user-123' },
|
|
163
|
+
comments: [
|
|
164
|
+
{ type: 'comment', id: 'c1' },
|
|
165
|
+
{ type: 'comment', id: 'c2' },
|
|
166
|
+
],
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const projection = projectResourceToJSONAPI(resource, 'post', relationships);
|
|
170
|
+
|
|
171
|
+
expect(projection.data.relationships.author.data).toEqual({
|
|
172
|
+
type: 'user',
|
|
173
|
+
id: 'user-123',
|
|
174
|
+
});
|
|
175
|
+
expect(projection.data.relationships.comments.data).toHaveLength(2);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('applyFilters', () => {
|
|
180
|
+
it('should filter with equality', () => {
|
|
181
|
+
const items = [
|
|
182
|
+
{ id: 1, status: 'active' },
|
|
183
|
+
{ id: 2, status: 'inactive' },
|
|
184
|
+
{ id: 3, status: 'active' },
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
const filtered = applyFilters(items, { status: 'active' });
|
|
188
|
+
|
|
189
|
+
expect(filtered).toHaveLength(2);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should filter with operators', () => {
|
|
193
|
+
const items = [
|
|
194
|
+
{ id: 1, count: 5 },
|
|
195
|
+
{ id: 2, count: 10 },
|
|
196
|
+
{ id: 3, count: 15 },
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
const filtered = applyFilters(items, { count: { gt: 7 } });
|
|
200
|
+
|
|
201
|
+
expect(filtered).toHaveLength(2);
|
|
202
|
+
expect(filtered[0].count).toBe(10);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should filter with contains operator', () => {
|
|
206
|
+
const items = [
|
|
207
|
+
{ id: 1, name: 'Alice' },
|
|
208
|
+
{ id: 2, name: 'Bob' },
|
|
209
|
+
{ id: 3, name: 'Charlie' },
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
const filtered = applyFilters(items, { name: { contains: 'li' } });
|
|
213
|
+
|
|
214
|
+
expect(filtered).toHaveLength(2); // Alice, Charlie
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('applySorting', () => {
|
|
219
|
+
it('should sort ascending', () => {
|
|
220
|
+
const items = [
|
|
221
|
+
{ id: 3, value: 30 },
|
|
222
|
+
{ id: 1, value: 10 },
|
|
223
|
+
{ id: 2, value: 20 },
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
const sorted = applySorting(items, [{ field: 'value', direction: 'asc' }]);
|
|
227
|
+
|
|
228
|
+
expect(sorted[0].value).toBe(10);
|
|
229
|
+
expect(sorted[2].value).toBe(30);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should sort descending', () => {
|
|
233
|
+
const items = [
|
|
234
|
+
{ id: 1, value: 10 },
|
|
235
|
+
{ id: 3, value: 30 },
|
|
236
|
+
{ id: 2, value: 20 },
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
const sorted = applySorting(items, [{ field: 'value', direction: 'desc' }]);
|
|
240
|
+
|
|
241
|
+
expect(sorted[0].value).toBe(30);
|
|
242
|
+
expect(sorted[2].value).toBe(10);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should handle multi-field sorting', () => {
|
|
246
|
+
const items = [
|
|
247
|
+
{ category: 'A', priority: 2 },
|
|
248
|
+
{ category: 'B', priority: 1 },
|
|
249
|
+
{ category: 'A', priority: 1 },
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
const sorted = applySorting(items, [
|
|
253
|
+
{ field: 'category', direction: 'asc' },
|
|
254
|
+
{ field: 'priority', direction: 'asc' },
|
|
255
|
+
]);
|
|
256
|
+
|
|
257
|
+
expect(sorted[0]).toEqual({ category: 'A', priority: 1 });
|
|
258
|
+
expect(sorted[1]).toEqual({ category: 'A', priority: 2 });
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe('applyPagination', () => {
|
|
263
|
+
it('should paginate items', () => {
|
|
264
|
+
const items = Array.from({ length: 10 }, (_, i) => ({ id: i + 1 }));
|
|
265
|
+
|
|
266
|
+
const result = applyPagination(items, { page: 2, pageSize: 3 });
|
|
267
|
+
|
|
268
|
+
expect(result.data).toHaveLength(3);
|
|
269
|
+
expect(result.data[0].id).toBe(4);
|
|
270
|
+
expect(result.meta.pagination.total).toBe(10);
|
|
271
|
+
expect(result.meta.pagination.totalPages).toBe(4);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('buildHATEOASLinks', () => {
|
|
276
|
+
it('should build HATEOAS links', () => {
|
|
277
|
+
const links = buildHATEOASLinks('users', '123');
|
|
278
|
+
|
|
279
|
+
expect(links.self).toBe('/users/123');
|
|
280
|
+
expect(links.collection).toBe('/users');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('should include additional links', () => {
|
|
284
|
+
const links = buildHATEOASLinks('users', '123', [
|
|
285
|
+
{ rel: 'posts', href: '/users/123/posts' },
|
|
286
|
+
{ rel: 'profile', href: '/users/123/profile' },
|
|
287
|
+
]);
|
|
288
|
+
|
|
289
|
+
expect(links.posts).toBe('/users/123/posts');
|
|
290
|
+
expect(links.profile).toBe('/users/123/profile');
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
});
|