@kravc/schema 2.7.6 → 2.8.0-alpha.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.
Files changed (176) hide show
  1. package/README.md +19 -14
  2. package/dist/CredentialFactory.d.ts +345 -0
  3. package/dist/CredentialFactory.d.ts.map +1 -0
  4. package/dist/CredentialFactory.js +381 -0
  5. package/dist/CredentialFactory.js.map +1 -0
  6. package/dist/Schema.d.ts +448 -0
  7. package/dist/Schema.d.ts.map +1 -0
  8. package/dist/Schema.js +506 -0
  9. package/dist/Schema.js.map +1 -0
  10. package/dist/ValidationError.d.ts +70 -0
  11. package/dist/ValidationError.d.ts.map +1 -0
  12. package/dist/ValidationError.js +78 -0
  13. package/dist/ValidationError.js.map +1 -0
  14. package/dist/Validator.d.ts +483 -0
  15. package/dist/Validator.d.ts.map +1 -0
  16. package/dist/Validator.js +570 -0
  17. package/dist/Validator.js.map +1 -0
  18. package/dist/helpers/JsonSchema.d.ts +99 -0
  19. package/dist/helpers/JsonSchema.d.ts.map +1 -0
  20. package/dist/helpers/JsonSchema.js +3 -0
  21. package/dist/helpers/JsonSchema.js.map +1 -0
  22. package/dist/helpers/cleanupAttributes.d.ts +34 -0
  23. package/dist/helpers/cleanupAttributes.d.ts.map +1 -0
  24. package/dist/helpers/cleanupAttributes.js +113 -0
  25. package/dist/helpers/cleanupAttributes.js.map +1 -0
  26. package/dist/helpers/cleanupNulls.d.ts +27 -0
  27. package/dist/helpers/cleanupNulls.d.ts.map +1 -0
  28. package/dist/helpers/cleanupNulls.js +96 -0
  29. package/dist/helpers/cleanupNulls.js.map +1 -0
  30. package/dist/helpers/createSchemasMap.d.ts +67 -0
  31. package/dist/helpers/createSchemasMap.d.ts.map +1 -0
  32. package/dist/helpers/createSchemasMap.js +200 -0
  33. package/dist/helpers/createSchemasMap.js.map +1 -0
  34. package/dist/helpers/getReferenceIds.d.ts +169 -0
  35. package/dist/helpers/getReferenceIds.d.ts.map +1 -0
  36. package/dist/helpers/getReferenceIds.js +241 -0
  37. package/dist/helpers/getReferenceIds.js.map +1 -0
  38. package/dist/helpers/got.d.ts +60 -0
  39. package/dist/helpers/got.d.ts.map +1 -0
  40. package/dist/helpers/got.js +72 -0
  41. package/dist/helpers/got.js.map +1 -0
  42. package/dist/helpers/mapObjectProperties.d.ts +150 -0
  43. package/dist/helpers/mapObjectProperties.d.ts.map +1 -0
  44. package/dist/helpers/mapObjectProperties.js +229 -0
  45. package/dist/helpers/mapObjectProperties.js.map +1 -0
  46. package/dist/helpers/normalizeAttributes.d.ts +213 -0
  47. package/dist/helpers/normalizeAttributes.d.ts.map +1 -0
  48. package/dist/helpers/normalizeAttributes.js +243 -0
  49. package/dist/helpers/normalizeAttributes.js.map +1 -0
  50. package/dist/helpers/normalizeProperties.d.ts +168 -0
  51. package/dist/helpers/normalizeProperties.d.ts.map +1 -0
  52. package/dist/helpers/normalizeProperties.js +223 -0
  53. package/dist/helpers/normalizeProperties.js.map +1 -0
  54. package/dist/helpers/normalizeRequired.d.ts +159 -0
  55. package/dist/helpers/normalizeRequired.d.ts.map +1 -0
  56. package/dist/helpers/normalizeRequired.js +206 -0
  57. package/dist/helpers/normalizeRequired.js.map +1 -0
  58. package/dist/helpers/normalizeType.d.ts +81 -0
  59. package/dist/helpers/normalizeType.d.ts.map +1 -0
  60. package/dist/helpers/normalizeType.js +210 -0
  61. package/dist/helpers/normalizeType.js.map +1 -0
  62. package/dist/helpers/nullifyEmptyValues.d.ts +139 -0
  63. package/dist/helpers/nullifyEmptyValues.d.ts.map +1 -0
  64. package/dist/helpers/nullifyEmptyValues.js +191 -0
  65. package/dist/helpers/nullifyEmptyValues.js.map +1 -0
  66. package/dist/helpers/removeRequiredAndDefault.d.ts +106 -0
  67. package/dist/helpers/removeRequiredAndDefault.d.ts.map +1 -0
  68. package/dist/helpers/removeRequiredAndDefault.js +138 -0
  69. package/dist/helpers/removeRequiredAndDefault.js.map +1 -0
  70. package/dist/helpers/validateId.d.ts +39 -0
  71. package/dist/helpers/validateId.d.ts.map +1 -0
  72. package/dist/helpers/validateId.js +51 -0
  73. package/dist/helpers/validateId.js.map +1 -0
  74. package/dist/index.d.ts +9 -0
  75. package/dist/index.d.ts.map +1 -0
  76. package/dist/index.js +21 -0
  77. package/dist/index.js.map +1 -0
  78. package/dist/ld/documentLoader.d.ts +8 -0
  79. package/dist/ld/documentLoader.d.ts.map +1 -0
  80. package/dist/ld/documentLoader.js +24 -0
  81. package/dist/ld/documentLoader.js.map +1 -0
  82. package/dist/ld/getLinkedDataAttributeType.d.ts +10 -0
  83. package/dist/ld/getLinkedDataAttributeType.d.ts.map +1 -0
  84. package/dist/ld/getLinkedDataAttributeType.js +32 -0
  85. package/dist/ld/getLinkedDataAttributeType.js.map +1 -0
  86. package/dist/ld/getLinkedDataContext.d.ts +19 -0
  87. package/dist/ld/getLinkedDataContext.d.ts.map +1 -0
  88. package/dist/ld/getLinkedDataContext.js +50 -0
  89. package/dist/ld/getLinkedDataContext.js.map +1 -0
  90. package/eslint.config.mjs +32 -52
  91. package/examples/credentials/createAccountCredential.ts +27 -0
  92. package/examples/credentials/createMineSweeperScoreCredential.ts +115 -0
  93. package/examples/index.ts +7 -0
  94. package/examples/schemas/FavoriteItemSchema.ts +27 -0
  95. package/examples/{Preferences.yaml → schemas/Preferences.yaml} +2 -0
  96. package/examples/schemas/PreferencesSchema.ts +29 -0
  97. package/examples/schemas/ProfileSchema.ts +91 -0
  98. package/examples/schemas/Status.yaml +3 -0
  99. package/examples/schemas/StatusSchema.ts +12 -0
  100. package/jest.config.mjs +5 -0
  101. package/package.json +27 -20
  102. package/src/CredentialFactory.ts +392 -0
  103. package/src/Schema.ts +583 -0
  104. package/src/ValidationError.ts +90 -0
  105. package/src/Validator.ts +603 -0
  106. package/src/__tests__/CredentialFactory.test.ts +588 -0
  107. package/src/__tests__/Schema.test.ts +371 -0
  108. package/src/__tests__/ValidationError.test.ts +235 -0
  109. package/src/__tests__/Validator.test.ts +787 -0
  110. package/src/helpers/JsonSchema.ts +119 -0
  111. package/src/helpers/__tests__/cleanupAttributes.test.ts +943 -0
  112. package/src/helpers/__tests__/cleanupNulls.test.ts +772 -0
  113. package/src/helpers/__tests__/createSchemasMap.test.ts +238 -0
  114. package/src/helpers/__tests__/getReferenceIds.test.ts +975 -0
  115. package/src/helpers/__tests__/got.test.ts +193 -0
  116. package/src/helpers/__tests__/mapObjectProperties.test.ts +1126 -0
  117. package/src/helpers/__tests__/normalizeAttributes.test.ts +1435 -0
  118. package/src/helpers/__tests__/normalizeProperties.test.ts +727 -0
  119. package/src/helpers/__tests__/normalizeRequired.test.ts +669 -0
  120. package/src/helpers/__tests__/normalizeType.test.ts +772 -0
  121. package/src/helpers/__tests__/nullifyEmptyValues.test.ts +735 -0
  122. package/src/helpers/__tests__/removeRequiredAndDefault.test.ts +734 -0
  123. package/src/helpers/__tests__/validateId.test.ts +118 -0
  124. package/src/helpers/cleanupAttributes.ts +151 -0
  125. package/src/helpers/cleanupNulls.ts +106 -0
  126. package/src/helpers/createSchemasMap.ts +212 -0
  127. package/src/helpers/getReferenceIds.ts +273 -0
  128. package/src/helpers/got.ts +73 -0
  129. package/src/helpers/mapObjectProperties.ts +272 -0
  130. package/src/helpers/normalizeAttributes.ts +247 -0
  131. package/src/helpers/normalizeProperties.ts +249 -0
  132. package/src/helpers/normalizeRequired.ts +233 -0
  133. package/src/helpers/normalizeType.ts +235 -0
  134. package/src/helpers/nullifyEmptyValues.ts +207 -0
  135. package/src/helpers/removeRequiredAndDefault.ts +151 -0
  136. package/src/helpers/validateId.ts +53 -0
  137. package/src/index.ts +17 -0
  138. package/src/ld/__tests__/documentLoader.test.ts +57 -0
  139. package/src/ld/__tests__/getLinkedDataAttributeType.test.ts +212 -0
  140. package/src/ld/__tests__/getLinkedDataContext.test.ts +378 -0
  141. package/src/ld/documentLoader.ts +28 -0
  142. package/src/ld/getLinkedDataAttributeType.ts +46 -0
  143. package/src/ld/getLinkedDataContext.ts +80 -0
  144. package/tsconfig.json +27 -0
  145. package/types/credentials-context.d.ts +14 -0
  146. package/types/security-context.d.ts +6 -0
  147. package/examples/Status.yaml +0 -3
  148. package/examples/createAccountCredential.js +0 -27
  149. package/examples/createMineSweeperScoreCredential.js +0 -63
  150. package/examples/index.js +0 -9
  151. package/src/CredentialFactory.js +0 -67
  152. package/src/CredentialFactory.spec.js +0 -131
  153. package/src/Schema.js +0 -104
  154. package/src/Schema.spec.js +0 -172
  155. package/src/ValidationError.js +0 -31
  156. package/src/Validator.js +0 -128
  157. package/src/Validator.spec.js +0 -355
  158. package/src/helpers/cleanupAttributes.js +0 -71
  159. package/src/helpers/cleanupNulls.js +0 -42
  160. package/src/helpers/getReferenceIds.js +0 -71
  161. package/src/helpers/mapObject.js +0 -65
  162. package/src/helpers/normalizeAttributes.js +0 -28
  163. package/src/helpers/normalizeProperties.js +0 -61
  164. package/src/helpers/normalizeRequired.js +0 -37
  165. package/src/helpers/normalizeType.js +0 -41
  166. package/src/helpers/nullifyEmptyValues.js +0 -57
  167. package/src/helpers/removeRequiredAndDefault.js +0 -30
  168. package/src/helpers/validateId.js +0 -19
  169. package/src/index.d.ts +0 -25
  170. package/src/index.js +0 -8
  171. package/src/ld/documentLoader.js +0 -25
  172. package/src/ld/documentLoader.spec.js +0 -12
  173. package/src/ld/getLinkedDataContext.js +0 -63
  174. package/src/ld/getLinkedDataType.js +0 -38
  175. /package/examples/{FavoriteItem.yaml → schemas/FavoriteItem.yaml} +0 -0
  176. /package/examples/{Profile.yaml → schemas/Profile.yaml} +0 -0
@@ -0,0 +1,787 @@
1
+ import { load } from 'js-yaml';
2
+ import { readFileSync } from 'fs';
3
+ import { Schema, Validator, ValidationError } from '../../src';
4
+ import type {
5
+ PropertiesSchema,
6
+ EnumSchema
7
+ } from '../../src/helpers/JsonSchema';
8
+
9
+ // eslint-disable-next-line jsdoc/require-jsdoc
10
+ const loadSync = (yamlPath: string): Schema => {
11
+ const id = yamlPath.split('.')[0].split('/').reverse()[0];
12
+ const fullPath = yamlPath.startsWith('/') ? yamlPath : `${process.cwd()}/${yamlPath}`;
13
+ const source = load(readFileSync(fullPath, 'utf8')) as PropertiesSchema | EnumSchema;
14
+
15
+ return new Schema(source, id);
16
+ };
17
+
18
+ const SCHEMAS = [
19
+ 'examples/schemas/Status.yaml',
20
+ 'examples/schemas/Profile.yaml',
21
+ 'examples/schemas/Preferences.yaml',
22
+ 'examples/schemas/FavoriteItem.yaml'
23
+ ].map(path => loadSync(path));
24
+
25
+ describe('Validator', () => {
26
+ describe('constructor', () => {
27
+ it('creates validator instance with valid schemas', () => {
28
+ const validator = new Validator(SCHEMAS);
29
+ expect(validator).toBeInstanceOf(Validator);
30
+ expect(validator.schemasMap).toBeDefined();
31
+ });
32
+
33
+ it('throws error if no schemas provided', () => {
34
+ expect(() => new Validator(undefined)).toThrow('No schemas provided');
35
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
+ expect(() => new Validator(null as any)).toThrow('No schemas provided');
37
+ });
38
+
39
+ it('throws error if empty schemas array provided', () => {
40
+ expect(() => new Validator([])).toThrow();
41
+ });
42
+
43
+ it('throws error if multiple schemas have the same ID', () => {
44
+ const schema1 = new Schema({ name: { type: 'string' } }, 'Duplicate');
45
+ const schema2 = new Schema({ age: { type: 'number' } }, 'Duplicate');
46
+
47
+ expect(() => new Validator([schema1, schema2]))
48
+ .toThrow('Multiple schemas provided for ID: Duplicate');
49
+ });
50
+
51
+ it('throws error if referenced schema is not found', () => {
52
+ const entitySchema = new Schema({ name: { $ref: 'MissingSchema' } }, 'Entity');
53
+
54
+ expect(() => new Validator([...SCHEMAS, entitySchema]))
55
+ .toThrow('Schemas validation failed, errors:');
56
+ });
57
+
58
+ it('accepts schemas with valid references', () => {
59
+ const addressSchema = new Schema({
60
+ street: { type: 'string' }
61
+ }, 'Address');
62
+
63
+ const userSchema = new Schema({
64
+ name: { type: 'string', required: true },
65
+ address: { $ref: 'Address' }
66
+ }, 'User');
67
+
68
+ expect(() => new Validator([addressSchema, userSchema])).not.toThrow();
69
+ });
70
+
71
+ it('validates schema structure during initialization', () => {
72
+ // The constructor validates schemas using z-schema
73
+ // Invalid references are caught (tested above)
74
+ // This test ensures valid schemas pass validation
75
+ const validSchema = new Schema({
76
+ name: { type: 'string', required: true }
77
+ }, 'Valid');
78
+
79
+ expect(() => new Validator([validSchema])).not.toThrow();
80
+ });
81
+ });
82
+
83
+ describe('.validate()', () => {
84
+ describe('basic validation', () => {
85
+ it('returns validated object when input matches schema', () => {
86
+ const validator = new Validator(SCHEMAS);
87
+
88
+ const input = {
89
+ name: 'Oleksandr',
90
+ gender: 'Other',
91
+ status: 'ACTIVE',
92
+ contactDetails: {
93
+ email: 'a@kra.vc',
94
+ mobileNumber: '380504112171'
95
+ }
96
+ };
97
+
98
+ const result = validator.validate(input, 'Profile');
99
+
100
+ expect(result.name).toEqual('Oleksandr');
101
+ expect(result.gender).toEqual('Other');
102
+ expect(result.status).toEqual('ACTIVE');
103
+ expect(result.contactDetails.email).toEqual('a@kra.vc');
104
+ expect(result.contactDetails.mobileNumber).toEqual('380504112171');
105
+ });
106
+
107
+ it('throws ValidationError when required fields are missing', () => {
108
+ const validator = new Validator(SCHEMAS);
109
+
110
+ try {
111
+ validator.validate({}, 'Profile');
112
+ throw new Error('Expected ValidationError to be thrown');
113
+ } catch (error) {
114
+ expect(error).toBeInstanceOf(ValidationError);
115
+ const errorDetails = (error as ValidationError).toJSON();
116
+ expect(errorDetails.schemaId).toEqual('Profile');
117
+ expect(errorDetails.code).toEqual('ValidationError');
118
+ expect(errorDetails.validationErrors.length).toBeGreaterThan(0);
119
+ }
120
+ });
121
+
122
+ it('throws ValidationError when field types do not match', () => {
123
+ const validator = new Validator(SCHEMAS);
124
+
125
+ const input = {
126
+ name: 'Oleksandr',
127
+ contactDetails: {
128
+ email: 'a@kra.vc'
129
+ },
130
+ favoriteItems: 'NOT_AN_ARRAY'
131
+ };
132
+
133
+ try {
134
+ validator.validate(input, 'Profile');
135
+ throw new Error('Expected ValidationError to be thrown');
136
+ } catch (error) {
137
+ expect(error).toBeInstanceOf(ValidationError);
138
+ const errorDetails = (error as ValidationError).toJSON();
139
+ const errorMessages = errorDetails.validationErrors.map((e: { message: string }) => e.message);
140
+ expect(errorMessages.some((msg: string) => msg.includes('array'))).toBe(true);
141
+ }
142
+ });
143
+
144
+ it('throws error if schema not found', () => {
145
+ const validator = new Validator(SCHEMAS);
146
+
147
+ expect(() => validator.validate({}, 'NonExistentSchema'))
148
+ .toThrow('Schema "NonExistentSchema" not found');
149
+ });
150
+ });
151
+
152
+ describe('attribute cleanup', () => {
153
+ it('removes attributes not defined in schema when cleanup is enabled', () => {
154
+ const validator = new Validator(SCHEMAS);
155
+
156
+ const input = {
157
+ name: 'Oleksandr',
158
+ gender: 'Other',
159
+ status: 'ACTIVE',
160
+ contactDetails: {
161
+ email: 'a@kra.vc',
162
+ mobileNumber: '380504112171'
163
+ },
164
+ extraField: 'should be removed',
165
+ _internalId: 'should be removed',
166
+ anotherExtra: { nested: 'data' }
167
+ };
168
+
169
+ const result = validator.validate(input, 'Profile', false, false);
170
+
171
+ expect(result.extraField).toBeUndefined();
172
+ expect(result._internalId).toBeUndefined();
173
+ expect(result.anotherExtra).toBeUndefined();
174
+ expect(result.name).toEqual('Oleksandr');
175
+ });
176
+
177
+ it('removes null values when shouldCleanupNulls is true', () => {
178
+ const validator = new Validator(SCHEMAS);
179
+
180
+ const _createdAt = new Date().toISOString();
181
+
182
+ const input = {
183
+ name: 'Oleksandr',
184
+ gender: 'Other',
185
+ status: 'ACTIVE',
186
+ contactDetails: {
187
+ email: 'a@kra.vc',
188
+ mobileNumber: '380504112171',
189
+ toBeRemoved: null
190
+ },
191
+ favoriteItems: [{
192
+ id: '1',
193
+ name: 'Book',
194
+ categories: ['Education'],
195
+ status: 'PENDING',
196
+ toBeRemoved: null,
197
+ _createdAt
198
+ }],
199
+ locations: [{
200
+ name: 'Home',
201
+ address: {
202
+ type: 'Primary',
203
+ country: 'Ukraine',
204
+ zip: '03119',
205
+ city: 'Kyiv',
206
+ addressLine1: 'Melnikova 83-D, 78',
207
+ _createdAt
208
+ },
209
+ _createdAt
210
+ }],
211
+ preferences: {
212
+ height: 180,
213
+ isNotificationEnabled: true,
214
+ _createdAt
215
+ },
216
+ toBeRemoved: null,
217
+ _createdAt
218
+ };
219
+
220
+ const result = validator.validate(input, 'Profile', false, true);
221
+
222
+ expect(result.toBeRemoved).toBeUndefined();
223
+ expect(result.contactDetails.toBeRemoved).toBeUndefined();
224
+ expect(result.favoriteItems[0].toBeRemoved).toBeUndefined();
225
+ expect(result._createdAt).toBeUndefined();
226
+ expect(result.preferences._createdAt).toBeUndefined();
227
+ expect(result.locations[0]._createdAt).toBeUndefined();
228
+ expect(result.locations[0].address._createdAt).toBeUndefined();
229
+ expect(result.favoriteItems[0]._createdAt).toBeUndefined();
230
+ });
231
+
232
+ it('preserves null values when shouldCleanupNulls is false', () => {
233
+ const validator = new Validator(SCHEMAS);
234
+
235
+ const input = {
236
+ name: 'Oleksandr',
237
+ gender: 'Other',
238
+ status: 'ACTIVE',
239
+ contactDetails: {
240
+ email: 'a@kra.vc',
241
+ mobileNumber: '380504112171',
242
+ nullableField: null
243
+ }
244
+ };
245
+
246
+ const result = validator.validate(input, 'Profile', false, false);
247
+
248
+ // Null values in nested objects may be preserved depending on schema
249
+ expect(result.name).toEqual('Oleksandr');
250
+ });
251
+ });
252
+
253
+ describe('type normalization', () => {
254
+ it('normalizes string numbers to numbers', () => {
255
+ const validator = new Validator(SCHEMAS);
256
+
257
+ const input = {
258
+ name: 'Oleksandr',
259
+ gender: 'Other',
260
+ status: 'ACTIVE',
261
+ contactDetails: {
262
+ email: 'a@kra.vc',
263
+ mobileNumber: '380504112171'
264
+ },
265
+ preferences: {
266
+ height: '180'
267
+ }
268
+ };
269
+
270
+ const result = validator.validate(input, 'Profile');
271
+
272
+ expect(result.preferences.height).toEqual(180);
273
+ expect(typeof result.preferences.height).toBe('number');
274
+ });
275
+
276
+ it('normalizes string booleans to booleans', () => {
277
+ const validator = new Validator(SCHEMAS);
278
+
279
+ const testCases = [
280
+ { input: 'true', expected: true },
281
+ { input: '1', expected: true },
282
+ { input: 'yes', expected: true },
283
+ { input: 'false', expected: false },
284
+ { input: '0', expected: false },
285
+ { input: 'no', expected: false }
286
+ ];
287
+
288
+ testCases.forEach(({ input, expected }) => {
289
+ const testInput = {
290
+ name: 'Oleksandr',
291
+ gender: 'Other',
292
+ status: 'ACTIVE',
293
+ contactDetails: {
294
+ email: 'a@kra.vc',
295
+ mobileNumber: '380504112171'
296
+ },
297
+ preferences: {
298
+ isNotificationEnabled: input
299
+ }
300
+ };
301
+
302
+ const result = validator.validate(testInput, 'Profile');
303
+ expect(result.preferences.isNotificationEnabled).toEqual(expected);
304
+ });
305
+ });
306
+
307
+ it('normalizes numeric booleans to booleans', () => {
308
+ const validator = new Validator(SCHEMAS);
309
+
310
+ const input = {
311
+ name: 'Oleksandr',
312
+ gender: 'Other',
313
+ status: 'ACTIVE',
314
+ contactDetails: {
315
+ email: 'a@kra.vc',
316
+ mobileNumber: '380504112171'
317
+ },
318
+ preferences: {
319
+ isNotificationEnabled: 0
320
+ }
321
+ };
322
+
323
+ const result = validator.validate(input, 'Profile');
324
+
325
+ expect(result.preferences.isNotificationEnabled).toEqual(false);
326
+ expect(typeof result.preferences.isNotificationEnabled).toBe('boolean');
327
+ });
328
+
329
+ it('throws validation error for invalid type conversions', () => {
330
+ const validator = new Validator(SCHEMAS);
331
+
332
+ const input = {
333
+ name: 'Oleksandr',
334
+ gender: 'Other',
335
+ status: 'ACTIVE',
336
+ contactDetails: {
337
+ email: 'a@kra.vc',
338
+ mobileNumber: '380504112171'
339
+ },
340
+ preferences: {
341
+ height: 'NaN',
342
+ isNotificationEnabled: 'invalid'
343
+ }
344
+ };
345
+
346
+ expect(() => validator.validate(input, 'Profile'))
347
+ .toThrow('"Profile" validation failed');
348
+ });
349
+ });
350
+
351
+ describe('nullify empty values', () => {
352
+ it('converts empty strings to null for optional fields when shouldNullifyEmptyValues is true', () => {
353
+ const validator = new Validator(SCHEMAS);
354
+
355
+ const input = {
356
+ name: 'Oleksandr',
357
+ status: 'ACTIVE',
358
+ gender: '', // Optional enum field
359
+ contactDetails: {
360
+ email: 'a@kra.vc',
361
+ mobileNumber: '', // Optional pattern field
362
+ secondaryEmail: '' // Optional format field
363
+ }
364
+ };
365
+
366
+ const result = validator.validate(input, 'Profile', true);
367
+
368
+ expect(result.gender).toBeNull();
369
+ expect(result.contactDetails.mobileNumber).toBeNull();
370
+ expect(result.contactDetails.secondaryEmail).toBeNull();
371
+ });
372
+
373
+ it('throws validation error for empty strings when shouldNullifyEmptyValues is false', () => {
374
+ const validator = new Validator(SCHEMAS);
375
+
376
+ const input = {
377
+ name: 'Oleksandr',
378
+ gender: '',
379
+ contactDetails: {
380
+ email: 'a@kra.vc',
381
+ mobileNumber: '',
382
+ secondaryEmail: ''
383
+ }
384
+ };
385
+
386
+ expect(() => validator.validate(input, 'Profile', false))
387
+ .toThrow('"Profile" validation failed');
388
+ });
389
+
390
+ it('still throws validation errors for invalid values even when nullifying empty values', () => {
391
+ const validator = new Validator(SCHEMAS);
392
+
393
+ const input = {
394
+ name: '', // Required field with empty string
395
+ gender: 'INVALID_ENUM', // Invalid enum value
396
+ contactDetails: {
397
+ email: 'a@kra.vc',
398
+ mobileNumber: 'abc', // Invalid pattern
399
+ secondaryEmail: ''
400
+ },
401
+ preferences: {
402
+ age: 'invalid-number' // Invalid type
403
+ }
404
+ };
405
+
406
+ try {
407
+ validator.validate(input, 'Profile', true);
408
+ throw new Error('Expected ValidationError to be thrown');
409
+ } catch (error) {
410
+ expect(error).toBeInstanceOf(ValidationError);
411
+ const errorDetails = (error as ValidationError).toJSON();
412
+ expect(errorDetails.validationErrors.length).toBeGreaterThan(0);
413
+
414
+ const codes = errorDetails.validationErrors.map((e: { code: string }) => e.code);
415
+ expect(codes).toContain('MIN_LENGTH'); // name is empty
416
+ expect(codes).toContain('ENUM_MISMATCH'); // gender is invalid
417
+ expect(codes).toContain('PATTERN'); // mobileNumber pattern mismatch
418
+ }
419
+ });
420
+ });
421
+
422
+ describe('nested objects and arrays', () => {
423
+ it('validates nested objects correctly', () => {
424
+ const validator = new Validator(SCHEMAS);
425
+
426
+ const input = {
427
+ name: 'Oleksandr',
428
+ gender: 'Other',
429
+ status: 'ACTIVE',
430
+ contactDetails: {
431
+ email: 'a@kra.vc',
432
+ mobileNumber: '380504112171'
433
+ },
434
+ locations: [{
435
+ name: 'Home',
436
+ address: {
437
+ type: 'Primary',
438
+ country: 'Ukraine',
439
+ zip: '03119',
440
+ city: 'Kyiv',
441
+ addressLine1: 'Melnikova 83-D, 78'
442
+ }
443
+ }],
444
+ favoriteItems: [{
445
+ id: '1',
446
+ name: 'Student Book',
447
+ categories: ['Education'],
448
+ status: 'PENDING'
449
+ }],
450
+ preferences: {
451
+ height: 180,
452
+ isNotificationEnabled: true
453
+ }
454
+ };
455
+
456
+ const result = validator.validate(input, 'Profile');
457
+
458
+ expect(result.locations).toHaveLength(1);
459
+ expect(result.locations[0].name).toEqual('Home');
460
+ expect(result.locations[0].address.country).toEqual('Ukraine');
461
+ expect(result.locations[0].address.city).toEqual('Kyiv');
462
+ expect(result.favoriteItems).toHaveLength(1);
463
+ expect(result.favoriteItems[0].name).toEqual('Student Book');
464
+ expect(result.preferences.height).toEqual(180);
465
+ });
466
+
467
+ it('validates empty arrays', () => {
468
+ const validator = new Validator(SCHEMAS);
469
+
470
+ const input = {
471
+ name: 'Oleksandr',
472
+ gender: 'Other',
473
+ status: 'ACTIVE',
474
+ contactDetails: {
475
+ email: 'a@kra.vc',
476
+ mobileNumber: '380504112171'
477
+ },
478
+ locations: [],
479
+ favoriteItems: [],
480
+ tags: []
481
+ };
482
+
483
+ const result = validator.validate(input, 'Profile');
484
+
485
+ expect(result.locations).toEqual([]);
486
+ expect(result.favoriteItems).toEqual([]);
487
+ expect(result.tags).toEqual([]);
488
+ });
489
+
490
+ it('validates schema references correctly', () => {
491
+ const validator = new Validator(SCHEMAS);
492
+
493
+ const input = {
494
+ name: 'Oleksandr',
495
+ gender: 'Other',
496
+ status: 'ACTIVE', // References Status enum
497
+ contactDetails: {
498
+ email: 'a@kra.vc',
499
+ mobileNumber: '380504112171'
500
+ },
501
+ favoriteItems: [{ // References FavoriteItem schema
502
+ id: '1',
503
+ name: 'Book',
504
+ categories: ['Education'],
505
+ status: 'PENDING'
506
+ }],
507
+ preferences: { // References Preferences schema
508
+ height: 180,
509
+ isNotificationEnabled: true
510
+ }
511
+ };
512
+
513
+ const result = validator.validate(input, 'Profile');
514
+
515
+ expect(result.status).toEqual('ACTIVE');
516
+ expect(result.favoriteItems[0].status).toEqual('PENDING');
517
+ expect(result.preferences.height).toEqual(180);
518
+ });
519
+ });
520
+
521
+ describe('error handling', () => {
522
+ it('returns ValidationError with correct structure', () => {
523
+ const validator = new Validator(SCHEMAS);
524
+
525
+ try {
526
+ validator.validate({}, 'Profile');
527
+ throw new Error('Expected ValidationError');
528
+ } catch (error) {
529
+ expect(error).toBeInstanceOf(ValidationError);
530
+ expect(error).toBeInstanceOf(Error);
531
+
532
+ const errorDetails = (error as ValidationError).toJSON();
533
+
534
+ expect(errorDetails).toHaveProperty('code');
535
+ expect(errorDetails).toHaveProperty('message');
536
+ expect(errorDetails).toHaveProperty('schemaId');
537
+ expect(errorDetails).toHaveProperty('object');
538
+ expect(errorDetails).toHaveProperty('validationErrors');
539
+
540
+ expect(errorDetails.code).toEqual('ValidationError');
541
+ expect(errorDetails.schemaId).toEqual('Profile');
542
+ expect(errorDetails.message).toEqual('"Profile" validation failed');
543
+ expect(Array.isArray(errorDetails.validationErrors)).toBe(true);
544
+ }
545
+ });
546
+
547
+ it('includes error path, code, and message in validation errors', () => {
548
+ const validator = new Validator(SCHEMAS);
549
+
550
+ try {
551
+ validator.validate({
552
+ name: '',
553
+ contactDetails: {
554
+ email: 'invalid-email'
555
+ }
556
+ }, 'Profile');
557
+ throw new Error('Expected ValidationError');
558
+ } catch (error) {
559
+ const errorDetails = (error as ValidationError).toJSON();
560
+
561
+ expect(errorDetails.validationErrors.length).toBeGreaterThan(0);
562
+
563
+ errorDetails.validationErrors.forEach((err: { path: string; code: string; message: string }) => {
564
+ expect(err).toHaveProperty('path');
565
+ expect(err).toHaveProperty('code');
566
+ expect(err).toHaveProperty('message');
567
+ expect(typeof err.path).toBe('string');
568
+ expect(typeof err.code).toBe('string');
569
+ expect(typeof err.message).toBe('string');
570
+ });
571
+ }
572
+ });
573
+ });
574
+ });
575
+
576
+ describe('.normalize()', () => {
577
+ it('normalizes object types without validation', () => {
578
+ const validator = new Validator(SCHEMAS);
579
+
580
+ const input = {
581
+ preferences: {
582
+ height: '180',
583
+ isNotificationEnabled: 'true'
584
+ }
585
+ };
586
+
587
+ const result = validator.normalize(input, 'Profile');
588
+
589
+ expect(result.preferences.height).toEqual(180);
590
+ expect(result.preferences.isNotificationEnabled).toEqual(true);
591
+ });
592
+
593
+ it('applies default values from schema', () => {
594
+ const validator = new Validator(SCHEMAS);
595
+
596
+ const input = {};
597
+
598
+ const result = validator.normalize(input, 'Profile');
599
+
600
+ expect(result.gender).toEqual('Other');
601
+ expect(result.status).toEqual('Pending');
602
+ });
603
+
604
+ it('does not validate required fields', () => {
605
+ const validator = new Validator(SCHEMAS);
606
+
607
+ const input = {
608
+ // Missing required 'name' field
609
+ preferences: {
610
+ height: '180'
611
+ }
612
+ };
613
+
614
+ // Should not throw even though required fields are missing
615
+ const result = validator.normalize(input, 'Profile');
616
+
617
+ expect(result.preferences.height).toEqual(180);
618
+ expect(result.name).toBeUndefined();
619
+ });
620
+
621
+ it('does not remove undefined attributes', () => {
622
+ const validator = new Validator(SCHEMAS);
623
+
624
+ const input = {
625
+ name: 'Oleksandr',
626
+ extraField: 'should remain',
627
+ contactDetails: {
628
+ email: 'a@kra.vc',
629
+ extraNested: 'should remain'
630
+ }
631
+ };
632
+
633
+ const result = validator.normalize(input, 'Profile');
634
+
635
+ expect(result.extraField).toEqual('should remain');
636
+ expect(result.contactDetails.extraNested).toEqual('should remain');
637
+ });
638
+
639
+ it('returns a new object without mutating input', () => {
640
+ const validator = new Validator(SCHEMAS);
641
+
642
+ const input = {
643
+ preferences: {
644
+ height: '180'
645
+ }
646
+ };
647
+
648
+ const result = validator.normalize(input, 'Profile');
649
+
650
+ expect(result).not.toBe(input);
651
+ expect(input.preferences.height).toEqual('180'); // Original unchanged
652
+ expect(result.preferences.height).toEqual(180); // Normalized in result
653
+ });
654
+
655
+ it('throws error if schema not found', () => {
656
+ const validator = new Validator(SCHEMAS);
657
+
658
+ expect(() => validator.normalize({}, 'NonExistentSchema'))
659
+ .toThrow('Schema "NonExistentSchema" not found');
660
+ });
661
+ });
662
+
663
+ describe('.schemasMap', () => {
664
+ it('returns a map of all registered schemas', () => {
665
+ const validator = new Validator(SCHEMAS);
666
+
667
+ const schemasMap = validator.schemasMap;
668
+
669
+ expect(schemasMap).toBeDefined();
670
+ expect(typeof schemasMap).toBe('object');
671
+ });
672
+
673
+ it('contains all schemas by their IDs', () => {
674
+ const validator = new Validator(SCHEMAS);
675
+
676
+ const schemasMap = validator.schemasMap;
677
+
678
+ expect(schemasMap['Status']).toBeDefined();
679
+ expect(schemasMap['Profile']).toBeDefined();
680
+ expect(schemasMap['Preferences']).toBeDefined();
681
+ expect(schemasMap['FavoriteItem']).toBeDefined();
682
+ });
683
+
684
+ it('returns Schema instances', () => {
685
+ const validator = new Validator(SCHEMAS);
686
+
687
+ const schemasMap = validator.schemasMap;
688
+
689
+ expect(schemasMap['Status']).toBeInstanceOf(Schema);
690
+ expect(schemasMap['Profile']).toBeInstanceOf(Schema);
691
+ });
692
+
693
+ it('allows iteration over schemas', () => {
694
+ const validator = new Validator(SCHEMAS);
695
+
696
+ const schemasMap = validator.schemasMap;
697
+ const schemaIds = Object.keys(schemasMap);
698
+
699
+ expect(schemaIds.length).toBeGreaterThan(0);
700
+ schemaIds.forEach(id => {
701
+ expect(schemasMap[id]).toBeInstanceOf(Schema);
702
+ expect(schemasMap[id].id).toEqual(id);
703
+ });
704
+ });
705
+
706
+ it('returns the same reference on multiple calls', () => {
707
+ const validator = new Validator(SCHEMAS);
708
+
709
+ const map1 = validator.schemasMap;
710
+ const map2 = validator.schemasMap;
711
+
712
+ expect(map1).toBe(map2);
713
+ });
714
+ });
715
+
716
+ describe('.getReferenceIds()', () => {
717
+ it('returns array of referenced schema IDs', () => {
718
+ const validator = new Validator(SCHEMAS);
719
+
720
+ const referenceIds = validator.getReferenceIds('Profile');
721
+
722
+ expect(Array.isArray(referenceIds)).toBe(true);
723
+ expect(referenceIds.length).toBeGreaterThan(0);
724
+ });
725
+
726
+ it('includes all direct schema references', () => {
727
+ const validator = new Validator(SCHEMAS);
728
+
729
+ const referenceIds = validator.getReferenceIds('Profile');
730
+
731
+ expect(referenceIds).toContain('Status');
732
+ expect(referenceIds).toContain('FavoriteItem');
733
+ expect(referenceIds).toContain('Preferences');
734
+ });
735
+
736
+ it('returns empty array for schema with no references', () => {
737
+ const simpleSchema = new Schema({
738
+ name: { type: 'string' }
739
+ }, 'Simple');
740
+
741
+ const validator = new Validator([simpleSchema]);
742
+
743
+ const referenceIds = validator.getReferenceIds('Simple');
744
+
745
+ expect(referenceIds).toEqual([]);
746
+ });
747
+
748
+ it('includes nested references', () => {
749
+ const countrySchema = new Schema({
750
+ code: { type: 'string' }
751
+ }, 'Country');
752
+
753
+ const addressSchema = new Schema({
754
+ street: { type: 'string' },
755
+ country: { $ref: 'Country' }
756
+ }, 'Address');
757
+
758
+ const userSchema = new Schema({
759
+ name: { type: 'string' },
760
+ address: { $ref: 'Address' }
761
+ }, 'User');
762
+
763
+ const validator = new Validator([countrySchema, addressSchema, userSchema]);
764
+
765
+ const referenceIds = validator.getReferenceIds('User');
766
+
767
+ expect(referenceIds).toContain('Address');
768
+ expect(referenceIds).toContain('Country');
769
+ });
770
+
771
+ it('throws error if schema not found', () => {
772
+ const validator = new Validator(SCHEMAS);
773
+
774
+ expect(() => validator.getReferenceIds('NonExistentSchema'))
775
+ .toThrow('Schema "NonExistentSchema" not found');
776
+ });
777
+
778
+ it('handles enum schemas correctly', () => {
779
+ const validator = new Validator(SCHEMAS);
780
+
781
+ // Enum schemas typically don't have references
782
+ const referenceIds = validator.getReferenceIds('Status');
783
+
784
+ expect(Array.isArray(referenceIds)).toBe(true);
785
+ });
786
+ });
787
+ });