Parse and validate AT Protocol Lexicons with DTO generation for Laravel
at dev 18 kB view raw
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}