Parse and validate AT Protocol Lexicons with DTO generation for Laravel
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}