Parse and validate AT Protocol Lexicons with DTO generation for Laravel

Implement core validator service

+2 -2
src/Generator/ClassGenerator.php
··· 42 42 ?MethodGenerator $methodGenerator = null, 43 43 ?DocBlockGenerator $docBlockGenerator = null 44 44 ) { 45 - $this->naming = $naming ?? new NamingConverter; 45 + $this->naming = $naming ?? new NamingConverter(); 46 46 $this->typeMapper = $typeMapper ?? new TypeMapper($this->naming); 47 - $this->renderer = $renderer ?? new StubRenderer; 47 + $this->renderer = $renderer ?? new StubRenderer(); 48 48 $this->methodGenerator = $methodGenerator ?? new MethodGenerator($this->naming, $this->typeMapper, $this->renderer); 49 49 $this->docBlockGenerator = $docBlockGenerator ?? new DocBlockGenerator($this->typeMapper); 50 50 }
+2 -2
src/Generator/ConstructorGenerator.php
··· 21 21 ?PropertyGenerator $propertyGenerator = null, 22 22 ?StubRenderer $renderer = null 23 23 ) { 24 - $this->propertyGenerator = $propertyGenerator ?? new PropertyGenerator; 25 - $this->renderer = $renderer ?? new StubRenderer; 24 + $this->propertyGenerator = $propertyGenerator ?? new PropertyGenerator(); 25 + $this->renderer = $renderer ?? new StubRenderer(); 26 26 } 27 27 28 28 /**
+1 -1
src/Generator/DocBlockGenerator.php
··· 16 16 */ 17 17 public function __construct(?TypeMapper $typeMapper = null) 18 18 { 19 - $this->typeMapper = $typeMapper ?? new TypeMapper; 19 + $this->typeMapper = $typeMapper ?? new TypeMapper(); 20 20 } 21 21 22 22 /**
+2 -2
src/Generator/MethodGenerator.php
··· 35 35 ?StubRenderer $renderer = null, 36 36 ?ModelMapper $modelMapper = null 37 37 ) { 38 - $this->naming = $naming ?? new NamingConverter; 38 + $this->naming = $naming ?? new NamingConverter(); 39 39 $this->typeMapper = $typeMapper ?? new TypeMapper($this->naming); 40 - $this->renderer = $renderer ?? new StubRenderer; 40 + $this->renderer = $renderer ?? new StubRenderer(); 41 41 $this->modelMapper = $modelMapper ?? new ModelMapper($this->naming, $this->typeMapper); 42 42 } 43 43
+1 -1
src/Generator/ModelMapper.php
··· 19 19 */ 20 20 public function __construct(?NamingConverter $naming = null, ?TypeMapper $typeMapper = null) 21 21 { 22 - $this->naming = $naming ?? new NamingConverter; 22 + $this->naming = $naming ?? new NamingConverter(); 23 23 $this->typeMapper = $typeMapper ?? new TypeMapper($this->naming); 24 24 } 25 25
+2 -2
src/Generator/PropertyGenerator.php
··· 19 19 */ 20 20 public function __construct(?TypeMapper $typeMapper = null, ?StubRenderer $renderer = null) 21 21 { 22 - $this->typeMapper = $typeMapper ?? new TypeMapper; 23 - $this->renderer = $renderer ?? new StubRenderer; 22 + $this->typeMapper = $typeMapper ?? new TypeMapper(); 23 + $this->renderer = $renderer ?? new StubRenderer(); 24 24 } 25 25 26 26 /**
+1 -1
src/Generator/TypeMapper.php
··· 14 14 */ 15 15 public function __construct(?NamingConverter $naming = null) 16 16 { 17 - $this->naming = $naming ?? new NamingConverter; 17 + $this->naming = $naming ?? new NamingConverter(); 18 18 } 19 19 20 20 /**
+446
src/Validation/Validator.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Validation; 4 + 5 + use SocialDept\Schema\Contracts\LexiconValidator as LexiconValidatorContract; 6 + use SocialDept\Schema\Data\LexiconDocument; 7 + use SocialDept\Schema\Exceptions\RecordValidationException; 8 + use SocialDept\Schema\Exceptions\SchemaValidationException; 9 + use SocialDept\Schema\Parser\SchemaLoader; 10 + use SocialDept\Schema\Parser\TypeParser; 11 + 12 + class Validator implements LexiconValidatorContract 13 + { 14 + /** 15 + * Validation mode constants. 16 + */ 17 + public const MODE_STRICT = 'strict'; 18 + 19 + public const MODE_OPTIMISTIC = 'optimistic'; 20 + 21 + public const MODE_LENIENT = 'lenient'; 22 + 23 + /** 24 + * Schema loader for loading lexicon documents. 25 + */ 26 + protected SchemaLoader $schemaLoader; 27 + 28 + /** 29 + * Type parser for parsing and resolving types. 30 + */ 31 + protected TypeParser $typeParser; 32 + 33 + /** 34 + * Validation mode. 35 + */ 36 + protected string $mode = self::MODE_STRICT; 37 + 38 + /** 39 + * Collected validation errors. 40 + * 41 + * @var array<string, array<string>> 42 + */ 43 + protected array $errors = []; 44 + 45 + /** 46 + * Create a new Validator. 47 + */ 48 + public function __construct( 49 + SchemaLoader $schemaLoader, 50 + ?TypeParser $typeParser = null 51 + ) { 52 + $this->schemaLoader = $schemaLoader; 53 + $this->typeParser = $typeParser ?? new TypeParser(schemaLoader: $schemaLoader); 54 + } 55 + 56 + /** 57 + * Validate data against Lexicon schema. 58 + */ 59 + public function validate(array $data, LexiconDocument $schema): bool 60 + { 61 + $this->errors = []; 62 + 63 + try { 64 + $this->validateData($data, $schema); 65 + 66 + return empty($this->errors); 67 + } catch (RecordValidationException|SchemaValidationException) { 68 + return false; 69 + } 70 + } 71 + 72 + /** 73 + * Validate and return errors. 74 + * 75 + * @return array<string, array<string>> 76 + */ 77 + public function validateWithErrors(array $data, LexiconDocument $schema): array 78 + { 79 + $this->errors = []; 80 + 81 + try { 82 + $this->validateData($data, $schema); 83 + 84 + return $this->errors; 85 + } catch (RecordValidationException $e) { 86 + return ['_root' => [$e->getMessage()]]; 87 + } catch (SchemaValidationException $e) { 88 + return ['_schema' => [$e->getMessage()]]; 89 + } 90 + } 91 + 92 + /** 93 + * Validate a specific field. 94 + */ 95 + public function validateField(mixed $value, string $field, LexiconDocument $schema): bool 96 + { 97 + $this->errors = []; 98 + 99 + try { 100 + $mainDef = $schema->getMainDefinition(); 101 + 102 + if ($mainDef === null) { 103 + return false; 104 + } 105 + 106 + $properties = $this->extractProperties($mainDef); 107 + 108 + if (! isset($properties[$field])) { 109 + if ($this->mode === self::MODE_STRICT) { 110 + $this->addError($field, "Field '{$field}' is not defined in schema"); 111 + 112 + return false; 113 + } 114 + 115 + return true; 116 + } 117 + 118 + $this->validateProperty($value, $field, $properties[$field], $schema); 119 + 120 + return empty($this->errors); 121 + } catch (RecordValidationException) { 122 + return false; 123 + } 124 + } 125 + 126 + /** 127 + * Set validation mode (strict, optimistic, lenient). 128 + */ 129 + public function setMode(string $mode): void 130 + { 131 + if (! in_array($mode, [self::MODE_STRICT, self::MODE_OPTIMISTIC, self::MODE_LENIENT])) { 132 + throw new \InvalidArgumentException("Invalid validation mode: {$mode}"); 133 + } 134 + 135 + $this->mode = $mode; 136 + } 137 + 138 + /** 139 + * Get current validation mode. 140 + */ 141 + public function getMode(): string 142 + { 143 + return $this->mode; 144 + } 145 + 146 + /** 147 + * Validate data against schema. 148 + */ 149 + protected function validateData(array $data, LexiconDocument $schema): void 150 + { 151 + $mainDef = $schema->getMainDefinition(); 152 + 153 + if ($mainDef === null) { 154 + throw SchemaValidationException::invalidStructure( 155 + $schema->getNsid(), 156 + ['Missing main definition'] 157 + ); 158 + } 159 + 160 + $type = $mainDef['type'] ?? null; 161 + 162 + // Only validate if it's a record or object type 163 + if ($type !== 'record' && $type !== 'object') { 164 + throw SchemaValidationException::invalidStructure( 165 + $schema->getNsid(), 166 + ['Schema must be a record or object type, got: ' . ($type ?? 'unknown')] 167 + ); 168 + } 169 + 170 + $properties = $this->extractProperties($mainDef); 171 + $required = $this->extractRequired($mainDef); 172 + 173 + // Validate required fields 174 + $this->validateRequired($data, $required); 175 + 176 + // Validate defined properties 177 + foreach ($properties as $name => $propDef) { 178 + if (array_key_exists($name, $data)) { 179 + $this->validateProperty($data[$name], $name, $propDef, $schema); 180 + } 181 + } 182 + 183 + // Check for unknown fields 184 + if ($this->mode === self::MODE_STRICT) { 185 + $this->validateNoUnknownFields($data, array_keys($properties)); 186 + } 187 + } 188 + 189 + /** 190 + * Extract properties from definition. 191 + * 192 + * @param array<string, mixed> $definition 193 + * @return array<string, array<string, mixed>> 194 + */ 195 + protected function extractProperties(array $definition): array 196 + { 197 + // Handle record type 198 + if (isset($definition['record']) && is_array($definition['record'])) { 199 + return $definition['record']['properties'] ?? []; 200 + } 201 + 202 + // Handle object type 203 + if ($definition['type'] === 'object' || isset($definition['properties'])) { 204 + return $definition['properties'] ?? []; 205 + } 206 + 207 + return []; 208 + } 209 + 210 + /** 211 + * Extract required fields from definition. 212 + * 213 + * @param array<string, mixed> $definition 214 + * @return array<string> 215 + */ 216 + protected function extractRequired(array $definition): array 217 + { 218 + // Handle record type 219 + if (isset($definition['record']) && is_array($definition['record'])) { 220 + return $definition['record']['required'] ?? []; 221 + } 222 + 223 + // Handle object type 224 + return $definition['required'] ?? []; 225 + } 226 + 227 + /** 228 + * Validate required fields are present. 229 + * 230 + * @param array<string> $required 231 + */ 232 + protected function validateRequired(array $data, array $required): void 233 + { 234 + if ($this->mode === self::MODE_LENIENT) { 235 + return; 236 + } 237 + 238 + foreach ($required as $field) { 239 + if (! array_key_exists($field, $data)) { 240 + $this->addError($field, "Required field '{$field}' is missing"); 241 + } 242 + } 243 + } 244 + 245 + /** 246 + * Validate a single property. 247 + * 248 + * @param array<string, mixed> $propDef 249 + */ 250 + protected function validateProperty(mixed $value, string $name, array $propDef, LexiconDocument $schema): void 251 + { 252 + try { 253 + $type = $propDef['type'] ?? 'unknown'; 254 + 255 + // Basic type validation 256 + $this->validateType($value, $type, $name); 257 + 258 + // Constraint validation (skip in lenient mode) 259 + if ($this->mode !== self::MODE_LENIENT) { 260 + $this->validateConstraints($value, $propDef, $name); 261 + } 262 + 263 + // Nested object validation 264 + if ($type === 'object' && is_array($value)) { 265 + $this->validateNestedObject($value, $propDef, $name, $schema); 266 + } 267 + 268 + // Array validation 269 + if ($type === 'array' && is_array($value)) { 270 + $this->validateArray($value, $propDef, $name, $schema); 271 + } 272 + } catch (RecordValidationException $e) { 273 + $this->addError($name, $e->getMessage()); 274 + } 275 + } 276 + 277 + /** 278 + * Validate value type. 279 + */ 280 + protected function validateType(mixed $value, string $expectedType, string $fieldName): void 281 + { 282 + $actualType = gettype($value); 283 + 284 + $valid = match ($expectedType) { 285 + 'string' => is_string($value), 286 + 'integer' => is_int($value), 287 + 'boolean' => is_bool($value), 288 + 'number' => is_numeric($value), 289 + 'object' => is_array($value), 290 + 'array' => is_array($value), 291 + 'null' => is_null($value), 292 + default => true, // Unknown types pass in optimistic/lenient modes 293 + }; 294 + 295 + if (! $valid) { 296 + $this->addError($fieldName, "Expected type '{$expectedType}', got '{$actualType}'"); 297 + } 298 + } 299 + 300 + /** 301 + * Validate field constraints. 302 + * 303 + * @param array<string, mixed> $propDef 304 + */ 305 + protected function validateConstraints(mixed $value, array $propDef, string $fieldName): void 306 + { 307 + // String length constraints 308 + if (is_string($value)) { 309 + if (isset($propDef['maxLength']) && strlen($value) > $propDef['maxLength']) { 310 + $this->addError($fieldName, "String exceeds maximum length of {$propDef['maxLength']}"); 311 + } 312 + 313 + if (isset($propDef['minLength']) && strlen($value) < $propDef['minLength']) { 314 + $this->addError($fieldName, "String is shorter than minimum length of {$propDef['minLength']}"); 315 + } 316 + 317 + if (isset($propDef['maxGraphemes']) && grapheme_strlen($value) > $propDef['maxGraphemes']) { 318 + $this->addError($fieldName, "String exceeds maximum graphemes of {$propDef['maxGraphemes']}"); 319 + } 320 + 321 + if (isset($propDef['minGraphemes']) && grapheme_strlen($value) < $propDef['minGraphemes']) { 322 + $this->addError($fieldName, "String has fewer than minimum graphemes of {$propDef['minGraphemes']}"); 323 + } 324 + } 325 + 326 + // Number constraints 327 + if (is_numeric($value)) { 328 + if (isset($propDef['maximum']) && $value > $propDef['maximum']) { 329 + $this->addError($fieldName, "Value exceeds maximum of {$propDef['maximum']}"); 330 + } 331 + 332 + if (isset($propDef['minimum']) && $value < $propDef['minimum']) { 333 + $this->addError($fieldName, "Value is less than minimum of {$propDef['minimum']}"); 334 + } 335 + } 336 + 337 + // Array constraints 338 + if (is_array($value)) { 339 + $count = count($value); 340 + 341 + if (isset($propDef['maxItems']) && $count > $propDef['maxItems']) { 342 + $this->addError($fieldName, "Array exceeds maximum items of {$propDef['maxItems']}"); 343 + } 344 + 345 + if (isset($propDef['minItems']) && $count < $propDef['minItems']) { 346 + $this->addError($fieldName, "Array has fewer than minimum items of {$propDef['minItems']}"); 347 + } 348 + } 349 + 350 + // Enum constraint 351 + if (isset($propDef['enum']) && ! in_array($value, $propDef['enum'], true)) { 352 + $allowedValues = implode(', ', $propDef['enum']); 353 + $this->addError($fieldName, "Value must be one of: {$allowedValues}"); 354 + } 355 + 356 + // Const constraint 357 + if (isset($propDef['const']) && $value !== $propDef['const']) { 358 + $expectedValue = json_encode($propDef['const']); 359 + $this->addError($fieldName, "Value must be {$expectedValue}"); 360 + } 361 + } 362 + 363 + /** 364 + * Validate nested object. 365 + * 366 + * @param array<string, mixed> $propDef 367 + */ 368 + protected function validateNestedObject(array $value, array $propDef, string $fieldName, LexiconDocument $schema): void 369 + { 370 + $nestedProperties = $propDef['properties'] ?? []; 371 + $nestedRequired = $propDef['required'] ?? []; 372 + 373 + // Create temporary document for nested validation 374 + $nestedDoc = new LexiconDocument( 375 + lexicon: 1, 376 + id: $schema->id, 377 + defs: ['main' => [ 378 + 'type' => 'object', 379 + 'properties' => $nestedProperties, 380 + 'required' => $nestedRequired, 381 + ]], 382 + description: null, 383 + source: null, 384 + raw: [] 385 + ); 386 + 387 + $originalErrors = $this->errors; 388 + $this->errors = []; 389 + 390 + $this->validateData($value, $nestedDoc); 391 + 392 + // Prefix nested errors with field name 393 + foreach ($this->errors as $nestedField => $messages) { 394 + foreach ($messages as $message) { 395 + $this->addError("{$fieldName}.{$nestedField}", $message); 396 + } 397 + } 398 + 399 + $this->errors = array_merge($originalErrors, $this->errors); 400 + } 401 + 402 + /** 403 + * Validate array items. 404 + * 405 + * @param array<string, mixed> $propDef 406 + */ 407 + protected function validateArray(array $value, array $propDef, string $fieldName, LexiconDocument $schema): void 408 + { 409 + if (! isset($propDef['items'])) { 410 + return; 411 + } 412 + 413 + $itemDef = $propDef['items']; 414 + 415 + foreach ($value as $index => $item) { 416 + $itemFieldName = "{$fieldName}[{$index}]"; 417 + $this->validateProperty($item, $itemFieldName, $itemDef, $schema); 418 + } 419 + } 420 + 421 + /** 422 + * Validate no unknown fields are present. 423 + * 424 + * @param array<string> $allowedFields 425 + */ 426 + protected function validateNoUnknownFields(array $data, array $allowedFields): void 427 + { 428 + foreach (array_keys($data) as $field) { 429 + if (! in_array($field, $allowedFields)) { 430 + $this->addError($field, "Unknown field '{$field}' is not allowed"); 431 + } 432 + } 433 + } 434 + 435 + /** 436 + * Add a validation error. 437 + */ 438 + protected function addError(string $field, string $message): void 439 + { 440 + if (! isset($this->errors[$field])) { 441 + $this->errors[$field] = []; 442 + } 443 + 444 + $this->errors[$field][] = $message; 445 + } 446 + }
+1 -1
tests/Unit/Generator/ClassGeneratorTest.php
··· 16 16 { 17 17 parent::setUp(); 18 18 19 - $this->generator = new ClassGenerator; 19 + $this->generator = new ClassGenerator(); 20 20 } 21 21 22 22 public function test_it_generates_simple_record_class(): void
+1 -1
tests/Unit/Generator/ConstructorGeneratorTest.php
··· 13 13 { 14 14 parent::setUp(); 15 15 16 - $this->generator = new ConstructorGenerator; 16 + $this->generator = new ConstructorGenerator(); 17 17 } 18 18 19 19 public function test_it_generates_constructor_with_promoted_properties(): void
+1 -1
tests/Unit/Generator/DocBlockGeneratorTest.php
··· 15 15 { 16 16 parent::setUp(); 17 17 18 - $this->generator = new DocBlockGenerator; 18 + $this->generator = new DocBlockGenerator(); 19 19 } 20 20 21 21 public function test_it_generates_class_docblock_with_description(): void
+1 -1
tests/Unit/Generator/MethodGeneratorTest.php
··· 15 15 { 16 16 parent::setUp(); 17 17 18 - $this->generator = new MethodGenerator; 18 + $this->generator = new MethodGenerator(); 19 19 } 20 20 21 21 public function test_it_generates_get_lexicon_method(): void
+2 -1
tests/Unit/Generator/ModelMapperTest.php
··· 13 13 { 14 14 parent::setUp(); 15 15 16 - $this->mapper = new ModelMapper; 16 + $this->mapper = new ModelMapper(); 17 17 } 18 18 19 19 public function test_it_generates_to_model_body_for_simple_properties(): void ··· 281 281 foreach ($lines as $line) { 282 282 if (str_contains($line, 'last:')) { 283 283 $lastPropertyLine = $line; 284 + 284 285 break; 285 286 } 286 287 }
+1 -1
tests/Unit/Generator/PropertyGeneratorTest.php
··· 13 13 { 14 14 parent::setUp(); 15 15 16 - $this->generator = new PropertyGenerator; 16 + $this->generator = new PropertyGenerator(); 17 17 } 18 18 19 19 public function test_it_generates_required_string_property(): void
+577
tests/Unit/Validation/ValidatorTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Validation; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\LexiconDocument; 7 + use SocialDept\Schema\Parser\Nsid; 8 + use SocialDept\Schema\Parser\SchemaLoader; 9 + use SocialDept\Schema\Validation\Validator; 10 + 11 + class 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 + }