Parse and validate AT Protocol Lexicons with DTO generation for Laravel
at dev 12 kB view raw
1<?php 2 3namespace SocialDept\AtpSchema\Tests\Integration; 4 5use Illuminate\Http\UploadedFile; 6use Illuminate\Support\Facades\Storage; 7use Orchestra\Testbench\TestCase; 8use SocialDept\AtpSchema\Contracts\LexiconRegistry; 9use SocialDept\AtpSchema\Data\LexiconDocument; 10use SocialDept\AtpSchema\Parser\SchemaLoader; 11use SocialDept\AtpSchema\Services\BlobHandler; 12use SocialDept\AtpSchema\Services\UnionResolver; 13use SocialDept\AtpSchema\Support\ExtensionManager; 14use SocialDept\AtpSchema\Validation\Validator; 15 16class CompleteWorkflowTest extends TestCase 17{ 18 protected function setUp(): void 19 { 20 parent::setUp(); 21 22 Storage::fake('local'); 23 } 24 25 public function test_complete_post_creation_workflow(): void 26 { 27 // Step 1: Load schema 28 $schemaLoader = new SchemaLoader([]); 29 $schema = LexiconDocument::fromArray([ 30 'lexicon' => 1, 31 'id' => 'app.bsky.feed.post', 32 'defs' => [ 33 'main' => [ 34 'type' => 'record', 35 'key' => 'tid', 36 'record' => [ 37 'type' => 'object', 38 'required' => ['text', 'createdAt'], 39 'properties' => [ 40 'text' => [ 41 'type' => 'string', 42 'maxLength' => 300, 43 'maxGraphemes' => 300, 44 ], 45 'createdAt' => [ 46 'type' => 'string', 47 'format' => 'datetime', 48 ], 49 'embed' => [ 50 'type' => 'union', 51 'refs' => ['app.bsky.embed.images', 'app.bsky.embed.external'], 52 'closed' => true, 53 ], 54 ], 55 ], 56 ], 57 ], 58 ]); 59 60 // Step 2: Create post data 61 $postData = [ 62 'text' => 'Check out this amazing photo!', 63 'createdAt' => '2024-01-01T12:00:00Z', 64 'embed' => [ 65 '$type' => 'app.bsky.embed.images', 66 'images' => [], 67 ], 68 ]; 69 70 // Step 3: Validate 71 $validator = new Validator($schemaLoader); 72 $isValid = $validator->validate($postData, $schema); 73 74 $this->assertTrue($isValid); 75 76 // Step 4: Verify union type 77 $unionResolver = new UnionResolver(); 78 $embedType = $unionResolver->extractType($postData['embed']); 79 80 $this->assertEquals('app.bsky.embed.images', $embedType); 81 } 82 83 public function test_image_upload_with_validation_workflow(): void 84 { 85 // Step 1: Upload image 86 $blobHandler = new BlobHandler('local'); 87 $file = UploadedFile::fake()->image('photo.jpg', 800, 600); 88 89 $constraints = [ 90 'accept' => ['image/*'], 91 'maxSize' => 1024 * 1024 * 5, // 5MB 92 ]; 93 94 $blob = $blobHandler->store($file, $constraints); 95 96 // Step 2: Verify blob 97 $this->assertStringStartsWith('bafyrei', $blob->ref); 98 $this->assertTrue($blob->isImage()); 99 100 // Step 3: Create record with blob 101 $schemaLoader = new SchemaLoader([]); 102 $schema = LexiconDocument::fromArray([ 103 'lexicon' => 1, 104 'id' => 'app.bsky.embed.images', 105 'defs' => [ 106 'main' => [ 107 'type' => 'record', 108 'key' => 'tid', 109 'record' => [ 110 'type' => 'object', 111 'properties' => [ 112 'images' => [ 113 'type' => 'array', 114 'maxLength' => 4, 115 'items' => [ 116 'type' => 'object', 117 'properties' => [ 118 'image' => [ 119 'type' => 'blob', 120 'accept' => ['image/*'], 121 ], 122 'alt' => [ 123 'type' => 'string', 124 'maxLength' => 1000, 125 ], 126 ], 127 ], 128 ], 129 ], 130 ], 131 ], 132 ], 133 ]); 134 135 $data = [ 136 'images' => [ 137 [ 138 'image' => $blob->toArray(), 139 'alt' => 'A beautiful sunset', 140 ], 141 ], 142 ]; 143 144 // Step 4: Validate 145 $validator = new Validator($schemaLoader); 146 $isValid = $validator->validate($data, $schema); 147 148 $this->assertTrue($isValid); 149 150 // Step 5: Retrieve blob content 151 $content = $blobHandler->get($blob->ref); 152 153 $this->assertNotNull($content); 154 } 155 156 public function test_validation_error_formatting_workflow(): void 157 { 158 $schemaLoader = new SchemaLoader([]); 159 $schema = LexiconDocument::fromArray([ 160 'lexicon' => 1, 161 'id' => 'app.bsky.feed.post', 162 'defs' => [ 163 'main' => [ 164 'type' => 'record', 165 'key' => 'tid', 166 'record' => [ 167 'type' => 'object', 168 'required' => ['text', 'createdAt'], 169 'properties' => [ 170 'text' => ['type' => 'string', 'maxLength' => 10], 171 'createdAt' => ['type' => 'string', 'format' => 'datetime'], 172 ], 173 ], 174 ], 175 ], 176 ]); 177 178 $invalidData = [ 179 'text' => 'This text is way too long for the constraint', 180 ]; 181 182 $validator = new Validator($schemaLoader); 183 $validator->validate($invalidData, $schema); 184 185 // Get errors 186 $errors = $validator->validateWithErrors($invalidData, $schema); 187 188 $this->assertNotEmpty($errors); 189 190 // Errors are in Laravel format 191 $this->assertArrayHasKey('text', $errors); 192 $this->assertArrayHasKey('createdAt', $errors); // Missing required field 193 194 // Verify error messages 195 $this->assertIsArray($errors['text']); 196 $this->assertNotEmpty($errors['text'][0]); 197 $this->assertIsArray($errors['createdAt']); 198 $this->assertNotEmpty($errors['createdAt'][0]); 199 } 200 201 public function test_extension_hooks_workflow(): void 202 { 203 $extensions = new ExtensionManager(); 204 205 // Register validation hook 206 $extensions->hook('before:validate', function ($data) { 207 // Transform data before validation 208 if (isset($data['text'])) { 209 $data['text'] = trim($data['text']); 210 } 211 212 return $data; 213 }); 214 215 // Register post-validation hook 216 $executed = false; 217 $extensions->hook('after:validate', function ($result) use (&$executed) { 218 $executed = true; 219 220 return $result; 221 }); 222 223 // Execute hooks 224 $data = ['text' => ' Hello, world! ']; 225 $transformed = $extensions->filter('before:validate', $data); 226 227 $this->assertEquals('Hello, world!', $transformed['text']); 228 229 $extensions->execute('after:validate', true); 230 231 $this->assertTrue($executed); 232 } 233 234 public function test_schema_registry_workflow(): void 235 { 236 $registry = new SimpleRegistry(); 237 238 // Register schemas 239 $schemaLoader = new SchemaLoader([]); 240 241 $postSchema = LexiconDocument::fromArray([ 242 'lexicon' => 1, 243 'id' => 'app.bsky.feed.post', 244 'defs' => ['main' => ['type' => 'record', 'key' => 'tid']], 245 ]); 246 247 $repostSchema = LexiconDocument::fromArray([ 248 'lexicon' => 1, 249 'id' => 'app.bsky.feed.repost', 250 'defs' => ['main' => ['type' => 'record', 'key' => 'tid']], 251 ]); 252 253 $registry->register($postSchema); 254 $registry->register($repostSchema); 255 256 // Retrieve schemas 257 $this->assertTrue($registry->has('app.bsky.feed.post')); 258 $this->assertTrue($registry->has('app.bsky.feed.repost')); 259 260 $retrieved = $registry->get('app.bsky.feed.post'); 261 $this->assertInstanceOf(LexiconDocument::class, $retrieved); 262 $this->assertEquals('app.bsky.feed.post', $retrieved->getNsid()); 263 264 // Use with union resolver 265 $unionResolver = new UnionResolver($registry); 266 267 $data = ['$type' => 'app.bsky.feed.post']; 268 $unionDef = [ 269 'type' => 'union', 270 'refs' => ['app.bsky.feed.post', 'app.bsky.feed.repost'], 271 'closed' => true, 272 ]; 273 274 $typeDef = $unionResolver->getTypeDefinition($data, $unionDef); 275 276 $this->assertInstanceOf(LexiconDocument::class, $typeDef); 277 $this->assertEquals('app.bsky.feed.post', $typeDef->getNsid()); 278 } 279 280 public function test_multimode_validation_workflow(): void 281 { 282 $schemaLoader = new SchemaLoader([]); 283 $schema = LexiconDocument::fromArray([ 284 'lexicon' => 1, 285 'id' => 'app.test.record', 286 'defs' => [ 287 'main' => [ 288 'type' => 'record', 289 'key' => 'tid', 290 'record' => [ 291 'type' => 'object', 292 'required' => ['name'], 293 'properties' => [ 294 'name' => ['type' => 'string'], 295 'age' => ['type' => 'integer', 'minimum' => 0], 296 ], 297 ], 298 ], 299 ], 300 ]); 301 302 $validator = new Validator($schemaLoader); 303 304 $dataWithExtra = [ 305 'name' => 'Alice', 306 'age' => 30, 307 'unknownField' => 'value', 308 ]; 309 310 // Strict mode 311 $validator->setMode(Validator::MODE_STRICT); 312 $this->assertFalse($validator->validate($dataWithExtra, $schema)); 313 314 // Optimistic mode 315 $validator->setMode(Validator::MODE_OPTIMISTIC); 316 $this->assertTrue($validator->validate($dataWithExtra, $schema)); 317 318 // Lenient mode 319 $validator->setMode(Validator::MODE_LENIENT); 320 $this->assertTrue($validator->validate($dataWithExtra, $schema)); 321 322 // Lenient mode ignores constraints 323 $invalidAge = ['name' => 'Bob', 'age' => -5]; 324 $validator->setMode(Validator::MODE_LENIENT); 325 $this->assertTrue($validator->validate($invalidAge, $schema)); 326 327 // But optimistic/strict catch it 328 $validator->setMode(Validator::MODE_OPTIMISTIC); 329 $this->assertFalse($validator->validate($invalidAge, $schema)); 330 } 331} 332 333// Simple registry implementation for testing 334class SimpleRegistry implements LexiconRegistry 335{ 336 protected array $schemas = []; 337 338 public function register(LexiconDocument $document): void 339 { 340 $this->schemas[$document->getNsid()] = $document; 341 } 342 343 public function get(string $nsid): ?LexiconDocument 344 { 345 return $this->schemas[$nsid] ?? null; 346 } 347 348 public function has(string $nsid): bool 349 { 350 return isset($this->schemas[$nsid]); 351 } 352 353 public function all(): array 354 { 355 return $this->schemas; 356 } 357 358 public function clear(): void 359 { 360 $this->schemas = []; 361 } 362}