Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1<?php
2
3namespace SocialDept\AtpSchema\Tests\Unit\Services;
4
5use Orchestra\Testbench\TestCase;
6use SocialDept\AtpSchema\Contracts\LexiconRegistry;
7use SocialDept\AtpSchema\Data\LexiconDocument;
8use SocialDept\AtpSchema\Exceptions\RecordValidationException;
9use SocialDept\AtpSchema\Parser\Nsid;
10use SocialDept\AtpSchema\Services\UnionResolver;
11
12class UnionResolverTest extends TestCase
13{
14 protected UnionResolver $resolver;
15
16 protected function setUp(): void
17 {
18 parent::setUp();
19
20 $this->resolver = new UnionResolver();
21 }
22
23 public function test_it_resolves_discriminated_union(): void
24 {
25 $data = ['$type' => 'app.bsky.feed.post'];
26
27 $unionDef = [
28 'type' => 'union',
29 'refs' => ['app.bsky.feed.post', 'app.bsky.feed.repost'],
30 'closed' => true,
31 ];
32
33 $type = $this->resolver->resolve($data, $unionDef);
34
35 $this->assertEquals('app.bsky.feed.post', $type);
36 }
37
38 public function test_it_returns_null_for_open_union(): void
39 {
40 $data = ['text' => 'Hello'];
41
42 $unionDef = [
43 'type' => 'union',
44 'refs' => ['app.bsky.feed.post'],
45 'closed' => false,
46 ];
47
48 $type = $this->resolver->resolve($data, $unionDef);
49
50 $this->assertNull($type);
51 }
52
53 public function test_it_throws_exception_for_discriminated_union_without_type(): void
54 {
55 $this->expectException(RecordValidationException::class);
56
57 $data = ['text' => 'Hello'];
58
59 $unionDef = [
60 'type' => 'union',
61 'refs' => ['app.bsky.feed.post'],
62 'closed' => true,
63 ];
64
65 $this->resolver->resolve($data, $unionDef);
66 }
67
68 public function test_it_throws_exception_for_invalid_type(): void
69 {
70 $this->expectException(RecordValidationException::class);
71
72 $data = ['$type' => 'app.bsky.feed.invalid'];
73
74 $unionDef = [
75 'type' => 'union',
76 'refs' => ['app.bsky.feed.post', 'app.bsky.feed.repost'],
77 'closed' => true,
78 ];
79
80 $this->resolver->resolve($data, $unionDef);
81 }
82
83 public function test_it_throws_exception_for_non_object_discriminated_union(): void
84 {
85 $this->expectException(RecordValidationException::class);
86
87 $unionDef = [
88 'type' => 'union',
89 'refs' => ['app.bsky.feed.post'],
90 'closed' => true,
91 ];
92
93 $this->resolver->resolve('not an object', $unionDef);
94 }
95
96 public function test_it_checks_if_data_matches_type(): void
97 {
98 $data = ['$type' => 'app.bsky.feed.post'];
99
100 $unionDef = [
101 'type' => 'union',
102 'refs' => ['app.bsky.feed.post', 'app.bsky.feed.repost'],
103 'closed' => true,
104 ];
105
106 $this->assertTrue($this->resolver->matches($data, 'app.bsky.feed.post', $unionDef));
107 $this->assertFalse($this->resolver->matches($data, 'app.bsky.feed.repost', $unionDef));
108 }
109
110 public function test_it_returns_false_for_invalid_data_when_checking_match(): void
111 {
112 $data = ['text' => 'Hello'];
113
114 $unionDef = [
115 'type' => 'union',
116 'refs' => ['app.bsky.feed.post'],
117 'closed' => true,
118 ];
119
120 $this->assertFalse($this->resolver->matches($data, 'app.bsky.feed.post', $unionDef));
121 }
122
123 public function test_it_returns_false_for_open_union_when_checking_match(): void
124 {
125 $data = ['$type' => 'app.bsky.feed.post'];
126
127 $unionDef = [
128 'type' => 'union',
129 'refs' => ['app.bsky.feed.post'],
130 'closed' => false,
131 ];
132
133 $this->assertFalse($this->resolver->matches($data, 'app.bsky.feed.post', $unionDef));
134 }
135
136 public function test_it_gets_type_definition_with_registry(): void
137 {
138 // Create a simple registry implementation
139 $registry = new class () implements LexiconRegistry {
140 public function register(LexiconDocument $document): void
141 {
142 }
143
144 public function get(string $nsid): ?LexiconDocument
145 {
146 return new LexiconDocument(
147 1,
148 Nsid::parse('app.bsky.feed.post'),
149 ['main' => ['type' => 'record']]
150 );
151 }
152
153 public function has(string $nsid): bool
154 {
155 return true;
156 }
157
158 public function all(): array
159 {
160 return [];
161 }
162
163 public function clear(): void
164 {
165 }
166 };
167
168 $this->resolver->setRegistry($registry);
169
170 $data = ['$type' => 'app.bsky.feed.post'];
171
172 $unionDef = [
173 'type' => 'union',
174 'refs' => ['app.bsky.feed.post'],
175 'closed' => true,
176 ];
177
178 $result = $this->resolver->getTypeDefinition($data, $unionDef);
179
180 $this->assertInstanceOf(LexiconDocument::class, $result);
181 $this->assertEquals('app.bsky.feed.post', $result->getNsid());
182 }
183
184 public function test_it_returns_null_for_type_definition_without_registry(): void
185 {
186 $data = ['$type' => 'app.bsky.feed.post'];
187
188 $unionDef = [
189 'type' => 'union',
190 'refs' => ['app.bsky.feed.post'],
191 'closed' => true,
192 ];
193
194 $result = $this->resolver->getTypeDefinition($data, $unionDef);
195
196 $this->assertNull($result);
197 }
198
199 public function test_it_returns_null_for_type_definition_with_open_union(): void
200 {
201 // Create a simple registry implementation
202 $registry = new class () implements LexiconRegistry {
203 public function register(LexiconDocument $document): void
204 {
205 }
206
207 public function get(string $nsid): ?LexiconDocument
208 {
209 return null;
210 }
211
212 public function has(string $nsid): bool
213 {
214 return false;
215 }
216
217 public function all(): array
218 {
219 return [];
220 }
221
222 public function clear(): void
223 {
224 }
225 };
226
227 $this->resolver->setRegistry($registry);
228
229 $data = ['text' => 'Hello'];
230
231 $unionDef = [
232 'type' => 'union',
233 'refs' => ['app.bsky.feed.post'],
234 'closed' => false,
235 ];
236
237 $result = $this->resolver->getTypeDefinition($data, $unionDef);
238
239 $this->assertNull($result);
240 }
241
242 public function test_it_validates_discriminated_union(): void
243 {
244 $data = ['$type' => 'app.bsky.feed.post'];
245 $refs = ['app.bsky.feed.post', 'app.bsky.feed.repost'];
246
247 $this->resolver->validateDiscriminated($data, $refs);
248
249 $this->assertTrue(true); // No exception thrown
250 }
251
252 public function test_it_throws_exception_when_validating_non_object(): void
253 {
254 $this->expectException(RecordValidationException::class);
255
256 $this->resolver->validateDiscriminated('not an object', ['app.bsky.feed.post']);
257 }
258
259 public function test_it_throws_exception_when_validating_without_type(): void
260 {
261 $this->expectException(RecordValidationException::class);
262
263 $this->resolver->validateDiscriminated(['text' => 'Hello'], ['app.bsky.feed.post']);
264 }
265
266 public function test_it_throws_exception_when_validating_invalid_type(): void
267 {
268 $this->expectException(RecordValidationException::class);
269
270 $data = ['$type' => 'app.bsky.feed.invalid'];
271
272 $this->resolver->validateDiscriminated($data, ['app.bsky.feed.post']);
273 }
274
275 public function test_it_extracts_type_from_data(): void
276 {
277 $data = ['$type' => 'app.bsky.feed.post', 'text' => 'Hello'];
278
279 $type = $this->resolver->extractType($data);
280
281 $this->assertEquals('app.bsky.feed.post', $type);
282 }
283
284 public function test_it_returns_null_when_extracting_type_from_non_object(): void
285 {
286 $type = $this->resolver->extractType('not an object');
287
288 $this->assertNull($type);
289 }
290
291 public function test_it_returns_null_when_extracting_type_without_type_field(): void
292 {
293 $data = ['text' => 'Hello'];
294
295 $type = $this->resolver->extractType($data);
296
297 $this->assertNull($type);
298 }
299
300 public function test_it_creates_discriminated_union_data(): void
301 {
302 $data = $this->resolver->createDiscriminated('app.bsky.feed.post', [
303 'text' => 'Hello',
304 'createdAt' => '2024-01-01T00:00:00Z',
305 ]);
306
307 $this->assertEquals([
308 '$type' => 'app.bsky.feed.post',
309 'text' => 'Hello',
310 'createdAt' => '2024-01-01T00:00:00Z',
311 ], $data);
312 }
313
314 public function test_it_checks_if_union_is_closed(): void
315 {
316 $closedUnion = ['closed' => true];
317 $openUnion = ['closed' => false];
318 $defaultUnion = [];
319
320 $this->assertTrue($this->resolver->isClosed($closedUnion));
321 $this->assertFalse($this->resolver->isClosed($openUnion));
322 $this->assertFalse($this->resolver->isClosed($defaultUnion));
323 }
324
325 public function test_it_gets_union_types(): void
326 {
327 $unionDef = [
328 'type' => 'union',
329 'refs' => ['app.bsky.feed.post', 'app.bsky.feed.repost'],
330 ];
331
332 $types = $this->resolver->getTypes($unionDef);
333
334 $this->assertEquals(['app.bsky.feed.post', 'app.bsky.feed.repost'], $types);
335 }
336
337 public function test_it_returns_empty_array_for_union_without_refs(): void
338 {
339 $unionDef = ['type' => 'union'];
340
341 $types = $this->resolver->getTypes($unionDef);
342
343 $this->assertEquals([], $types);
344 }
345
346 public function test_it_allows_setting_registry(): void
347 {
348 // Create a simple registry implementation
349 $registry = new class () implements LexiconRegistry {
350 public function register(LexiconDocument $document): void
351 {
352 }
353
354 public function get(string $nsid): ?LexiconDocument
355 {
356 return null;
357 }
358
359 public function has(string $nsid): bool
360 {
361 return false;
362 }
363
364 public function all(): array
365 {
366 return [];
367 }
368
369 public function clear(): void
370 {
371 }
372 };
373
374 $result = $this->resolver->setRegistry($registry);
375
376 $this->assertSame($this->resolver, $result);
377 }
378
379 public function test_it_handles_multiple_types_in_discriminated_union(): void
380 {
381 $refs = [
382 'app.bsky.feed.post',
383 'app.bsky.feed.repost',
384 'app.bsky.feed.like',
385 ];
386
387 $unionDef = [
388 'type' => 'union',
389 'refs' => $refs,
390 'closed' => true,
391 ];
392
393 foreach ($refs as $ref) {
394 $data = ['$type' => $ref];
395 $type = $this->resolver->resolve($data, $unionDef);
396 $this->assertEquals($ref, $type);
397 }
398 }
399
400 public function test_it_preserves_data_when_creating_discriminated_union(): void
401 {
402 $originalData = [
403 'field1' => 'value1',
404 'field2' => 123,
405 'field3' => ['nested' => 'data'],
406 ];
407
408 $data = $this->resolver->createDiscriminated('app.bsky.feed.post', $originalData);
409
410 $this->assertEquals('app.bsky.feed.post', $data['$type']);
411 $this->assertEquals('value1', $data['field1']);
412 $this->assertEquals(123, $data['field2']);
413 $this->assertEquals(['nested' => 'data'], $data['field3']);
414 }
415
416 public function test_it_overwrites_existing_type_when_creating_discriminated_union(): void
417 {
418 $originalData = [
419 '$type' => 'old.type',
420 'text' => 'Hello',
421 ];
422
423 $data = $this->resolver->createDiscriminated('app.bsky.feed.post', $originalData);
424
425 $this->assertEquals('app.bsky.feed.post', $data['$type']);
426 $this->assertEquals('Hello', $data['text']);
427 }
428}