Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1<?php
2
3namespace SocialDept\AtpSchema\Tests\Unit\Validation;
4
5use Orchestra\Testbench\TestCase;
6use SocialDept\AtpSchema\Data\LexiconDocument;
7use SocialDept\AtpSchema\Parser\Nsid;
8use SocialDept\AtpSchema\Parser\SchemaLoader;
9use SocialDept\AtpSchema\Validation\Validator;
10
11class ValidatorTest extends TestCase
12{
13 protected Validator $validator;
14
15 protected SchemaLoader $loader;
16
17 protected function setUp(): void
18 {
19 parent::setUp();
20
21 $fixturesPath = __DIR__.'/../../fixtures';
22 $this->loader = new SchemaLoader([$fixturesPath], false);
23 $this->validator = new Validator($this->loader);
24 }
25
26 public function test_it_validates_valid_data(): void
27 {
28 $document = $this->createDocument([
29 'type' => 'record',
30 'record' => [
31 'type' => 'object',
32 'required' => ['name'],
33 'properties' => [
34 'name' => ['type' => 'string'],
35 'age' => ['type' => 'integer'],
36 ],
37 ],
38 ]);
39
40 $data = ['name' => 'John', 'age' => 30];
41
42 $this->assertTrue($this->validator->validate($data, $document));
43 }
44
45 public function test_it_rejects_missing_required_field(): void
46 {
47 $document = $this->createDocument([
48 'type' => 'record',
49 'record' => [
50 'type' => 'object',
51 'required' => ['name', 'email'],
52 'properties' => [
53 'name' => ['type' => 'string'],
54 'email' => ['type' => 'string'],
55 ],
56 ],
57 ]);
58
59 $data = ['name' => 'John'];
60
61 $this->assertFalse($this->validator->validate($data, $document));
62
63 $errors = $this->validator->validateWithErrors($data, $document);
64 $this->assertArrayHasKey('email', $errors);
65 $this->assertStringContainsString('Required', $errors['email'][0]);
66 }
67
68 public function test_it_validates_type_mismatch(): void
69 {
70 $document = $this->createDocument([
71 'type' => 'record',
72 'record' => [
73 'type' => 'object',
74 'required' => ['age'],
75 'properties' => [
76 'age' => ['type' => 'integer'],
77 ],
78 ],
79 ]);
80
81 $data = ['age' => 'not a number'];
82
83 $this->assertFalse($this->validator->validate($data, $document));
84
85 $errors = $this->validator->validateWithErrors($data, $document);
86 $this->assertArrayHasKey('age', $errors);
87 }
88
89 public function test_it_validates_string_max_length(): void
90 {
91 $document = $this->createDocument([
92 'type' => 'record',
93 'record' => [
94 'type' => 'object',
95 'required' => ['text'],
96 'properties' => [
97 'text' => [
98 'type' => 'string',
99 'maxLength' => 10,
100 ],
101 ],
102 ],
103 ]);
104
105 $data = ['text' => 'This is way too long'];
106
107 $this->assertFalse($this->validator->validate($data, $document));
108
109 $errors = $this->validator->validateWithErrors($data, $document);
110 $this->assertArrayHasKey('text', $errors);
111 $this->assertStringContainsString('maximum length', $errors['text'][0]);
112 }
113
114 public function test_it_validates_string_min_length(): void
115 {
116 $document = $this->createDocument([
117 'type' => 'record',
118 'record' => [
119 'type' => 'object',
120 'required' => ['text'],
121 'properties' => [
122 'text' => [
123 'type' => 'string',
124 'minLength' => 5,
125 ],
126 ],
127 ],
128 ]);
129
130 $data = ['text' => 'Hi'];
131
132 $this->assertFalse($this->validator->validate($data, $document));
133
134 $errors = $this->validator->validateWithErrors($data, $document);
135 $this->assertArrayHasKey('text', $errors);
136 $this->assertStringContainsString('minimum length', $errors['text'][0]);
137 }
138
139 public function test_it_validates_grapheme_constraints(): void
140 {
141 $document = $this->createDocument([
142 'type' => 'record',
143 'record' => [
144 'type' => 'object',
145 'required' => ['text'],
146 'properties' => [
147 'text' => [
148 'type' => 'string',
149 'maxGraphemes' => 5,
150 ],
151 ],
152 ],
153 ]);
154
155 $data = ['text' => '😀😁😂😃😄😅']; // 6 graphemes
156
157 $this->assertFalse($this->validator->validate($data, $document));
158 }
159
160 public function test_it_validates_number_maximum(): void
161 {
162 $document = $this->createDocument([
163 'type' => 'record',
164 'record' => [
165 'type' => 'object',
166 'required' => ['count'],
167 'properties' => [
168 'count' => [
169 'type' => 'integer',
170 'maximum' => 100,
171 ],
172 ],
173 ],
174 ]);
175
176 $data = ['count' => 150];
177
178 $this->assertFalse($this->validator->validate($data, $document));
179
180 $errors = $this->validator->validateWithErrors($data, $document);
181 $this->assertArrayHasKey('count', $errors);
182 $this->assertStringContainsString('maximum', $errors['count'][0]);
183 }
184
185 public function test_it_validates_number_minimum(): void
186 {
187 $document = $this->createDocument([
188 'type' => 'record',
189 'record' => [
190 'type' => 'object',
191 'required' => ['count'],
192 'properties' => [
193 'count' => [
194 'type' => 'integer',
195 'minimum' => 10,
196 ],
197 ],
198 ],
199 ]);
200
201 $data = ['count' => 5];
202
203 $this->assertFalse($this->validator->validate($data, $document));
204
205 $errors = $this->validator->validateWithErrors($data, $document);
206 $this->assertArrayHasKey('count', $errors);
207 $this->assertStringContainsString('minimum', $errors['count'][0]);
208 }
209
210 public function test_it_validates_array_max_items(): void
211 {
212 $document = $this->createDocument([
213 'type' => 'record',
214 'record' => [
215 'type' => 'object',
216 'required' => ['items'],
217 'properties' => [
218 'items' => [
219 'type' => 'array',
220 'maxItems' => 3,
221 'items' => ['type' => 'string'],
222 ],
223 ],
224 ],
225 ]);
226
227 $data = ['items' => ['a', 'b', 'c', 'd']];
228
229 $this->assertFalse($this->validator->validate($data, $document));
230
231 $errors = $this->validator->validateWithErrors($data, $document);
232 $this->assertArrayHasKey('items', $errors);
233 $this->assertStringContainsString('maximum items', $errors['items'][0]);
234 }
235
236 public function test_it_validates_array_min_items(): void
237 {
238 $document = $this->createDocument([
239 'type' => 'record',
240 'record' => [
241 'type' => 'object',
242 'required' => ['items'],
243 'properties' => [
244 'items' => [
245 'type' => 'array',
246 'minItems' => 2,
247 'items' => ['type' => 'string'],
248 ],
249 ],
250 ],
251 ]);
252
253 $data = ['items' => ['a']];
254
255 $this->assertFalse($this->validator->validate($data, $document));
256
257 $errors = $this->validator->validateWithErrors($data, $document);
258 $this->assertArrayHasKey('items', $errors);
259 $this->assertStringContainsString('minimum items', $errors['items'][0]);
260 }
261
262 public function test_it_validates_enum_constraint(): void
263 {
264 $document = $this->createDocument([
265 'type' => 'record',
266 'record' => [
267 'type' => 'object',
268 'required' => ['status'],
269 'properties' => [
270 'status' => [
271 'type' => 'string',
272 'enum' => ['active', 'inactive', 'pending'],
273 ],
274 ],
275 ],
276 ]);
277
278 $data = ['status' => 'unknown'];
279
280 $this->assertFalse($this->validator->validate($data, $document));
281
282 $errors = $this->validator->validateWithErrors($data, $document);
283 $this->assertArrayHasKey('status', $errors);
284 $this->assertStringContainsString('one of:', $errors['status'][0]);
285 }
286
287 public function test_it_validates_const_constraint(): void
288 {
289 $document = $this->createDocument([
290 'type' => 'record',
291 'record' => [
292 'type' => 'object',
293 'required' => ['type'],
294 'properties' => [
295 'type' => [
296 'type' => 'string',
297 'const' => 'post',
298 ],
299 ],
300 ],
301 ]);
302
303 $data = ['type' => 'comment'];
304
305 $this->assertFalse($this->validator->validate($data, $document));
306
307 $errors = $this->validator->validateWithErrors($data, $document);
308 $this->assertArrayHasKey('type', $errors);
309 }
310
311 public function test_it_validates_nested_objects(): void
312 {
313 $document = $this->createDocument([
314 'type' => 'record',
315 'record' => [
316 'type' => 'object',
317 'required' => ['author'],
318 'properties' => [
319 'author' => [
320 'type' => 'object',
321 'required' => ['name'],
322 'properties' => [
323 'name' => ['type' => 'string'],
324 'email' => ['type' => 'string'],
325 ],
326 ],
327 ],
328 ],
329 ]);
330
331 $data = ['author' => ['email' => 'john@example.com']];
332
333 $this->assertFalse($this->validator->validate($data, $document));
334
335 $errors = $this->validator->validateWithErrors($data, $document);
336 $this->assertArrayHasKey('author.name', $errors);
337 }
338
339 public function test_it_validates_array_items(): void
340 {
341 $document = $this->createDocument([
342 'type' => 'record',
343 'record' => [
344 'type' => 'object',
345 'required' => ['tags'],
346 'properties' => [
347 'tags' => [
348 'type' => 'array',
349 'items' => [
350 'type' => 'string',
351 'maxLength' => 10,
352 ],
353 ],
354 ],
355 ],
356 ]);
357
358 $data = ['tags' => ['short', 'this is way too long']];
359
360 $this->assertFalse($this->validator->validate($data, $document));
361
362 $errors = $this->validator->validateWithErrors($data, $document);
363 $this->assertArrayHasKey('tags[1]', $errors);
364 }
365
366 public function test_strict_mode_rejects_unknown_fields(): void
367 {
368 $document = $this->createDocument([
369 'type' => 'record',
370 'record' => [
371 'type' => 'object',
372 'required' => ['name'],
373 'properties' => [
374 'name' => ['type' => 'string'],
375 ],
376 ],
377 ]);
378
379 $data = ['name' => 'John', 'unknown' => 'value'];
380
381 $this->validator->setMode(Validator::MODE_STRICT);
382 $this->assertFalse($this->validator->validate($data, $document));
383
384 $errors = $this->validator->validateWithErrors($data, $document);
385 $this->assertArrayHasKey('unknown', $errors);
386 }
387
388 public function test_optimistic_mode_allows_unknown_fields(): void
389 {
390 $document = $this->createDocument([
391 'type' => 'record',
392 'record' => [
393 'type' => 'object',
394 'required' => ['name'],
395 'properties' => [
396 'name' => ['type' => 'string'],
397 ],
398 ],
399 ]);
400
401 $data = ['name' => 'John', 'unknown' => 'value'];
402
403 $this->validator->setMode(Validator::MODE_OPTIMISTIC);
404 $this->assertTrue($this->validator->validate($data, $document));
405 }
406
407 public function test_lenient_mode_skips_required_validation(): void
408 {
409 $document = $this->createDocument([
410 'type' => 'record',
411 'record' => [
412 'type' => 'object',
413 'required' => ['name', 'email'],
414 'properties' => [
415 'name' => ['type' => 'string'],
416 'email' => ['type' => 'string'],
417 ],
418 ],
419 ]);
420
421 $data = ['name' => 'John'];
422
423 $this->validator->setMode(Validator::MODE_LENIENT);
424 $this->assertTrue($this->validator->validate($data, $document));
425 }
426
427 public function test_lenient_mode_skips_constraint_validation(): void
428 {
429 $document = $this->createDocument([
430 'type' => 'record',
431 'record' => [
432 'type' => 'object',
433 'required' => ['text'],
434 'properties' => [
435 'text' => [
436 'type' => 'string',
437 'maxLength' => 5,
438 ],
439 ],
440 ],
441 ]);
442
443 $data = ['text' => 'This is way too long'];
444
445 $this->validator->setMode(Validator::MODE_LENIENT);
446 $this->assertTrue($this->validator->validate($data, $document));
447 }
448
449 public function test_it_validates_specific_field(): void
450 {
451 $document = $this->createDocument([
452 'type' => 'record',
453 'record' => [
454 'type' => 'object',
455 'required' => ['name'],
456 'properties' => [
457 'name' => [
458 'type' => 'string',
459 'maxLength' => 50,
460 ],
461 'age' => ['type' => 'integer'],
462 ],
463 ],
464 ]);
465
466 $this->assertTrue($this->validator->validateField('John', 'name', $document));
467 $this->assertFalse($this->validator->validateField('not a number', 'age', $document));
468 }
469
470 public function test_it_validates_field_constraints(): void
471 {
472 $document = $this->createDocument([
473 'type' => 'record',
474 'record' => [
475 'type' => 'object',
476 'required' => ['name'],
477 'properties' => [
478 'name' => [
479 'type' => 'string',
480 'maxLength' => 5,
481 ],
482 ],
483 ],
484 ]);
485
486 $this->assertFalse($this->validator->validateField('John Doe', 'name', $document));
487 }
488
489 public function test_it_rejects_invalid_validation_mode(): void
490 {
491 $this->expectException(\InvalidArgumentException::class);
492
493 $this->validator->setMode('invalid');
494 }
495
496 public function test_it_returns_current_mode(): void
497 {
498 $this->assertEquals(Validator::MODE_STRICT, $this->validator->getMode());
499
500 $this->validator->setMode(Validator::MODE_LENIENT);
501 $this->assertEquals(Validator::MODE_LENIENT, $this->validator->getMode());
502 }
503
504 public function test_it_returns_empty_errors_for_valid_data(): void
505 {
506 $document = $this->createDocument([
507 'type' => 'record',
508 'record' => [
509 'type' => 'object',
510 'required' => ['name'],
511 'properties' => [
512 'name' => ['type' => 'string'],
513 ],
514 ],
515 ]);
516
517 $data = ['name' => 'John'];
518
519 $errors = $this->validator->validateWithErrors($data, $document);
520 $this->assertEmpty($errors);
521 }
522
523 public function test_it_validates_object_type_definition(): void
524 {
525 $document = $this->createDocument([
526 'type' => 'object',
527 'required' => ['name'],
528 'properties' => [
529 'name' => ['type' => 'string'],
530 ],
531 ]);
532
533 $data = ['name' => 'John'];
534
535 $this->assertTrue($this->validator->validate($data, $document));
536 }
537
538 public function test_it_validates_multiple_errors(): void
539 {
540 $document = $this->createDocument([
541 'type' => 'record',
542 'record' => [
543 'type' => 'object',
544 'required' => ['name', 'age', 'email'],
545 'properties' => [
546 'name' => ['type' => 'string'],
547 'age' => ['type' => 'integer'],
548 'email' => ['type' => 'string'],
549 ],
550 ],
551 ]);
552
553 $data = ['name' => 'John'];
554
555 $errors = $this->validator->validateWithErrors($data, $document);
556 $this->assertCount(2, $errors); // Missing age and email
557 $this->assertArrayHasKey('age', $errors);
558 $this->assertArrayHasKey('email', $errors);
559 }
560
561 /**
562 * Helper to create a test document.
563 *
564 * @param array<string, mixed> $mainDef
565 */
566 protected function createDocument(array $mainDef): LexiconDocument
567 {
568 return new LexiconDocument(
569 lexicon: 1,
570 id: Nsid::parse('com.example.test'),
571 defs: ['main' => $mainDef],
572 description: null,
573 source: null,
574 raw: []
575 );
576 }
577}