@the-cascade-protocol/cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/.dockerignore +7 -0
  2. package/.eslintrc.json +23 -0
  3. package/.prettierrc +7 -0
  4. package/DOCKER.md +36 -0
  5. package/Dockerfile +18 -0
  6. package/README.md +69 -0
  7. package/dist/commands/capabilities.d.ts +9 -0
  8. package/dist/commands/capabilities.d.ts.map +1 -0
  9. package/dist/commands/capabilities.js +194 -0
  10. package/dist/commands/capabilities.js.map +1 -0
  11. package/dist/commands/conformance.d.ts +15 -0
  12. package/dist/commands/conformance.d.ts.map +1 -0
  13. package/dist/commands/conformance.js +348 -0
  14. package/dist/commands/conformance.js.map +1 -0
  15. package/dist/commands/convert.d.ts +21 -0
  16. package/dist/commands/convert.d.ts.map +1 -0
  17. package/dist/commands/convert.js +134 -0
  18. package/dist/commands/convert.js.map +1 -0
  19. package/dist/commands/pod/export.d.ts +8 -0
  20. package/dist/commands/pod/export.d.ts.map +1 -0
  21. package/dist/commands/pod/export.js +72 -0
  22. package/dist/commands/pod/export.js.map +1 -0
  23. package/dist/commands/pod/helpers.d.ts +79 -0
  24. package/dist/commands/pod/helpers.d.ts.map +1 -0
  25. package/dist/commands/pod/helpers.js +369 -0
  26. package/dist/commands/pod/helpers.js.map +1 -0
  27. package/dist/commands/pod/index.d.ts +20 -0
  28. package/dist/commands/pod/index.d.ts.map +1 -0
  29. package/dist/commands/pod/index.js +29 -0
  30. package/dist/commands/pod/index.js.map +1 -0
  31. package/dist/commands/pod/info.d.ts +9 -0
  32. package/dist/commands/pod/info.d.ts.map +1 -0
  33. package/dist/commands/pod/info.js +196 -0
  34. package/dist/commands/pod/info.js.map +1 -0
  35. package/dist/commands/pod/init.d.ts +9 -0
  36. package/dist/commands/pod/init.d.ts.map +1 -0
  37. package/dist/commands/pod/init.js +251 -0
  38. package/dist/commands/pod/init.js.map +1 -0
  39. package/dist/commands/pod/query.d.ts +9 -0
  40. package/dist/commands/pod/query.d.ts.map +1 -0
  41. package/dist/commands/pod/query.js +169 -0
  42. package/dist/commands/pod/query.js.map +1 -0
  43. package/dist/commands/pod 2.js +1017 -0
  44. package/dist/commands/pod.d.ts +28 -0
  45. package/dist/commands/pod.d.ts 2.map +1 -0
  46. package/dist/commands/pod.d.ts.map +1 -0
  47. package/dist/commands/pod.js +1031 -0
  48. package/dist/commands/pod.js 2.map +1 -0
  49. package/dist/commands/pod.js.map +1 -0
  50. package/dist/commands/serve.d.ts +33 -0
  51. package/dist/commands/serve.d.ts.map +1 -0
  52. package/dist/commands/serve.js +74 -0
  53. package/dist/commands/serve.js.map +1 -0
  54. package/dist/commands/validate.d.ts +18 -0
  55. package/dist/commands/validate.d.ts.map +1 -0
  56. package/dist/commands/validate.js +275 -0
  57. package/dist/commands/validate.js.map +1 -0
  58. package/dist/index.d.ts +19 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +49 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/lib/fhir-converter/cascade-to-fhir.d.ts +17 -0
  63. package/dist/lib/fhir-converter/cascade-to-fhir.d.ts.map +1 -0
  64. package/dist/lib/fhir-converter/cascade-to-fhir.js +358 -0
  65. package/dist/lib/fhir-converter/cascade-to-fhir.js.map +1 -0
  66. package/dist/lib/fhir-converter/converters-clinical.d.ts +29 -0
  67. package/dist/lib/fhir-converter/converters-clinical.d.ts.map +1 -0
  68. package/dist/lib/fhir-converter/converters-clinical.js +391 -0
  69. package/dist/lib/fhir-converter/converters-clinical.js.map +1 -0
  70. package/dist/lib/fhir-converter/converters-demographics.d.ts +20 -0
  71. package/dist/lib/fhir-converter/converters-demographics.d.ts.map +1 -0
  72. package/dist/lib/fhir-converter/converters-demographics.js +242 -0
  73. package/dist/lib/fhir-converter/converters-demographics.js.map +1 -0
  74. package/dist/lib/fhir-converter/fhir-to-cascade.d.ts +17 -0
  75. package/dist/lib/fhir-converter/fhir-to-cascade.d.ts.map +1 -0
  76. package/dist/lib/fhir-converter/fhir-to-cascade.js +63 -0
  77. package/dist/lib/fhir-converter/fhir-to-cascade.js.map +1 -0
  78. package/dist/lib/fhir-converter/index.d.ts +36 -0
  79. package/dist/lib/fhir-converter/index.d.ts.map +1 -0
  80. package/dist/lib/fhir-converter/index.js +187 -0
  81. package/dist/lib/fhir-converter/index.js.map +1 -0
  82. package/dist/lib/fhir-converter/types.d.ts +77 -0
  83. package/dist/lib/fhir-converter/types.d.ts.map +1 -0
  84. package/dist/lib/fhir-converter/types.js +236 -0
  85. package/dist/lib/fhir-converter/types.js.map +1 -0
  86. package/dist/lib/fhir-converter.d.ts +62 -0
  87. package/dist/lib/fhir-converter.d.ts.map +1 -0
  88. package/dist/lib/fhir-converter.js +1474 -0
  89. package/dist/lib/fhir-converter.js.map +1 -0
  90. package/dist/lib/mcp/audit.d.ts +24 -0
  91. package/dist/lib/mcp/audit.d.ts.map +1 -0
  92. package/dist/lib/mcp/audit.js +85 -0
  93. package/dist/lib/mcp/audit.js.map +1 -0
  94. package/dist/lib/mcp/server.d.ts +38 -0
  95. package/dist/lib/mcp/server.d.ts.map +1 -0
  96. package/dist/lib/mcp/server.js +172 -0
  97. package/dist/lib/mcp/server.js.map +1 -0
  98. package/dist/lib/mcp/tools.d.ts +47 -0
  99. package/dist/lib/mcp/tools.d.ts.map +1 -0
  100. package/dist/lib/mcp/tools.js +547 -0
  101. package/dist/lib/mcp/tools.js.map +1 -0
  102. package/dist/lib/output.d.ts +26 -0
  103. package/dist/lib/output.d.ts.map +1 -0
  104. package/dist/lib/output.js +64 -0
  105. package/dist/lib/output.js.map +1 -0
  106. package/dist/lib/shacl-validator.d.ts +53 -0
  107. package/dist/lib/shacl-validator.d.ts.map +1 -0
  108. package/dist/lib/shacl-validator.js +245 -0
  109. package/dist/lib/shacl-validator.js.map +1 -0
  110. package/dist/lib/turtle-parser.d.ts +64 -0
  111. package/dist/lib/turtle-parser.d.ts.map +1 -0
  112. package/dist/lib/turtle-parser.js +236 -0
  113. package/dist/lib/turtle-parser.js.map +1 -0
  114. package/dist/shapes/checkup.shapes.ttl +1459 -0
  115. package/dist/shapes/clinical.shapes.ttl +1350 -0
  116. package/dist/shapes/clinical.ttl +1369 -0
  117. package/dist/shapes/core.shapes.ttl +450 -0
  118. package/dist/shapes/core.ttl +603 -0
  119. package/dist/shapes/coverage.shapes.ttl +214 -0
  120. package/dist/shapes/coverage.ttl +182 -0
  121. package/dist/shapes/health.shapes.ttl +697 -0
  122. package/dist/shapes/health.ttl +859 -0
  123. package/dist/shapes/pots.shapes.ttl +481 -0
  124. package/package.json +54 -0
  125. package/src/commands/capabilities.ts +235 -0
  126. package/src/commands/conformance.ts +447 -0
  127. package/src/commands/convert.ts +164 -0
  128. package/src/commands/pod/export.ts +85 -0
  129. package/src/commands/pod/helpers.ts +449 -0
  130. package/src/commands/pod/index.ts +32 -0
  131. package/src/commands/pod/info.ts +239 -0
  132. package/src/commands/pod/init.ts +273 -0
  133. package/src/commands/pod/query.ts +224 -0
  134. package/src/commands/serve.ts +92 -0
  135. package/src/commands/validate.ts +303 -0
  136. package/src/index.ts +58 -0
  137. package/src/lib/fhir-converter/cascade-to-fhir.ts +369 -0
  138. package/src/lib/fhir-converter/converters-clinical.ts +446 -0
  139. package/src/lib/fhir-converter/converters-demographics.ts +270 -0
  140. package/src/lib/fhir-converter/fhir-to-cascade.ts +82 -0
  141. package/src/lib/fhir-converter/index.ts +215 -0
  142. package/src/lib/fhir-converter/types.ts +318 -0
  143. package/src/lib/mcp/audit.ts +107 -0
  144. package/src/lib/mcp/server.ts +192 -0
  145. package/src/lib/mcp/tools.ts +668 -0
  146. package/src/lib/output.ts +76 -0
  147. package/src/lib/shacl-validator.ts +314 -0
  148. package/src/lib/turtle-parser.ts +277 -0
  149. package/src/shapes/checkup.shapes.ttl +1459 -0
  150. package/src/shapes/clinical.shapes.ttl +1350 -0
  151. package/src/shapes/clinical.ttl +1369 -0
  152. package/src/shapes/core.shapes.ttl +450 -0
  153. package/src/shapes/core.ttl +603 -0
  154. package/src/shapes/coverage.shapes.ttl +214 -0
  155. package/src/shapes/coverage.ttl +182 -0
  156. package/src/shapes/health.shapes.ttl +697 -0
  157. package/src/shapes/health.ttl +859 -0
  158. package/src/shapes/pots.shapes.ttl +481 -0
  159. package/test-fixtures/fhir-bundle-example.json +216 -0
  160. package/test-fixtures/fhir-medication-example.json +18 -0
  161. package/tests/cli.test.ts +126 -0
  162. package/tests/fhir-converter.test.ts +874 -0
  163. package/tests/mcp-server.test.ts +396 -0
  164. package/tests/pod.test.ts +400 -0
  165. package/tsconfig.json +24 -0
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Tests for the MCP server tools.
3
+ *
4
+ * Tests the tool handler logic by importing the server creation function
5
+ * and verifying tool registration and behavior.
6
+ *
7
+ * Phase 3.5: Added comprehensive tests for Turtle generation, escaping,
8
+ * path validation, audit IDs, field mappings, and type registries.
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest';
12
+ import { createServer } from '../src/lib/mcp/server.js';
13
+ import {
14
+ setDefaultPodPath,
15
+ validatePathBoundary,
16
+ escapeTurtleString,
17
+ formatTurtleValue,
18
+ generatePrefixes,
19
+ buildRecordTurtle,
20
+ TYPE_MAPPING,
21
+ PROPERTY_PREDICATES,
22
+ } from '../src/lib/mcp/tools.js';
23
+ import * as path from 'path';
24
+ import * as fs from 'fs';
25
+
26
+ // Reference patient pod path
27
+ const REFERENCE_POD = path.resolve(__dirname, '..', '..', 'reference-patient-pod');
28
+
29
+ // ─── Server Creation ──────────────────────────────────────────────────────────
30
+
31
+ describe('MCP Server', () => {
32
+ it('should create a server instance', () => {
33
+ const server = createServer();
34
+ expect(server).toBeDefined();
35
+ expect(server.server).toBeDefined();
36
+ });
37
+ });
38
+
39
+ // ─── Audit Log ────────────────────────────────────────────────────────────────
40
+
41
+ describe('MCP Audit Log', () => {
42
+ it('should format audit entries as Turtle', async () => {
43
+ const { createAuditEntry } = await import('../src/lib/mcp/audit.js');
44
+ const entry = createAuditEntry('pod_query', ['medications'], 8, 'test-agent');
45
+ expect(entry.operation).toBe('pod_query');
46
+ expect(entry.dataTypes).toEqual(['medications']);
47
+ expect(entry.recordsAccessed).toBe(8);
48
+ expect(entry.agentId).toBe('test-agent');
49
+ expect(entry.timestamp).toBeTruthy();
50
+ });
51
+
52
+ it('should write audit log to a temp directory', async () => {
53
+ const { writeAuditEntry, createAuditEntry } = await import('../src/lib/mcp/audit.js');
54
+ const tmpDir = path.join('/tmp', `cascade-mcp-test-${Date.now()}`);
55
+ fs.mkdirSync(tmpDir, { recursive: true });
56
+
57
+ const entry = createAuditEntry('pod_read', ['all'], 42, 'test-agent');
58
+ await writeAuditEntry(tmpDir, entry);
59
+
60
+ const auditLogPath = path.join(tmpDir, 'provenance', 'audit-log.ttl');
61
+ expect(fs.existsSync(auditLogPath)).toBe(true);
62
+
63
+ const content = fs.readFileSync(auditLogPath, 'utf-8');
64
+ expect(content).toContain('@prefix cascade:');
65
+ expect(content).toContain('cascade:AuditEntry');
66
+ expect(content).toContain('"pod_read"');
67
+ expect(content).toContain('"test-agent"');
68
+ expect(content).toContain('42');
69
+
70
+ fs.rmSync(tmpDir, { recursive: true, force: true });
71
+ });
72
+
73
+ it('should append multiple entries to audit log', async () => {
74
+ const { writeAuditEntry, createAuditEntry } = await import('../src/lib/mcp/audit.js');
75
+ const tmpDir = path.join('/tmp', `cascade-mcp-test-${Date.now()}`);
76
+ fs.mkdirSync(tmpDir, { recursive: true });
77
+
78
+ await writeAuditEntry(tmpDir, createAuditEntry('pod_read', ['all'], 10));
79
+ await writeAuditEntry(tmpDir, createAuditEntry('pod_query', ['medications'], 8));
80
+ await writeAuditEntry(tmpDir, createAuditEntry('write', ['conditions'], 1));
81
+
82
+ const content = fs.readFileSync(
83
+ path.join(tmpDir, 'provenance', 'audit-log.ttl'),
84
+ 'utf-8',
85
+ );
86
+
87
+ // Should have prefixes only once (at the top)
88
+ const prefixCount = (content.match(/@prefix cascade:/g) || []).length;
89
+ expect(prefixCount).toBe(1);
90
+
91
+ // Should have 3 audit entries
92
+ const entryCount = (content.match(/cascade:AuditEntry/g) || []).length;
93
+ expect(entryCount).toBe(3);
94
+
95
+ fs.rmSync(tmpDir, { recursive: true, force: true });
96
+ });
97
+
98
+ it('should generate UUID-based audit IDs (not sequential)', async () => {
99
+ const { writeAuditEntry, createAuditEntry } = await import('../src/lib/mcp/audit.js');
100
+ const tmpDir = path.join('/tmp', `cascade-mcp-test-uuid-${Date.now()}`);
101
+ fs.mkdirSync(tmpDir, { recursive: true });
102
+
103
+ await writeAuditEntry(tmpDir, createAuditEntry('pod_read', ['all'], 1));
104
+ await writeAuditEntry(tmpDir, createAuditEntry('pod_query', ['medications'], 2));
105
+
106
+ const content = fs.readFileSync(
107
+ path.join(tmpDir, 'provenance', 'audit-log.ttl'),
108
+ 'utf-8',
109
+ );
110
+
111
+ // IDs should be UUID-based, not sequential (audit-0001, audit-0002)
112
+ expect(content).not.toContain('audit-0001');
113
+ expect(content).not.toContain('audit-0002');
114
+ // Should contain UUID-pattern audit IDs
115
+ const auditIdMatches = content.match(/<#audit-[0-9a-f-]+>/g);
116
+ expect(auditIdMatches).not.toBeNull();
117
+ expect(auditIdMatches!.length).toBe(2);
118
+ // Each ID should be unique
119
+ expect(auditIdMatches![0]).not.toBe(auditIdMatches![1]);
120
+
121
+ fs.rmSync(tmpDir, { recursive: true, force: true });
122
+ });
123
+ });
124
+
125
+ // ─── Tool Helpers ─────────────────────────────────────────────────────────────
126
+
127
+ describe('MCP Tool Helpers', () => {
128
+ it('should resolve pod path from argument', () => {
129
+ setDefaultPodPath('/tmp/test-pod');
130
+ expect(() => setDefaultPodPath('/some/path')).not.toThrow();
131
+ });
132
+ });
133
+
134
+ // ─── Path Validation ──────────────────────────────────────────────────────────
135
+
136
+ describe('validatePathBoundary', () => {
137
+ it('should allow paths within the boundary', () => {
138
+ expect(validatePathBoundary('/home/user/pod/clinical/meds.ttl', '/home/user/pod')).toBe(true);
139
+ expect(validatePathBoundary('/home/user/pod', '/home/user/pod')).toBe(true);
140
+ expect(validatePathBoundary('/home/user/pod/deep/nested/file.ttl', '/home/user/pod')).toBe(true);
141
+ });
142
+
143
+ it('should reject paths outside the boundary', () => {
144
+ expect(validatePathBoundary('/home/user/other/file.ttl', '/home/user/pod')).toBe(false);
145
+ expect(validatePathBoundary('/etc/passwd', '/home/user/pod')).toBe(false);
146
+ expect(validatePathBoundary('/home/user/pod-evil/file.ttl', '/home/user/pod')).toBe(false);
147
+ });
148
+
149
+ it('should handle traversal attempts', () => {
150
+ // path.resolve normalizes these, so they should resolve outside the boundary
151
+ expect(validatePathBoundary('/home/user/pod/../../etc/passwd', '/home/user/pod')).toBe(false);
152
+ expect(validatePathBoundary('/home/user/pod/../other/file', '/home/user/pod')).toBe(false);
153
+ });
154
+ });
155
+
156
+ // ─── Turtle String Escaping ───────────────────────────────────────────────────
157
+
158
+ describe('escapeTurtleString', () => {
159
+ it('should escape basic strings', () => {
160
+ expect(escapeTurtleString('hello')).toBe('"hello"');
161
+ expect(escapeTurtleString('')).toBe('""');
162
+ });
163
+
164
+ it('should escape quotes and backslashes', () => {
165
+ expect(escapeTurtleString('say "hi"')).toBe('"say \\"hi\\""');
166
+ expect(escapeTurtleString('path\\to\\file')).toBe('"path\\\\to\\\\file"');
167
+ });
168
+
169
+ it('should use long literals for strings with newlines', () => {
170
+ const result = escapeTurtleString('line1\nline2');
171
+ expect(result.startsWith('"""')).toBe(true);
172
+ expect(result.endsWith('"""')).toBe(true);
173
+ });
174
+
175
+ it('should use long literals for long strings (>200 chars)', () => {
176
+ const longStr = 'a'.repeat(201);
177
+ const result = escapeTurtleString(longStr);
178
+ expect(result.startsWith('"""')).toBe(true);
179
+ expect(result.endsWith('"""')).toBe(true);
180
+ });
181
+
182
+ it('should escape tabs and carriage returns in short strings', () => {
183
+ const result = escapeTurtleString('col1\tcol2');
184
+ expect(result).toBe('"col1\\tcol2"');
185
+ const result2 = escapeTurtleString('line\r');
186
+ expect(result2).toBe('"line\\r"');
187
+ });
188
+ });
189
+
190
+ // ─── Turtle Value Formatting ──────────────────────────────────────────────────
191
+
192
+ describe('formatTurtleValue', () => {
193
+ it('should format booleans', () => {
194
+ expect(formatTurtleValue('isActive', true)).toBe('true');
195
+ expect(formatTurtleValue('isActive', false)).toBe('false');
196
+ });
197
+
198
+ it('should format integers without type annotation', () => {
199
+ expect(formatTurtleValue('count', 42)).toBe('42');
200
+ expect(formatTurtleValue('count', 0)).toBe('0');
201
+ });
202
+
203
+ it('should format floats with xsd:double', () => {
204
+ expect(formatTurtleValue('confidence', 0.85)).toBe('"0.85"^^xsd:double');
205
+ });
206
+
207
+ it('should format date-like keys with xsd:dateTime', () => {
208
+ expect(formatTurtleValue('startDate', '2026-01-15')).toBe('"2026-01-15"^^xsd:dateTime');
209
+ expect(formatTurtleValue('vaccineDate', '2025-10-01')).toBe('"2025-10-01"^^xsd:dateTime');
210
+ expect(formatTurtleValue('timestamp', '2026-02-20T10:00:00Z')).toBe('"2026-02-20T10:00:00Z"^^xsd:dateTime');
211
+ });
212
+
213
+ it('should format regular strings as escaped literals', () => {
214
+ expect(formatTurtleValue('name', 'Aspirin')).toBe('"Aspirin"');
215
+ });
216
+ });
217
+
218
+ // ─── Namespace Prefixes ───────────────────────────────────────────────────────
219
+
220
+ describe('generatePrefixes', () => {
221
+ it('should include all required namespace prefixes', () => {
222
+ const prefixes = generatePrefixes();
223
+ expect(prefixes).toContain('@prefix cascade:');
224
+ expect(prefixes).toContain('@prefix health:');
225
+ expect(prefixes).toContain('@prefix clinical:');
226
+ expect(prefixes).toContain('@prefix coverage:');
227
+ expect(prefixes).toContain('@prefix fhir:');
228
+ expect(prefixes).toContain('@prefix xsd:');
229
+ expect(prefixes).toContain('@prefix prov:');
230
+ expect(prefixes).toContain('https://ns.cascadeprotocol.org/core/v1#');
231
+ expect(prefixes).toContain('https://ns.cascadeprotocol.org/health/v1#');
232
+ expect(prefixes).toContain('https://ns.cascadeprotocol.org/clinical/v1#');
233
+ });
234
+ });
235
+
236
+ // ─── TYPE_MAPPING Registry ────────────────────────────────────────────────────
237
+
238
+ describe('TYPE_MAPPING', () => {
239
+ it('should map all supported data types', () => {
240
+ const expectedTypes = ['medications', 'conditions', 'allergies', 'lab-results', 'immunizations', 'vital-signs', 'supplements'];
241
+ for (const type of expectedTypes) {
242
+ expect(TYPE_MAPPING[type]).toBeDefined();
243
+ expect(TYPE_MAPPING[type].rdfType).toBeTruthy();
244
+ expect(TYPE_MAPPING[type].nameKey).toBeTruthy();
245
+ expect(TYPE_MAPPING[type].namePred).toBeTruthy();
246
+ }
247
+ });
248
+
249
+ it('should use correct RDF types', () => {
250
+ expect(TYPE_MAPPING['medications'].rdfType).toBe('health:MedicationRecord');
251
+ expect(TYPE_MAPPING['conditions'].rdfType).toBe('health:ConditionRecord');
252
+ expect(TYPE_MAPPING['allergies'].rdfType).toBe('health:AllergyRecord');
253
+ expect(TYPE_MAPPING['lab-results'].rdfType).toBe('health:LabResultRecord');
254
+ expect(TYPE_MAPPING['immunizations'].rdfType).toBe('health:ImmunizationRecord');
255
+ expect(TYPE_MAPPING['vital-signs'].rdfType).toBe('clinical:VitalSign');
256
+ expect(TYPE_MAPPING['supplements'].rdfType).toBe('clinical:Supplement');
257
+ });
258
+ });
259
+
260
+ // ─── PROPERTY_PREDICATES Registry ─────────────────────────────────────────────
261
+
262
+ describe('PROPERTY_PREDICATES', () => {
263
+ it('should map vaccineDate and administrationDate to health:administrationDate', () => {
264
+ expect(PROPERTY_PREDICATES['vaccineDate']).toBe('health:administrationDate');
265
+ expect(PROPERTY_PREDICATES['administrationDate']).toBe('health:administrationDate');
266
+ });
267
+
268
+ it('should have namespace-prefixed predicates for all mappings', () => {
269
+ for (const [key, pred] of Object.entries(PROPERTY_PREDICATES)) {
270
+ expect(pred).toMatch(/^(health|clinical|cascade|coverage|fhir|prov):/);
271
+ }
272
+ });
273
+
274
+ it('should map common medical fields correctly', () => {
275
+ expect(PROPERTY_PREDICATES['dose']).toBe('health:dose');
276
+ expect(PROPERTY_PREDICATES['frequency']).toBe('health:frequency');
277
+ expect(PROPERTY_PREDICATES['severity']).toBe('health:allergySeverity');
278
+ expect(PROPERTY_PREDICATES['resultValue']).toBe('health:resultValue');
279
+ expect(PROPERTY_PREDICATES['interpretation']).toBe('health:interpretation');
280
+ expect(PROPERTY_PREDICATES['indication']).toBe('clinical:indication');
281
+ });
282
+ });
283
+
284
+ // ─── buildRecordTurtle ────────────────────────────────────────────────────────
285
+
286
+ describe('buildRecordTurtle', () => {
287
+ const mockTypeInfo = { directory: 'clinical', filename: 'medications.ttl' };
288
+
289
+ it('should generate valid Turtle for a medication', () => {
290
+ const turtle = buildRecordTurtle(
291
+ 'urn:uuid:test-001',
292
+ 'medications',
293
+ mockTypeInfo,
294
+ { name: 'Aspirin', dose: '81 mg', frequency: 'daily', isActive: true },
295
+ { agentId: 'test-agent', reason: 'Test record' },
296
+ '2026-02-20T10:00:00Z',
297
+ );
298
+
299
+ expect(turtle).toContain('<urn:uuid:test-001> a health:MedicationRecord');
300
+ expect(turtle).toContain('health:medicationName "Aspirin"');
301
+ expect(turtle).toContain('health:dose "81 mg"');
302
+ expect(turtle).toContain('health:frequency "daily"');
303
+ expect(turtle).toContain('health:isActive true');
304
+ expect(turtle).toContain('cascade:dataProvenance cascade:AIGenerated');
305
+ expect(turtle).toContain('cascade:schemaVersion "1.3"');
306
+ expect(turtle).toContain('prov:wasGeneratedBy');
307
+ expect(turtle).toContain('"test-agent"');
308
+ });
309
+
310
+ it('should generate valid Turtle for a condition', () => {
311
+ const turtle = buildRecordTurtle(
312
+ 'urn:uuid:test-002',
313
+ 'conditions',
314
+ { directory: 'clinical', filename: 'conditions.ttl' },
315
+ { name: 'Hypertension', status: 'active', onsetDate: '2020-03-15' },
316
+ undefined,
317
+ '2026-02-20T10:00:00Z',
318
+ );
319
+
320
+ expect(turtle).toContain('a health:ConditionRecord');
321
+ expect(turtle).toContain('health:conditionName "Hypertension"');
322
+ expect(turtle).toContain('health:status "active"');
323
+ expect(turtle).toContain('"2020-03-15"^^xsd:dateTime');
324
+ });
325
+
326
+ it('should include provenance metadata with confidence and source records', () => {
327
+ const turtle = buildRecordTurtle(
328
+ 'urn:uuid:test-003',
329
+ 'medications',
330
+ mockTypeInfo,
331
+ { name: 'Lisinopril' },
332
+ {
333
+ agentId: 'claude-agent',
334
+ reason: 'Drug interaction analysis',
335
+ confidence: 0.95,
336
+ sourceRecords: ['urn:uuid:med-001', 'urn:uuid:lab-001'],
337
+ },
338
+ '2026-02-20T10:00:00Z',
339
+ );
340
+
341
+ expect(turtle).toContain('cascade:confidence "0.95"^^xsd:double');
342
+ expect(turtle).toContain('prov:used <urn:uuid:med-001>, <urn:uuid:lab-001>');
343
+ expect(turtle).toContain('"Drug interaction analysis"');
344
+ });
345
+
346
+ it('should handle special characters in record values', () => {
347
+ const turtle = buildRecordTurtle(
348
+ 'urn:uuid:test-004',
349
+ 'medications',
350
+ mockTypeInfo,
351
+ { name: 'Drug with "quotes" & specials' },
352
+ undefined,
353
+ '2026-02-20T10:00:00Z',
354
+ );
355
+
356
+ // Quotes should be escaped in the output
357
+ expect(turtle).toContain('\\"quotes\\"');
358
+ });
359
+
360
+ it('should skip null and undefined values in record', () => {
361
+ const turtle = buildRecordTurtle(
362
+ 'urn:uuid:test-005',
363
+ 'medications',
364
+ mockTypeInfo,
365
+ { name: 'Test', dose: null as unknown as string, frequency: undefined as unknown as string },
366
+ undefined,
367
+ '2026-02-20T10:00:00Z',
368
+ );
369
+
370
+ expect(turtle).not.toContain('health:dose');
371
+ expect(turtle).not.toContain('health:frequency');
372
+ expect(turtle).toContain('health:medicationName "Test"');
373
+ });
374
+
375
+ it('should use correct name predicate per data type', () => {
376
+ const allergyTurtle = buildRecordTurtle(
377
+ 'urn:uuid:test-006',
378
+ 'allergies',
379
+ { directory: 'clinical', filename: 'allergies.ttl' },
380
+ { name: 'Penicillin', severity: 'severe' },
381
+ undefined,
382
+ '2026-02-20T10:00:00Z',
383
+ );
384
+ expect(allergyTurtle).toContain('health:allergen "Penicillin"');
385
+
386
+ const labTurtle = buildRecordTurtle(
387
+ 'urn:uuid:test-007',
388
+ 'lab-results',
389
+ { directory: 'clinical', filename: 'lab-results.ttl' },
390
+ { name: 'Glucose', resultValue: '95', resultUnit: 'mg/dL' },
391
+ undefined,
392
+ '2026-02-20T10:00:00Z',
393
+ );
394
+ expect(labTurtle).toContain('health:testName "Glucose"');
395
+ });
396
+ });