Parse and validate AT Protocol Lexicons with DTO generation for Laravel

Implement type-specific validators

+90
src/Validation/TypeValidators/ArrayValidator.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Validation\TypeValidators; 4 + 5 + use SocialDept\Schema\Exceptions\RecordValidationException; 6 + 7 + class ArrayValidator 8 + { 9 + /** 10 + * Validate an array value against constraints. 11 + * 12 + * @param array<string, mixed> $definition 13 + */ 14 + public function validate(mixed $value, array $definition, string $path): void 15 + { 16 + if (! is_array($value)) { 17 + throw RecordValidationException::invalidType($path, 'array', gettype($value)); 18 + } 19 + 20 + $count = count($value); 21 + 22 + // MaxItems constraint 23 + if (isset($definition['maxItems'])) { 24 + if ($count > $definition['maxItems']) { 25 + throw RecordValidationException::invalidValue( 26 + $path, 27 + "Array length ({$count}) exceeds maximum ({$definition['maxItems']})" 28 + ); 29 + } 30 + } 31 + 32 + // MinItems constraint 33 + if (isset($definition['minItems'])) { 34 + if ($count < $definition['minItems']) { 35 + throw RecordValidationException::invalidValue( 36 + $path, 37 + "Array length ({$count}) is below minimum ({$definition['minItems']})" 38 + ); 39 + } 40 + } 41 + 42 + // Validate items if item schema is provided 43 + if (isset($definition['items']) && is_array($definition['items'])) { 44 + $this->validateItems($value, $definition['items'], $path); 45 + } 46 + } 47 + 48 + /** 49 + * Validate array items. 50 + * 51 + * @param array<mixed> $items 52 + * @param array<string, mixed> $itemDefinition 53 + */ 54 + protected function validateItems(array $items, array $itemDefinition, string $path): void 55 + { 56 + $itemType = $itemDefinition['type'] ?? null; 57 + 58 + if ($itemType === null) { 59 + return; 60 + } 61 + 62 + foreach ($items as $index => $item) { 63 + $itemPath = "{$path}[{$index}]"; 64 + $this->validateItem($item, $itemDefinition, $itemPath); 65 + } 66 + } 67 + 68 + /** 69 + * Validate a single array item. 70 + * 71 + * @param array<string, mixed> $definition 72 + */ 73 + protected function validateItem(mixed $value, array $definition, string $path): void 74 + { 75 + $type = $definition['type'] ?? null; 76 + 77 + $validator = match ($type) { 78 + 'string' => new StringValidator(), 79 + 'integer' => new IntegerValidator(), 80 + 'boolean' => new BooleanValidator(), 81 + 'object' => new ObjectValidator(), 82 + 'array' => new ArrayValidator(), 83 + default => null, 84 + }; 85 + 86 + if ($validator !== null) { 87 + $validator->validate($value, $definition, $path); 88 + } 89 + } 90 + }
+87
src/Validation/TypeValidators/BlobValidator.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Validation\TypeValidators; 4 + 5 + use SocialDept\Schema\Exceptions\RecordValidationException; 6 + 7 + class BlobValidator 8 + { 9 + /** 10 + * Validate a blob value against constraints. 11 + * 12 + * @param array<string, mixed> $definition 13 + */ 14 + public function validate(mixed $value, array $definition, string $path): void 15 + { 16 + // Blob should be an object with specific structure 17 + if (! is_array($value)) { 18 + throw RecordValidationException::invalidType($path, 'blob', gettype($value)); 19 + } 20 + 21 + // Check for required blob structure 22 + if (! isset($value['$type']) || $value['$type'] !== 'blob') { 23 + throw RecordValidationException::invalidValue( 24 + $path, 25 + 'Blob must have $type field set to "blob"' 26 + ); 27 + } 28 + 29 + // Validate ref (CID) 30 + if (! isset($value['ref'])) { 31 + throw RecordValidationException::invalidValue($path, 'Blob must have ref field'); 32 + } 33 + 34 + // Validate mimeType 35 + if (! isset($value['mimeType']) || ! is_string($value['mimeType'])) { 36 + throw RecordValidationException::invalidValue($path, 'Blob must have valid mimeType'); 37 + } 38 + 39 + // Validate size 40 + if (! isset($value['size']) || ! is_int($value['size'])) { 41 + throw RecordValidationException::invalidValue($path, 'Blob must have valid size'); 42 + } 43 + 44 + // Validate MIME type constraint 45 + if (isset($definition['accept']) && is_array($definition['accept'])) { 46 + $this->validateMimeType($value['mimeType'], $definition['accept'], $path); 47 + } 48 + 49 + // Validate size constraints 50 + if (isset($definition['maxSize'])) { 51 + if ($value['size'] > $definition['maxSize']) { 52 + throw RecordValidationException::invalidValue( 53 + $path, 54 + "Blob size ({$value['size']}) exceeds maximum ({$definition['maxSize']})" 55 + ); 56 + } 57 + } 58 + } 59 + 60 + /** 61 + * Validate MIME type against accepted types. 62 + * 63 + * @param array<string> $acceptedTypes 64 + */ 65 + protected function validateMimeType(string $mimeType, array $acceptedTypes, string $path): void 66 + { 67 + foreach ($acceptedTypes as $acceptedType) { 68 + // Handle wildcards (e.g., image/*) 69 + if (str_contains($acceptedType, '*')) { 70 + // Quote everything except the asterisk, then replace * with .* 71 + $pattern = '/^'.str_replace('\\*', '.*', preg_quote($acceptedType, '/')).'$/'; 72 + if (preg_match($pattern, $mimeType)) { 73 + return; 74 + } 75 + } elseif ($mimeType === $acceptedType) { 76 + return; 77 + } 78 + } 79 + 80 + $allowed = implode(', ', $acceptedTypes); 81 + 82 + throw RecordValidationException::invalidValue( 83 + $path, 84 + "MIME type '{$mimeType}' not accepted. Allowed: {$allowed}" 85 + ); 86 + } 87 + }
+32
src/Validation/TypeValidators/BooleanValidator.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Validation\TypeValidators; 4 + 5 + use SocialDept\Schema\Exceptions\RecordValidationException; 6 + 7 + class BooleanValidator 8 + { 9 + /** 10 + * Validate a boolean value. 11 + * 12 + * @param array<string, mixed> $definition 13 + */ 14 + public function validate(mixed $value, array $definition, string $path): void 15 + { 16 + if (! is_bool($value)) { 17 + throw RecordValidationException::invalidType($path, 'boolean', gettype($value)); 18 + } 19 + 20 + // Const constraint 21 + if (isset($definition['const'])) { 22 + if ($value !== $definition['const']) { 23 + $expectedValue = $definition['const'] ? 'true' : 'false'; 24 + 25 + throw RecordValidationException::invalidValue( 26 + $path, 27 + "Value must be {$expectedValue}" 28 + ); 29 + } 30 + } 31 + } 32 + }
+62
src/Validation/TypeValidators/IntegerValidator.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Validation\TypeValidators; 4 + 5 + use SocialDept\Schema\Exceptions\RecordValidationException; 6 + 7 + class IntegerValidator 8 + { 9 + /** 10 + * Validate an integer value against constraints. 11 + * 12 + * @param array<string, mixed> $definition 13 + */ 14 + public function validate(mixed $value, array $definition, string $path): void 15 + { 16 + if (! is_int($value)) { 17 + throw RecordValidationException::invalidType($path, 'integer', gettype($value)); 18 + } 19 + 20 + // Maximum constraint 21 + if (isset($definition['maximum'])) { 22 + if ($value > $definition['maximum']) { 23 + throw RecordValidationException::invalidValue( 24 + $path, 25 + "Value ({$value}) exceeds maximum ({$definition['maximum']})" 26 + ); 27 + } 28 + } 29 + 30 + // Minimum constraint 31 + if (isset($definition['minimum'])) { 32 + if ($value < $definition['minimum']) { 33 + throw RecordValidationException::invalidValue( 34 + $path, 35 + "Value ({$value}) is below minimum ({$definition['minimum']})" 36 + ); 37 + } 38 + } 39 + 40 + // Enum constraint 41 + if (isset($definition['enum']) && is_array($definition['enum'])) { 42 + if (! in_array($value, $definition['enum'], true)) { 43 + $allowed = implode(', ', $definition['enum']); 44 + 45 + throw RecordValidationException::invalidValue( 46 + $path, 47 + "Value must be one of: {$allowed}" 48 + ); 49 + } 50 + } 51 + 52 + // Const constraint 53 + if (isset($definition['const'])) { 54 + if ($value !== $definition['const']) { 55 + throw RecordValidationException::invalidValue( 56 + $path, 57 + "Value must be {$definition['const']}" 58 + ); 59 + } 60 + } 61 + } 62 + }
+63
src/Validation/TypeValidators/ObjectValidator.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Validation\TypeValidators; 4 + 5 + use SocialDept\Schema\Exceptions\RecordValidationException; 6 + 7 + class ObjectValidator 8 + { 9 + /** 10 + * Validate an object value against constraints. 11 + * 12 + * @param array<string, mixed> $definition 13 + */ 14 + public function validate(mixed $value, array $definition, string $path): void 15 + { 16 + if (! is_array($value)) { 17 + throw RecordValidationException::invalidType($path, 'object', gettype($value)); 18 + } 19 + 20 + $properties = $definition['properties'] ?? []; 21 + $required = $definition['required'] ?? []; 22 + 23 + // Validate required fields 24 + foreach ($required as $field) { 25 + if (! array_key_exists($field, $value)) { 26 + throw RecordValidationException::invalidValue( 27 + "{$path}.{$field}", 28 + 'Required field is missing' 29 + ); 30 + } 31 + } 32 + 33 + // Validate properties 34 + foreach ($properties as $name => $propDef) { 35 + if (array_key_exists($name, $value)) { 36 + $this->validateProperty($value[$name], $propDef, "{$path}.{$name}"); 37 + } 38 + } 39 + } 40 + 41 + /** 42 + * Validate a single property. 43 + * 44 + * @param array<string, mixed> $definition 45 + */ 46 + protected function validateProperty(mixed $value, array $definition, string $path): void 47 + { 48 + $type = $definition['type'] ?? null; 49 + 50 + $validator = match ($type) { 51 + 'string' => new StringValidator(), 52 + 'integer' => new IntegerValidator(), 53 + 'boolean' => new BooleanValidator(), 54 + 'object' => new ObjectValidator(), 55 + 'array' => new ArrayValidator(), 56 + default => null, 57 + }; 58 + 59 + if ($validator !== null) { 60 + $validator->validate($value, $definition, $path); 61 + } 62 + } 63 + }
+235
src/Validation/TypeValidators/StringValidator.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Validation\TypeValidators; 4 + 5 + use SocialDept\Schema\Exceptions\RecordValidationException; 6 + 7 + class StringValidator 8 + { 9 + /** 10 + * Validate a string value against constraints. 11 + * 12 + * @param array<string, mixed> $definition 13 + */ 14 + public function validate(mixed $value, array $definition, string $path): void 15 + { 16 + if (! is_string($value)) { 17 + throw RecordValidationException::invalidType($path, 'string', gettype($value)); 18 + } 19 + 20 + // Length constraints 21 + if (isset($definition['maxLength'])) { 22 + $length = strlen($value); 23 + if ($length > $definition['maxLength']) { 24 + throw RecordValidationException::invalidValue( 25 + $path, 26 + "String length ({$length}) exceeds maximum ({$definition['maxLength']})" 27 + ); 28 + } 29 + } 30 + 31 + if (isset($definition['minLength'])) { 32 + $length = strlen($value); 33 + if ($length < $definition['minLength']) { 34 + throw RecordValidationException::invalidValue( 35 + $path, 36 + "String length ({$length}) is below minimum ({$definition['minLength']})" 37 + ); 38 + } 39 + } 40 + 41 + // Grapheme constraints 42 + if (isset($definition['maxGraphemes'])) { 43 + $graphemes = grapheme_strlen($value); 44 + if ($graphemes > $definition['maxGraphemes']) { 45 + throw RecordValidationException::invalidValue( 46 + $path, 47 + "String graphemes ({$graphemes}) exceeds maximum ({$definition['maxGraphemes']})" 48 + ); 49 + } 50 + } 51 + 52 + if (isset($definition['minGraphemes'])) { 53 + $graphemes = grapheme_strlen($value); 54 + if ($graphemes < $definition['minGraphemes']) { 55 + throw RecordValidationException::invalidValue( 56 + $path, 57 + "String graphemes ({$graphemes}) is below minimum ({$definition['minGraphemes']})" 58 + ); 59 + } 60 + } 61 + 62 + // Enum constraint 63 + if (isset($definition['enum']) && is_array($definition['enum'])) { 64 + if (! in_array($value, $definition['enum'], true)) { 65 + $allowed = implode(', ', $definition['enum']); 66 + 67 + throw RecordValidationException::invalidValue( 68 + $path, 69 + "Value must be one of: {$allowed}" 70 + ); 71 + } 72 + } 73 + 74 + // Const constraint 75 + if (isset($definition['const'])) { 76 + if ($value !== $definition['const']) { 77 + throw RecordValidationException::invalidValue( 78 + $path, 79 + "Value must be '{$definition['const']}'" 80 + ); 81 + } 82 + } 83 + 84 + // Format validation 85 + if (isset($definition['format'])) { 86 + $this->validateFormat($value, $definition['format'], $path); 87 + } 88 + } 89 + 90 + /** 91 + * Validate string format. 92 + */ 93 + protected function validateFormat(string $value, string $format, string $path): void 94 + { 95 + $valid = match ($format) { 96 + 'datetime' => $this->validateDatetime($value), 97 + 'uri' => $this->validateUri($value), 98 + 'at-uri' => $this->validateAtUri($value), 99 + 'did' => $this->validateDid($value), 100 + 'handle' => $this->validateHandle($value), 101 + 'at-identifier' => $this->validateAtIdentifier($value), 102 + 'nsid' => $this->validateNsid($value), 103 + 'cid' => $this->validateCid($value), 104 + 'language' => $this->validateLanguage($value), 105 + default => true, // Unknown formats pass 106 + }; 107 + 108 + if (! $valid) { 109 + throw RecordValidationException::invalidValue($path, "Invalid format: {$format}"); 110 + } 111 + } 112 + 113 + /** 114 + * Validate datetime format. 115 + */ 116 + protected function validateDatetime(string $value): bool 117 + { 118 + $datetime = \DateTime::createFromFormat(\DateTime::ATOM, $value); 119 + if ($datetime !== false) { 120 + return true; 121 + } 122 + 123 + $datetime = \DateTime::createFromFormat('Y-m-d\TH:i:s.u\Z', $value); 124 + if ($datetime !== false) { 125 + return true; 126 + } 127 + 128 + $datetime = \DateTime::createFromFormat('Y-m-d\TH:i:s\Z', $value); 129 + 130 + return $datetime !== false; 131 + } 132 + 133 + /** 134 + * Validate URI format. 135 + */ 136 + protected function validateUri(string $value): bool 137 + { 138 + return filter_var($value, FILTER_VALIDATE_URL) !== false; 139 + } 140 + 141 + /** 142 + * Validate AT URI format. 143 + */ 144 + protected function validateAtUri(string $value): bool 145 + { 146 + return str_starts_with($value, 'at://') && strlen($value) > 5; 147 + } 148 + 149 + /** 150 + * Validate DID format. 151 + */ 152 + protected function validateDid(string $value): bool 153 + { 154 + return (bool) preg_match('/^did:[a-z]+:[a-zA-Z0-9._:%-]+$/', $value); 155 + } 156 + 157 + /** 158 + * Validate handle format. 159 + */ 160 + protected function validateHandle(string $value): bool 161 + { 162 + if (strlen($value) < 3 || strlen($value) > 253) { 163 + return false; 164 + } 165 + 166 + return (bool) preg_match('/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/', $value); 167 + } 168 + 169 + /** 170 + * Validate AT identifier (DID or handle). 171 + */ 172 + protected function validateAtIdentifier(string $value): bool 173 + { 174 + return $this->validateDid($value) || $this->validateHandle($value); 175 + } 176 + 177 + /** 178 + * Validate NSID format. 179 + */ 180 + protected function validateNsid(string $value): bool 181 + { 182 + try { 183 + \SocialDept\Schema\Parser\Nsid::parse($value); 184 + 185 + return true; 186 + } catch (\Exception) { 187 + return false; 188 + } 189 + } 190 + 191 + /** 192 + * Validate CID format. 193 + */ 194 + protected function validateCid(string $value): bool 195 + { 196 + // CIDv0 or CIDv1 197 + if (str_starts_with($value, 'Qm') && strlen($value) === 46) { 198 + return (bool) preg_match('/^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/', $value); 199 + } 200 + 201 + if ((str_starts_with($value, 'b') || str_starts_with($value, 'bafy')) && strlen($value) > 10) { 202 + return (bool) preg_match('/^[a-z2-7]+$/', $value); 203 + } 204 + 205 + if (str_starts_with($value, 'z') && strlen($value) > 10) { 206 + return (bool) preg_match('/^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]+$/', $value); 207 + } 208 + 209 + return false; 210 + } 211 + 212 + /** 213 + * Validate language code (BCP 47). 214 + */ 215 + protected function validateLanguage(string $value): bool 216 + { 217 + $pattern = '/^ 218 + ([a-z]{2,3}|[a-z]{4}|[a-z]{5,8}) 219 + (-[A-Z][a-z]{3})? 220 + (-([A-Z]{2}|[0-9]{3}))? 221 + (-([a-z0-9]{5,8}|[0-9][a-z0-9]{3}))* 222 + (-[a-z]-[a-z0-9]{2,8})* 223 + (-x-[a-z0-9]{1,8})? 224 + $/xi'; 225 + 226 + if (! preg_match($pattern, $value)) { 227 + return false; 228 + } 229 + 230 + $primaryLanguage = strtolower(explode('-', $value)[0]); 231 + $length = strlen($primaryLanguage); 232 + 233 + return $length >= 2 && $length <= 8; 234 + } 235 + }
+84
src/Validation/TypeValidators/UnionValidator.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Validation\TypeValidators; 4 + 5 + use SocialDept\Schema\Exceptions\RecordValidationException; 6 + 7 + class UnionValidator 8 + { 9 + /** 10 + * Validate a union value against constraints. 11 + * 12 + * @param array<string, mixed> $definition 13 + */ 14 + public function validate(mixed $value, array $definition, string $path): void 15 + { 16 + $refs = $definition['refs'] ?? []; 17 + 18 + if (empty($refs)) { 19 + throw RecordValidationException::invalidValue($path, 'Union must have refs defined'); 20 + } 21 + 22 + // Check if union is discriminated (closed) 23 + $closed = $definition['closed'] ?? false; 24 + 25 + if ($closed) { 26 + $this->validateDiscriminatedUnion($value, $refs, $path); 27 + } else { 28 + $this->validateOpenUnion($value, $refs, $path); 29 + } 30 + } 31 + 32 + /** 33 + * Validate discriminated (closed) union. 34 + * 35 + * @param array<string> $refs 36 + */ 37 + protected function validateDiscriminatedUnion(mixed $value, array $refs, string $path): void 38 + { 39 + if (! is_array($value)) { 40 + throw RecordValidationException::invalidType($path, 'object', gettype($value)); 41 + } 42 + 43 + // Check for $type discriminator 44 + if (! isset($value['$type'])) { 45 + throw RecordValidationException::invalidValue( 46 + $path, 47 + 'Discriminated union must have $type field' 48 + ); 49 + } 50 + 51 + $type = $value['$type']; 52 + 53 + // Validate that $type is one of the allowed refs 54 + if (! in_array($type, $refs, true)) { 55 + $allowed = implode(', ', $refs); 56 + 57 + throw RecordValidationException::invalidValue( 58 + $path, 59 + "Union type '{$type}' not allowed. Must be one of: {$allowed}" 60 + ); 61 + } 62 + } 63 + 64 + /** 65 + * Validate open (undiscriminated) union. 66 + * 67 + * @param array<string> $refs 68 + */ 69 + protected function validateOpenUnion(mixed $value, array $refs, string $path): void 70 + { 71 + // For open unions, we just verify it's valid data 72 + // The actual type checking would require schema resolution which is complex 73 + // For now, we just ensure it's an object or primitive type 74 + 75 + if (is_null($value)) { 76 + throw RecordValidationException::invalidValue($path, 'Union value cannot be null'); 77 + } 78 + 79 + // Open unions are flexible, so we allow objects and primitives 80 + if (! is_array($value) && ! is_string($value) && ! is_int($value) && ! is_bool($value)) { 81 + throw RecordValidationException::invalidType($path, 'valid union value', gettype($value)); 82 + } 83 + } 84 + }
+134
tests/Unit/Validation/TypeValidators/ArrayValidatorTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Validation\TypeValidators; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Exceptions\RecordValidationException; 7 + use SocialDept\Schema\Validation\TypeValidators\ArrayValidator; 8 + 9 + class ArrayValidatorTest extends TestCase 10 + { 11 + protected ArrayValidator $validator; 12 + 13 + protected function setUp(): void 14 + { 15 + parent::setUp(); 16 + 17 + $this->validator = new ArrayValidator(); 18 + } 19 + 20 + public function test_it_validates_valid_array(): void 21 + { 22 + $this->validator->validate([1, 2, 3], ['type' => 'array'], '$.field'); 23 + 24 + $this->assertTrue(true); 25 + } 26 + 27 + public function test_it_rejects_non_array(): void 28 + { 29 + $this->expectException(RecordValidationException::class); 30 + 31 + $this->validator->validate('not an array', ['type' => 'array'], '$.field'); 32 + } 33 + 34 + public function test_it_validates_max_items(): void 35 + { 36 + $this->validator->validate([1, 2, 3], ['type' => 'array', 'maxItems' => 5], '$.field'); 37 + 38 + $this->assertTrue(true); 39 + } 40 + 41 + public function test_it_rejects_array_exceeding_max_items(): void 42 + { 43 + $this->expectException(RecordValidationException::class); 44 + 45 + $this->validator->validate([1, 2, 3, 4, 5, 6], ['type' => 'array', 'maxItems' => 5], '$.field'); 46 + } 47 + 48 + public function test_it_validates_min_items(): void 49 + { 50 + $this->validator->validate([1, 2, 3], ['type' => 'array', 'minItems' => 2], '$.field'); 51 + 52 + $this->assertTrue(true); 53 + } 54 + 55 + public function test_it_rejects_array_below_min_items(): void 56 + { 57 + $this->expectException(RecordValidationException::class); 58 + 59 + $this->validator->validate([1], ['type' => 'array', 'minItems' => 3], '$.field'); 60 + } 61 + 62 + public function test_it_validates_array_items(): void 63 + { 64 + $this->validator->validate( 65 + ['a', 'b', 'c'], 66 + ['type' => 'array', 'items' => ['type' => 'string']], 67 + '$.field' 68 + ); 69 + 70 + $this->assertTrue(true); 71 + } 72 + 73 + public function test_it_rejects_invalid_array_item(): void 74 + { 75 + $this->expectException(RecordValidationException::class); 76 + 77 + $this->validator->validate( 78 + ['a', 123, 'c'], 79 + ['type' => 'array', 'items' => ['type' => 'string']], 80 + '$.field' 81 + ); 82 + } 83 + 84 + public function test_it_validates_array_of_integers(): void 85 + { 86 + $this->validator->validate( 87 + [1, 2, 3], 88 + ['type' => 'array', 'items' => ['type' => 'integer']], 89 + '$.field' 90 + ); 91 + 92 + $this->assertTrue(true); 93 + } 94 + 95 + public function test_it_validates_array_of_objects(): void 96 + { 97 + $this->validator->validate( 98 + [ 99 + ['name' => 'John'], 100 + ['name' => 'Jane'], 101 + ], 102 + [ 103 + 'type' => 'array', 104 + 'items' => [ 105 + 'type' => 'object', 106 + 'required' => ['name'], 107 + 'properties' => [ 108 + 'name' => ['type' => 'string'], 109 + ], 110 + ], 111 + ], 112 + '$.field' 113 + ); 114 + 115 + $this->assertTrue(true); 116 + } 117 + 118 + public function test_it_validates_nested_arrays(): void 119 + { 120 + $this->validator->validate( 121 + [[1, 2], [3, 4]], 122 + [ 123 + 'type' => 'array', 124 + 'items' => [ 125 + 'type' => 'array', 126 + 'items' => ['type' => 'integer'], 127 + ], 128 + ], 129 + '$.field' 130 + ); 131 + 132 + $this->assertTrue(true); 133 + } 134 + }
+182
tests/Unit/Validation/TypeValidators/BlobValidatorTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Validation\TypeValidators; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Exceptions\RecordValidationException; 7 + use SocialDept\Schema\Validation\TypeValidators\BlobValidator; 8 + 9 + class BlobValidatorTest extends TestCase 10 + { 11 + protected BlobValidator $validator; 12 + 13 + protected function setUp(): void 14 + { 15 + parent::setUp(); 16 + 17 + $this->validator = new BlobValidator(); 18 + } 19 + 20 + public function test_it_validates_valid_blob(): void 21 + { 22 + $this->validator->validate( 23 + [ 24 + '$type' => 'blob', 25 + 'ref' => 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi', 26 + 'mimeType' => 'image/png', 27 + 'size' => 1024, 28 + ], 29 + ['type' => 'blob'], 30 + '$.field' 31 + ); 32 + 33 + $this->assertTrue(true); 34 + } 35 + 36 + public function test_it_rejects_non_array_blob(): void 37 + { 38 + $this->expectException(RecordValidationException::class); 39 + 40 + $this->validator->validate('not a blob', ['type' => 'blob'], '$.field'); 41 + } 42 + 43 + public function test_it_rejects_blob_without_type_field(): void 44 + { 45 + $this->expectException(RecordValidationException::class); 46 + 47 + $this->validator->validate( 48 + [ 49 + 'ref' => 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi', 50 + 'mimeType' => 'image/png', 51 + 'size' => 1024, 52 + ], 53 + ['type' => 'blob'], 54 + '$.field' 55 + ); 56 + } 57 + 58 + public function test_it_rejects_blob_without_ref(): void 59 + { 60 + $this->expectException(RecordValidationException::class); 61 + 62 + $this->validator->validate( 63 + [ 64 + '$type' => 'blob', 65 + 'mimeType' => 'image/png', 66 + 'size' => 1024, 67 + ], 68 + ['type' => 'blob'], 69 + '$.field' 70 + ); 71 + } 72 + 73 + public function test_it_rejects_blob_without_mime_type(): void 74 + { 75 + $this->expectException(RecordValidationException::class); 76 + 77 + $this->validator->validate( 78 + [ 79 + '$type' => 'blob', 80 + 'ref' => 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi', 81 + 'size' => 1024, 82 + ], 83 + ['type' => 'blob'], 84 + '$.field' 85 + ); 86 + } 87 + 88 + public function test_it_rejects_blob_without_size(): void 89 + { 90 + $this->expectException(RecordValidationException::class); 91 + 92 + $this->validator->validate( 93 + [ 94 + '$type' => 'blob', 95 + 'ref' => 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi', 96 + 'mimeType' => 'image/png', 97 + ], 98 + ['type' => 'blob'], 99 + '$.field' 100 + ); 101 + } 102 + 103 + public function test_it_validates_accepted_mime_type(): void 104 + { 105 + $this->validator->validate( 106 + [ 107 + '$type' => 'blob', 108 + 'ref' => 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi', 109 + 'mimeType' => 'image/png', 110 + 'size' => 1024, 111 + ], 112 + ['type' => 'blob', 'accept' => ['image/png', 'image/jpeg']], 113 + '$.field' 114 + ); 115 + 116 + $this->assertTrue(true); 117 + } 118 + 119 + public function test_it_rejects_unaccepted_mime_type(): void 120 + { 121 + $this->expectException(RecordValidationException::class); 122 + 123 + $this->validator->validate( 124 + [ 125 + '$type' => 'blob', 126 + 'ref' => 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi', 127 + 'mimeType' => 'video/mp4', 128 + 'size' => 1024, 129 + ], 130 + ['type' => 'blob', 'accept' => ['image/png', 'image/jpeg']], 131 + '$.field' 132 + ); 133 + } 134 + 135 + public function test_it_validates_wildcard_mime_type(): void 136 + { 137 + $this->validator->validate( 138 + [ 139 + '$type' => 'blob', 140 + 'ref' => 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi', 141 + 'mimeType' => 'image/webp', 142 + 'size' => 1024, 143 + ], 144 + ['type' => 'blob', 'accept' => ['image/*']], 145 + '$.field' 146 + ); 147 + 148 + $this->assertTrue(true); 149 + } 150 + 151 + public function test_it_validates_max_size(): void 152 + { 153 + $this->validator->validate( 154 + [ 155 + '$type' => 'blob', 156 + 'ref' => 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi', 157 + 'mimeType' => 'image/png', 158 + 'size' => 1024, 159 + ], 160 + ['type' => 'blob', 'maxSize' => 2048], 161 + '$.field' 162 + ); 163 + 164 + $this->assertTrue(true); 165 + } 166 + 167 + public function test_it_rejects_blob_exceeding_max_size(): void 168 + { 169 + $this->expectException(RecordValidationException::class); 170 + 171 + $this->validator->validate( 172 + [ 173 + '$type' => 'blob', 174 + 'ref' => 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi', 175 + 'mimeType' => 'image/png', 176 + 'size' => 3000, 177 + ], 178 + ['type' => 'blob', 'maxSize' => 2048], 179 + '$.field' 180 + ); 181 + } 182 + }
+68
tests/Unit/Validation/TypeValidators/BooleanValidatorTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Validation\TypeValidators; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Exceptions\RecordValidationException; 7 + use SocialDept\Schema\Validation\TypeValidators\BooleanValidator; 8 + 9 + class BooleanValidatorTest extends TestCase 10 + { 11 + protected BooleanValidator $validator; 12 + 13 + protected function setUp(): void 14 + { 15 + parent::setUp(); 16 + 17 + $this->validator = new BooleanValidator(); 18 + } 19 + 20 + public function test_it_validates_true(): void 21 + { 22 + $this->validator->validate(true, ['type' => 'boolean'], '$.field'); 23 + 24 + $this->assertTrue(true); 25 + } 26 + 27 + public function test_it_validates_false(): void 28 + { 29 + $this->validator->validate(false, ['type' => 'boolean'], '$.field'); 30 + 31 + $this->assertTrue(true); 32 + } 33 + 34 + public function test_it_rejects_non_boolean(): void 35 + { 36 + $this->expectException(RecordValidationException::class); 37 + 38 + $this->validator->validate('not a boolean', ['type' => 'boolean'], '$.field'); 39 + } 40 + 41 + public function test_it_rejects_integer_zero(): void 42 + { 43 + $this->expectException(RecordValidationException::class); 44 + 45 + $this->validator->validate(0, ['type' => 'boolean'], '$.field'); 46 + } 47 + 48 + public function test_it_validates_const_true(): void 49 + { 50 + $this->validator->validate(true, ['type' => 'boolean', 'const' => true], '$.field'); 51 + 52 + $this->assertTrue(true); 53 + } 54 + 55 + public function test_it_validates_const_false(): void 56 + { 57 + $this->validator->validate(false, ['type' => 'boolean', 'const' => false], '$.field'); 58 + 59 + $this->assertTrue(true); 60 + } 61 + 62 + public function test_it_rejects_value_not_matching_const(): void 63 + { 64 + $this->expectException(RecordValidationException::class); 65 + 66 + $this->validator->validate(false, ['type' => 'boolean', 'const' => true], '$.field'); 67 + } 68 + }
+89
tests/Unit/Validation/TypeValidators/IntegerValidatorTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Validation\TypeValidators; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Exceptions\RecordValidationException; 7 + use SocialDept\Schema\Validation\TypeValidators\IntegerValidator; 8 + 9 + class IntegerValidatorTest extends TestCase 10 + { 11 + protected IntegerValidator $validator; 12 + 13 + protected function setUp(): void 14 + { 15 + parent::setUp(); 16 + 17 + $this->validator = new IntegerValidator(); 18 + } 19 + 20 + public function test_it_validates_valid_integer(): void 21 + { 22 + $this->validator->validate(42, ['type' => 'integer'], '$.field'); 23 + 24 + $this->assertTrue(true); 25 + } 26 + 27 + public function test_it_rejects_non_integer(): void 28 + { 29 + $this->expectException(RecordValidationException::class); 30 + 31 + $this->validator->validate('not an integer', ['type' => 'integer'], '$.field'); 32 + } 33 + 34 + public function test_it_validates_maximum_constraint(): void 35 + { 36 + $this->validator->validate(50, ['type' => 'integer', 'maximum' => 100], '$.field'); 37 + 38 + $this->assertTrue(true); 39 + } 40 + 41 + public function test_it_rejects_value_exceeding_maximum(): void 42 + { 43 + $this->expectException(RecordValidationException::class); 44 + 45 + $this->validator->validate(150, ['type' => 'integer', 'maximum' => 100], '$.field'); 46 + } 47 + 48 + public function test_it_validates_minimum_constraint(): void 49 + { 50 + $this->validator->validate(50, ['type' => 'integer', 'minimum' => 10], '$.field'); 51 + 52 + $this->assertTrue(true); 53 + } 54 + 55 + public function test_it_rejects_value_below_minimum(): void 56 + { 57 + $this->expectException(RecordValidationException::class); 58 + 59 + $this->validator->validate(5, ['type' => 'integer', 'minimum' => 10], '$.field'); 60 + } 61 + 62 + public function test_it_validates_enum_constraint(): void 63 + { 64 + $this->validator->validate(2, ['type' => 'integer', 'enum' => [1, 2, 3]], '$.field'); 65 + 66 + $this->assertTrue(true); 67 + } 68 + 69 + public function test_it_rejects_value_not_in_enum(): void 70 + { 71 + $this->expectException(RecordValidationException::class); 72 + 73 + $this->validator->validate(5, ['type' => 'integer', 'enum' => [1, 2, 3]], '$.field'); 74 + } 75 + 76 + public function test_it_validates_const_constraint(): void 77 + { 78 + $this->validator->validate(42, ['type' => 'integer', 'const' => 42], '$.field'); 79 + 80 + $this->assertTrue(true); 81 + } 82 + 83 + public function test_it_rejects_value_not_matching_const(): void 84 + { 85 + $this->expectException(RecordValidationException::class); 86 + 87 + $this->validator->validate(100, ['type' => 'integer', 'const' => 42], '$.field'); 88 + } 89 + }
+166
tests/Unit/Validation/TypeValidators/ObjectValidatorTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Validation\TypeValidators; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Exceptions\RecordValidationException; 7 + use SocialDept\Schema\Validation\TypeValidators\ObjectValidator; 8 + 9 + class ObjectValidatorTest extends TestCase 10 + { 11 + protected ObjectValidator $validator; 12 + 13 + protected function setUp(): void 14 + { 15 + parent::setUp(); 16 + 17 + $this->validator = new ObjectValidator(); 18 + } 19 + 20 + public function test_it_validates_valid_object(): void 21 + { 22 + $this->validator->validate( 23 + ['name' => 'John'], 24 + [ 25 + 'type' => 'object', 26 + 'required' => ['name'], 27 + 'properties' => [ 28 + 'name' => ['type' => 'string'], 29 + ], 30 + ], 31 + '$.field' 32 + ); 33 + 34 + $this->assertTrue(true); 35 + } 36 + 37 + public function test_it_rejects_non_object(): void 38 + { 39 + $this->expectException(RecordValidationException::class); 40 + 41 + $this->validator->validate('not an object', ['type' => 'object'], '$.field'); 42 + } 43 + 44 + public function test_it_validates_object_with_multiple_properties(): void 45 + { 46 + $this->validator->validate( 47 + ['name' => 'John', 'age' => 30], 48 + [ 49 + 'type' => 'object', 50 + 'required' => ['name', 'age'], 51 + 'properties' => [ 52 + 'name' => ['type' => 'string'], 53 + 'age' => ['type' => 'integer'], 54 + ], 55 + ], 56 + '$.field' 57 + ); 58 + 59 + $this->assertTrue(true); 60 + } 61 + 62 + public function test_it_rejects_missing_required_field(): void 63 + { 64 + $this->expectException(RecordValidationException::class); 65 + 66 + $this->validator->validate( 67 + ['name' => 'John'], 68 + [ 69 + 'type' => 'object', 70 + 'required' => ['name', 'age'], 71 + 'properties' => [ 72 + 'name' => ['type' => 'string'], 73 + 'age' => ['type' => 'integer'], 74 + ], 75 + ], 76 + '$.field' 77 + ); 78 + } 79 + 80 + public function test_it_validates_optional_properties(): void 81 + { 82 + $this->validator->validate( 83 + ['name' => 'John'], 84 + [ 85 + 'type' => 'object', 86 + 'required' => ['name'], 87 + 'properties' => [ 88 + 'name' => ['type' => 'string'], 89 + 'age' => ['type' => 'integer'], 90 + ], 91 + ], 92 + '$.field' 93 + ); 94 + 95 + $this->assertTrue(true); 96 + } 97 + 98 + public function test_it_validates_nested_objects(): void 99 + { 100 + $this->validator->validate( 101 + [ 102 + 'user' => [ 103 + 'name' => 'John', 104 + 'profile' => [ 105 + 'bio' => 'Developer', 106 + ], 107 + ], 108 + ], 109 + [ 110 + 'type' => 'object', 111 + 'required' => ['user'], 112 + 'properties' => [ 113 + 'user' => [ 114 + 'type' => 'object', 115 + 'required' => ['name', 'profile'], 116 + 'properties' => [ 117 + 'name' => ['type' => 'string'], 118 + 'profile' => [ 119 + 'type' => 'object', 120 + 'required' => ['bio'], 121 + 'properties' => [ 122 + 'bio' => ['type' => 'string'], 123 + ], 124 + ], 125 + ], 126 + ], 127 + ], 128 + ], 129 + '$.field' 130 + ); 131 + 132 + $this->assertTrue(true); 133 + } 134 + 135 + public function test_it_rejects_invalid_property_type(): void 136 + { 137 + $this->expectException(RecordValidationException::class); 138 + 139 + $this->validator->validate( 140 + ['name' => 123], 141 + [ 142 + 'type' => 'object', 143 + 'required' => ['name'], 144 + 'properties' => [ 145 + 'name' => ['type' => 'string'], 146 + ], 147 + ], 148 + '$.field' 149 + ); 150 + } 151 + 152 + public function test_it_validates_empty_object(): void 153 + { 154 + $this->validator->validate( 155 + [], 156 + [ 157 + 'type' => 'object', 158 + 'required' => [], 159 + 'properties' => [], 160 + ], 161 + '$.field' 162 + ); 163 + 164 + $this->assertTrue(true); 165 + } 166 + }
+213
tests/Unit/Validation/TypeValidators/StringValidatorTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Validation\TypeValidators; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Exceptions\RecordValidationException; 7 + use SocialDept\Schema\Validation\TypeValidators\StringValidator; 8 + 9 + class StringValidatorTest extends TestCase 10 + { 11 + protected StringValidator $validator; 12 + 13 + protected function setUp(): void 14 + { 15 + parent::setUp(); 16 + 17 + $this->validator = new StringValidator(); 18 + } 19 + 20 + public function test_it_validates_valid_string(): void 21 + { 22 + $this->validator->validate('test', ['type' => 'string'], '$.field'); 23 + 24 + $this->assertTrue(true); 25 + } 26 + 27 + public function test_it_rejects_non_string(): void 28 + { 29 + $this->expectException(RecordValidationException::class); 30 + 31 + $this->validator->validate(123, ['type' => 'string'], '$.field'); 32 + } 33 + 34 + public function test_it_validates_max_length(): void 35 + { 36 + $this->validator->validate('test', ['type' => 'string', 'maxLength' => 10], '$.field'); 37 + 38 + $this->assertTrue(true); 39 + } 40 + 41 + public function test_it_rejects_string_exceeding_max_length(): void 42 + { 43 + $this->expectException(RecordValidationException::class); 44 + 45 + $this->validator->validate('this is too long', ['type' => 'string', 'maxLength' => 5], '$.field'); 46 + } 47 + 48 + public function test_it_validates_min_length(): void 49 + { 50 + $this->validator->validate('test', ['type' => 'string', 'minLength' => 3], '$.field'); 51 + 52 + $this->assertTrue(true); 53 + } 54 + 55 + public function test_it_rejects_string_below_min_length(): void 56 + { 57 + $this->expectException(RecordValidationException::class); 58 + 59 + $this->validator->validate('ab', ['type' => 'string', 'minLength' => 5], '$.field'); 60 + } 61 + 62 + public function test_it_validates_max_graphemes(): void 63 + { 64 + $this->validator->validate('😀😁😂', ['type' => 'string', 'maxGraphemes' => 5], '$.field'); 65 + 66 + $this->assertTrue(true); 67 + } 68 + 69 + public function test_it_rejects_string_exceeding_max_graphemes(): void 70 + { 71 + $this->expectException(RecordValidationException::class); 72 + 73 + $this->validator->validate('😀😁😂😃😄😅', ['type' => 'string', 'maxGraphemes' => 5], '$.field'); 74 + } 75 + 76 + public function test_it_validates_min_graphemes(): void 77 + { 78 + $this->validator->validate('😀😁😂😃😄', ['type' => 'string', 'minGraphemes' => 3], '$.field'); 79 + 80 + $this->assertTrue(true); 81 + } 82 + 83 + public function test_it_rejects_string_below_min_graphemes(): void 84 + { 85 + $this->expectException(RecordValidationException::class); 86 + 87 + $this->validator->validate('😀😁', ['type' => 'string', 'minGraphemes' => 5], '$.field'); 88 + } 89 + 90 + public function test_it_validates_enum_constraint(): void 91 + { 92 + $this->validator->validate('active', [ 93 + 'type' => 'string', 94 + 'enum' => ['active', 'inactive', 'pending'], 95 + ], '$.field'); 96 + 97 + $this->assertTrue(true); 98 + } 99 + 100 + public function test_it_rejects_value_not_in_enum(): void 101 + { 102 + $this->expectException(RecordValidationException::class); 103 + 104 + $this->validator->validate('unknown', [ 105 + 'type' => 'string', 106 + 'enum' => ['active', 'inactive', 'pending'], 107 + ], '$.field'); 108 + } 109 + 110 + public function test_it_validates_const_constraint(): void 111 + { 112 + $this->validator->validate('post', ['type' => 'string', 'const' => 'post'], '$.field'); 113 + 114 + $this->assertTrue(true); 115 + } 116 + 117 + public function test_it_rejects_value_not_matching_const(): void 118 + { 119 + $this->expectException(RecordValidationException::class); 120 + 121 + $this->validator->validate('comment', ['type' => 'string', 'const' => 'post'], '$.field'); 122 + } 123 + 124 + public function test_it_validates_datetime_format(): void 125 + { 126 + $this->validator->validate('2024-01-01T00:00:00Z', [ 127 + 'type' => 'string', 128 + 'format' => 'datetime', 129 + ], '$.field'); 130 + 131 + $this->assertTrue(true); 132 + } 133 + 134 + public function test_it_rejects_invalid_datetime(): void 135 + { 136 + $this->expectException(RecordValidationException::class); 137 + 138 + $this->validator->validate('not-a-datetime', [ 139 + 'type' => 'string', 140 + 'format' => 'datetime', 141 + ], '$.field'); 142 + } 143 + 144 + public function test_it_validates_uri_format(): void 145 + { 146 + $this->validator->validate('https://example.com', [ 147 + 'type' => 'string', 148 + 'format' => 'uri', 149 + ], '$.field'); 150 + 151 + $this->assertTrue(true); 152 + } 153 + 154 + public function test_it_validates_did_format(): void 155 + { 156 + $this->validator->validate('did:plc:z72i7hdynmk6r22z27h6tvur', [ 157 + 'type' => 'string', 158 + 'format' => 'did', 159 + ], '$.field'); 160 + 161 + $this->assertTrue(true); 162 + } 163 + 164 + public function test_it_validates_handle_format(): void 165 + { 166 + $this->validator->validate('user.bsky.social', [ 167 + 'type' => 'string', 168 + 'format' => 'handle', 169 + ], '$.field'); 170 + 171 + $this->assertTrue(true); 172 + } 173 + 174 + public function test_it_validates_nsid_format(): void 175 + { 176 + $this->validator->validate('app.bsky.feed.post', [ 177 + 'type' => 'string', 178 + 'format' => 'nsid', 179 + ], '$.field'); 180 + 181 + $this->assertTrue(true); 182 + } 183 + 184 + public function test_it_validates_cid_format(): void 185 + { 186 + $this->validator->validate('bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi', [ 187 + 'type' => 'string', 188 + 'format' => 'cid', 189 + ], '$.field'); 190 + 191 + $this->assertTrue(true); 192 + } 193 + 194 + public function test_it_validates_language_format(): void 195 + { 196 + $this->validator->validate('en-US', [ 197 + 'type' => 'string', 198 + 'format' => 'language', 199 + ], '$.field'); 200 + 201 + $this->assertTrue(true); 202 + } 203 + 204 + public function test_it_passes_unknown_formats(): void 205 + { 206 + $this->validator->validate('anything', [ 207 + 'type' => 'string', 208 + 'format' => 'unknown-format', 209 + ], '$.field'); 210 + 211 + $this->assertTrue(true); 212 + } 213 + }
+153
tests/Unit/Validation/TypeValidators/UnionValidatorTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Validation\TypeValidators; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Exceptions\RecordValidationException; 7 + use SocialDept\Schema\Validation\TypeValidators\UnionValidator; 8 + 9 + class UnionValidatorTest extends TestCase 10 + { 11 + protected UnionValidator $validator; 12 + 13 + protected function setUp(): void 14 + { 15 + parent::setUp(); 16 + 17 + $this->validator = new UnionValidator(); 18 + } 19 + 20 + public function test_it_validates_discriminated_union(): void 21 + { 22 + $this->validator->validate( 23 + ['$type' => 'app.bsky.feed.post'], 24 + [ 25 + 'type' => 'union', 26 + 'refs' => ['app.bsky.feed.post', 'app.bsky.feed.repost'], 27 + 'closed' => true, 28 + ], 29 + '$.field' 30 + ); 31 + 32 + $this->assertTrue(true); 33 + } 34 + 35 + public function test_it_rejects_discriminated_union_without_type(): void 36 + { 37 + $this->expectException(RecordValidationException::class); 38 + 39 + $this->validator->validate( 40 + ['text' => 'Hello'], 41 + [ 42 + 'type' => 'union', 43 + 'refs' => ['app.bsky.feed.post', 'app.bsky.feed.repost'], 44 + 'closed' => true, 45 + ], 46 + '$.field' 47 + ); 48 + } 49 + 50 + public function test_it_rejects_discriminated_union_with_invalid_type(): void 51 + { 52 + $this->expectException(RecordValidationException::class); 53 + 54 + $this->validator->validate( 55 + ['$type' => 'app.bsky.feed.invalid'], 56 + [ 57 + 'type' => 'union', 58 + 'refs' => ['app.bsky.feed.post', 'app.bsky.feed.repost'], 59 + 'closed' => true, 60 + ], 61 + '$.field' 62 + ); 63 + } 64 + 65 + public function test_it_rejects_non_object_for_discriminated_union(): void 66 + { 67 + $this->expectException(RecordValidationException::class); 68 + 69 + $this->validator->validate( 70 + 'not an object', 71 + [ 72 + 'type' => 'union', 73 + 'refs' => ['app.bsky.feed.post', 'app.bsky.feed.repost'], 74 + 'closed' => true, 75 + ], 76 + '$.field' 77 + ); 78 + } 79 + 80 + public function test_it_validates_open_union_with_object(): void 81 + { 82 + $this->validator->validate( 83 + ['data' => 'value'], 84 + [ 85 + 'type' => 'union', 86 + 'refs' => ['app.bsky.feed.post', 'app.bsky.feed.repost'], 87 + 'closed' => false, 88 + ], 89 + '$.field' 90 + ); 91 + 92 + $this->assertTrue(true); 93 + } 94 + 95 + public function test_it_validates_open_union_with_string(): void 96 + { 97 + $this->validator->validate( 98 + 'some value', 99 + [ 100 + 'type' => 'union', 101 + 'refs' => ['app.bsky.feed.post', 'app.bsky.feed.repost'], 102 + 'closed' => false, 103 + ], 104 + '$.field' 105 + ); 106 + 107 + $this->assertTrue(true); 108 + } 109 + 110 + public function test_it_validates_open_union_with_integer(): void 111 + { 112 + $this->validator->validate( 113 + 123, 114 + [ 115 + 'type' => 'union', 116 + 'refs' => ['app.bsky.feed.post', 'app.bsky.feed.repost'], 117 + 'closed' => false, 118 + ], 119 + '$.field' 120 + ); 121 + 122 + $this->assertTrue(true); 123 + } 124 + 125 + public function test_it_rejects_open_union_with_null(): void 126 + { 127 + $this->expectException(RecordValidationException::class); 128 + 129 + $this->validator->validate( 130 + null, 131 + [ 132 + 'type' => 'union', 133 + 'refs' => ['app.bsky.feed.post', 'app.bsky.feed.repost'], 134 + 'closed' => false, 135 + ], 136 + '$.field' 137 + ); 138 + } 139 + 140 + public function test_it_rejects_union_without_refs(): void 141 + { 142 + $this->expectException(RecordValidationException::class); 143 + 144 + $this->validator->validate( 145 + ['$type' => 'app.bsky.feed.post'], 146 + [ 147 + 'type' => 'union', 148 + 'closed' => true, 149 + ], 150 + '$.field' 151 + ); 152 + } 153 + }