Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1<?php
2
3namespace SocialDept\AtpSchema\Tests\Integration;
4
5use Illuminate\Support\Facades\Storage;
6use Orchestra\Testbench\TestCase;
7use SocialDept\AtpSchema\Data\LexiconDocument;
8use SocialDept\AtpSchema\Parser\SchemaLoader;
9use SocialDept\AtpSchema\Services\BlobHandler;
10use SocialDept\AtpSchema\Validation\Validator;
11
12class ValidationIntegrationTest extends TestCase
13{
14 protected SchemaLoader $schemaLoader;
15
16 protected Validator $validator;
17
18 protected function setUp(): void
19 {
20 parent::setUp();
21
22 $this->schemaLoader = new SchemaLoader([]);
23 $this->validator = new Validator($this->schemaLoader);
24 }
25
26 public function test_it_validates_complete_record_with_all_types(): void
27 {
28 $schema = LexiconDocument::fromArray([
29 'lexicon' => 1,
30 'id' => 'app.test.post',
31 'defs' => [
32 'main' => [
33 'type' => 'record',
34 'key' => 'tid',
35 'record' => [
36 'type' => 'object',
37 'required' => ['text', 'createdAt'],
38 'properties' => [
39 'text' => [
40 'type' => 'string',
41 'maxLength' => 300,
42 'maxGraphemes' => 300,
43 ],
44 'createdAt' => [
45 'type' => 'string',
46 'format' => 'datetime',
47 ],
48 'facets' => [
49 'type' => 'array',
50 'items' => [
51 'type' => 'object',
52 'properties' => [
53 'index' => ['type' => 'integer'],
54 'features' => [
55 'type' => 'array',
56 'items' => ['type' => 'string'],
57 ],
58 ],
59 ],
60 ],
61 'embed' => [
62 'type' => 'union',
63 'refs' => ['app.test.images', 'app.test.external'],
64 'closed' => true,
65 ],
66 ],
67 ],
68 ],
69 ],
70 ]);
71
72 $validData = [
73 'text' => 'Hello, world!',
74 'createdAt' => '2024-01-01T00:00:00Z',
75 'facets' => [
76 [
77 'index' => 0,
78 'features' => ['mention', 'link'],
79 ],
80 ],
81 'embed' => [
82 '$type' => 'app.test.images',
83 'images' => [],
84 ],
85 ];
86
87 $result = $this->validator->validate($validData, $schema);
88
89 $this->assertTrue($result);
90 }
91
92 public function test_it_detects_multiple_validation_errors(): void
93 {
94 $schema = LexiconDocument::fromArray([
95 'lexicon' => 1,
96 'id' => 'app.test.post',
97 'defs' => [
98 'main' => [
99 'type' => 'record',
100 'key' => 'tid',
101 'record' => [
102 'type' => 'object',
103 'required' => ['text', 'createdAt'],
104 'properties' => [
105 'text' => [
106 'type' => 'string',
107 'maxLength' => 10,
108 ],
109 'createdAt' => [
110 'type' => 'string',
111 'format' => 'datetime',
112 ],
113 'count' => [
114 'type' => 'integer',
115 'minimum' => 0,
116 'maximum' => 100,
117 ],
118 ],
119 ],
120 ],
121 ],
122 ]);
123
124 $invalidData = [
125 'text' => 'This is a very long text that exceeds the maximum length',
126 'count' => 150,
127 ];
128
129 $result = $this->validator->validate($invalidData, $schema);
130
131 $this->assertFalse($result);
132
133 $errors = $this->validator->validateWithErrors($invalidData, $schema);
134
135 $this->assertArrayHasKey('text', $errors);
136 $this->assertArrayHasKey('createdAt', $errors); // Missing required field
137 $this->assertArrayHasKey('count', $errors);
138 }
139
140 public function test_it_validates_nested_objects_deeply(): void
141 {
142 $schema = LexiconDocument::fromArray([
143 'lexicon' => 1,
144 'id' => 'app.test.nested',
145 'defs' => [
146 'main' => [
147 'type' => 'record',
148 'key' => 'tid',
149 'record' => [
150 'type' => 'object',
151 'properties' => [
152 'user' => [
153 'type' => 'object',
154 'required' => ['name'],
155 'properties' => [
156 'name' => ['type' => 'string'],
157 'profile' => [
158 'type' => 'object',
159 'properties' => [
160 'bio' => ['type' => 'string', 'maxLength' => 100],
161 'avatar' => [
162 'type' => 'object',
163 'properties' => [
164 'url' => ['type' => 'string', 'format' => 'uri'],
165 ],
166 ],
167 ],
168 ],
169 ],
170 ],
171 ],
172 ],
173 ],
174 ],
175 ]);
176
177 $validData = [
178 'user' => [
179 'name' => 'Alice',
180 'profile' => [
181 'bio' => 'Software developer',
182 'avatar' => [
183 'url' => 'https://example.com/avatar.jpg',
184 ],
185 ],
186 ],
187 ];
188
189 $result = $this->validator->validate($validData, $schema);
190
191 $this->assertTrue($result);
192 }
193
194 public function test_it_validates_with_blob_handler_integration(): void
195 {
196 Storage::fake('local');
197
198 $blobHandler = new BlobHandler('local');
199
200 $schema = LexiconDocument::fromArray([
201 'lexicon' => 1,
202 'id' => 'app.test.image',
203 'defs' => [
204 'main' => [
205 'type' => 'record',
206 'key' => 'tid',
207 'record' => [
208 'type' => 'object',
209 'required' => ['image'],
210 'properties' => [
211 'image' => [
212 'type' => 'blob',
213 'accept' => ['image/*'],
214 'maxSize' => 1024 * 1024,
215 ],
216 ],
217 ],
218 ],
219 ],
220 ]);
221
222 // Create a blob
223 $blob = $blobHandler->storeFromString('test image content', 'image/png');
224
225 // Validate with blob data
226 $validData = [
227 'image' => $blob->toArray(),
228 ];
229
230 $result = $this->validator->validate($validData, $schema);
231
232 $this->assertTrue($result);
233 }
234
235 public function test_it_handles_array_validation_with_constraints(): void
236 {
237 $schema = LexiconDocument::fromArray([
238 'lexicon' => 1,
239 'id' => 'app.test.list',
240 'defs' => [
241 'main' => [
242 'type' => 'record',
243 'key' => 'tid',
244 'record' => [
245 'type' => 'object',
246 'properties' => [
247 'tags' => [
248 'type' => 'array',
249 'minLength' => 1,
250 'maxLength' => 5,
251 'items' => [
252 'type' => 'string',
253 'maxLength' => 20,
254 ],
255 ],
256 ],
257 ],
258 ],
259 ],
260 ]);
261
262 $validData = [
263 'tags' => ['tag1', 'tag2', 'tag3'],
264 ];
265
266 $result = $this->validator->validate($validData, $schema);
267
268 $this->assertTrue($result);
269
270 // Invalid: tag item too long
271 $invalidData = [
272 'tags' => ['tag1', 'this is a very long tag that exceeds the maximum length of 20 characters'],
273 ];
274
275 $result = $this->validator->validate($invalidData, $schema);
276
277 $this->assertFalse($result);
278 }
279
280 public function test_it_validates_different_modes(): void
281 {
282 $schema = LexiconDocument::fromArray([
283 'lexicon' => 1,
284 'id' => 'app.test.strict',
285 'defs' => [
286 'main' => [
287 'type' => 'record',
288 'key' => 'tid',
289 'record' => [
290 'type' => 'object',
291 'required' => ['name'],
292 'properties' => [
293 'name' => ['type' => 'string'],
294 ],
295 ],
296 ],
297 ],
298 ]);
299
300 $dataWithUnknownField = [
301 'name' => 'Alice',
302 'unknownField' => 'value',
303 ];
304
305 // STRICT mode - should reject unknown fields
306 $this->validator->setMode(Validator::MODE_STRICT);
307 $result = $this->validator->validate($dataWithUnknownField, $schema);
308 $this->assertFalse($result);
309
310 // OPTIMISTIC mode - should allow unknown fields
311 $this->validator->setMode(Validator::MODE_OPTIMISTIC);
312 $result = $this->validator->validate($dataWithUnknownField, $schema);
313 $this->assertTrue($result);
314 }
315
316 public function test_it_validates_with_all_format_types(): void
317 {
318 $schema = LexiconDocument::fromArray([
319 'lexicon' => 1,
320 'id' => 'app.test.formats',
321 'defs' => [
322 'main' => [
323 'type' => 'record',
324 'key' => 'tid',
325 'record' => [
326 'type' => 'object',
327 'properties' => [
328 'datetime' => ['type' => 'string', 'format' => 'datetime'],
329 'uri' => ['type' => 'string', 'format' => 'uri'],
330 'atUri' => ['type' => 'string', 'format' => 'at-uri'],
331 'did' => ['type' => 'string', 'format' => 'did'],
332 'nsid' => ['type' => 'string', 'format' => 'nsid'],
333 'cid' => ['type' => 'string', 'format' => 'cid'],
334 ],
335 ],
336 ],
337 ],
338 ]);
339
340 $validData = [
341 'datetime' => '2024-01-01T00:00:00Z',
342 'uri' => 'https://example.com',
343 'atUri' => 'at://did:plc:abc123/app.bsky.feed.post/123',
344 'did' => 'did:plc:abc123',
345 'nsid' => 'app.bsky.feed.post',
346 'cid' => 'bafyreigabcdefghijklmnopqrstuvwxyz234567',
347 ];
348
349 $result = $this->validator->validate($validData, $schema);
350
351 $this->assertTrue($result);
352 }
353}