Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1<?php
2
3namespace SocialDept\AtpSchema\Tests\Unit\Generator;
4
5use Orchestra\Testbench\TestCase;
6use SocialDept\AtpSchema\Data\LexiconDocument;
7use SocialDept\AtpSchema\Exceptions\GenerationException;
8use SocialDept\AtpSchema\Generator\ClassGenerator;
9use SocialDept\AtpSchema\Parser\Nsid;
10
11class ClassGeneratorTest extends TestCase
12{
13 protected ClassGenerator $generator;
14
15 protected function setUp(): void
16 {
17 parent::setUp();
18
19 $this->generator = new ClassGenerator();
20 }
21
22 public function test_it_generates_simple_record_class(): void
23 {
24 $document = $this->createDocument('app.test.post', [
25 'type' => 'record',
26 'properties' => [
27 'text' => ['type' => 'string'],
28 'createdAt' => ['type' => 'string'],
29 ],
30 'required' => ['text', 'createdAt'],
31 ]);
32
33 $code = $this->generator->generate($document);
34
35 $this->assertStringContainsString('namespace App\\Lexicons\\App\\Test;', $code);
36 $this->assertStringContainsString('class Post extends Data', $code);
37 $this->assertStringContainsString('public static function getLexicon(): string', $code);
38 $this->assertStringContainsString("return 'app.test.post';", $code);
39 }
40
41 public function test_it_handles_optional_properties(): void
42 {
43 $document = $this->createDocument('app.test.post', [
44 'type' => 'record',
45 'properties' => [
46 'title' => ['type' => 'string'],
47 'subtitle' => ['type' => 'string'],
48 ],
49 'required' => ['title'],
50 ]);
51
52 $code = $this->generator->generate($document);
53
54 $this->assertStringContainsString('@property string $title', $code);
55 $this->assertStringContainsString('@property string|null $subtitle', $code);
56 $this->assertStringContainsString('class Post extends Data', $code);
57 }
58
59 public function test_it_generates_constructor_with_parameters(): void
60 {
61 $document = $this->createDocument('app.test.user', [
62 'type' => 'record',
63 'properties' => [
64 'name' => ['type' => 'string'],
65 'age' => ['type' => 'integer'],
66 ],
67 'required' => ['name'],
68 ]);
69
70 $code = $this->generator->generate($document);
71
72 $this->assertStringContainsString('@property string $name', $code);
73 $this->assertStringContainsString('@property int|null $age', $code);
74 $this->assertStringContainsString('class User extends Data', $code);
75 }
76
77 public function test_it_generates_from_array_method(): void
78 {
79 $document = $this->createDocument('app.test.post', [
80 'type' => 'record',
81 'record' => [
82 'properties' => [
83 'text' => ['type' => 'string'],
84 ],
85 'required' => ['text'],
86 ],
87 ]);
88
89 $code = $this->generator->generate($document);
90
91 $this->assertStringContainsString('public static function fromArray(array $data): static', $code);
92 $this->assertStringContainsString('return new static(', $code);
93 $this->assertStringContainsString("text: \$data['text']", $code);
94 }
95
96 public function test_it_includes_use_statements(): void
97 {
98 $document = $this->createDocument('app.test.post', [
99 'type' => 'record',
100 'properties' => [
101 'text' => ['type' => 'string'],
102 ],
103 'required' => ['text'],
104 ]);
105
106 $code = $this->generator->generate($document);
107
108 $this->assertStringContainsString('use SocialDept\\AtpSchema\\Data\\Data;', $code);
109 }
110
111 public function test_it_includes_ref_use_statements(): void
112 {
113 $document = $this->createDocument('app.test.post', [
114 'type' => 'record',
115 'properties' => [
116 'author' => [
117 'type' => 'ref',
118 'ref' => 'app.test.author',
119 ],
120 ],
121 'required' => ['author'],
122 ]);
123
124 $code = $this->generator->generate($document);
125
126 $this->assertStringContainsString('class Post extends Data', $code);
127 $this->assertStringContainsString('public static function fromArray(array $data): static', $code);
128 }
129
130 public function test_it_includes_blob_use_statements(): void
131 {
132 $document = $this->createDocument('app.test.post', [
133 'type' => 'record',
134 'properties' => [
135 'image' => ['type' => 'blob'],
136 ],
137 'required' => [],
138 ]);
139
140 $code = $this->generator->generate($document);
141
142 $this->assertStringContainsString('@property', $code);
143 $this->assertStringContainsString('class Post extends Data', $code);
144 }
145
146 public function test_it_generates_class_docblock(): void
147 {
148 $document = $this->createDocument('app.test.post', [
149 'type' => 'record',
150 'description' => 'A social media post',
151 'properties' => ['text' => ['type' => 'string']],
152 'required' => ['text'],
153 ], 'A social media post');
154
155 $code = $this->generator->generate($document);
156
157 $this->assertStringContainsString('/**', $code);
158 $this->assertStringContainsString('* A social media post', $code);
159 $this->assertStringContainsString('* Lexicon: app.test.post', $code);
160 $this->assertStringContainsString('* Type: record', $code);
161 }
162
163 public function test_it_generates_property_docblocks(): void
164 {
165 $document = $this->createDocument('app.test.post', [
166 'type' => 'record',
167 'properties' => [
168 'text' => [
169 'type' => 'string',
170 'description' => 'The post content',
171 ],
172 ],
173 'required' => ['text'],
174 ]);
175
176 $code = $this->generator->generate($document);
177
178 $this->assertStringContainsString('@property string $text', $code);
179 }
180
181 public function test_it_throws_when_no_main_definition(): void
182 {
183 $document = new LexiconDocument(
184 lexicon: 1,
185 id: Nsid::parse('app.test.empty'),
186 defs: [],
187 description: null,
188 source: null,
189 raw: []
190 );
191
192 $this->expectException(GenerationException::class);
193 $this->expectExceptionMessage('No main definition found');
194
195 $this->generator->generate($document);
196 }
197
198 public function test_it_throws_for_non_record_types(): void
199 {
200 $document = $this->createDocument('app.test.query', [
201 'type' => 'query',
202 ]);
203
204 $this->expectException(GenerationException::class);
205 $this->expectExceptionMessage('Can only generate classes for record and object types');
206
207 $this->generator->generate($document);
208 }
209
210 public function test_it_handles_empty_properties(): void
211 {
212 $document = $this->createDocument('app.test.empty', [
213 'type' => 'record',
214 'properties' => [],
215 'required' => [],
216 ]);
217
218 $code = $this->generator->generate($document);
219
220 $this->assertStringContainsString('class Empty', $code);
221 $this->assertStringContainsString('public static function fromArray(array $data): static', $code);
222 $this->assertStringContainsString('return new static();', $code);
223 }
224
225 public function test_it_handles_array_of_refs(): void
226 {
227 $document = $this->createDocument('app.test.feed', [
228 'type' => 'record',
229 'properties' => [
230 'posts' => [
231 'type' => 'array',
232 'items' => [
233 'type' => 'ref',
234 'ref' => 'app.test.post',
235 ],
236 ],
237 ],
238 'required' => ['posts'],
239 ]);
240
241 $code = $this->generator->generate($document);
242
243 $this->assertStringContainsString('class Feed extends Data', $code);
244 $this->assertStringContainsString('public static function fromArray(array $data): static', $code);
245 }
246
247 public function test_it_generates_object_type(): void
248 {
249 $document = $this->createDocument('app.test.config', [
250 'type' => 'object',
251 'properties' => [
252 'enabled' => ['type' => 'boolean'],
253 ],
254 'required' => ['enabled'],
255 ]);
256
257 $code = $this->generator->generate($document);
258
259 $this->assertStringContainsString('class Config', $code);
260 $this->assertStringContainsString('public readonly bool $enabled', $code);
261 }
262
263 public function test_it_sorts_use_statements(): void
264 {
265 $document = $this->createDocument('app.test.complex', [
266 'type' => 'record',
267 'properties' => [
268 'image' => ['type' => 'blob'],
269 'author' => [
270 'type' => 'ref',
271 'ref' => 'app.test.author',
272 ],
273 ],
274 'required' => [],
275 ]);
276
277 $code = $this->generator->generate($document);
278
279 $basePos = strpos($code, 'use SocialDept\\AtpSchema\\Data\\Data;');
280
281 $this->assertNotFalse($basePos);
282 $this->assertStringContainsString('class Complex extends Data', $code);
283 }
284
285 public function test_it_provides_accessor_methods(): void
286 {
287 $naming = $this->generator->getNaming();
288 $typeMapper = $this->generator->getTypeMapper();
289 $renderer = $this->generator->getRenderer();
290
291 $this->assertInstanceOf(\SocialDept\AtpSchema\Generator\NamingConverter::class, $naming);
292 $this->assertInstanceOf(\SocialDept\AtpSchema\Generator\TypeMapper::class, $typeMapper);
293 $this->assertInstanceOf(\SocialDept\AtpSchema\Generator\StubRenderer::class, $renderer);
294 }
295
296 /**
297 * Helper to create a test document.
298 *
299 * @param array<string, mixed> $mainDef
300 */
301 protected function createDocument(string $nsid, array $mainDef, ?string $description = null): LexiconDocument
302 {
303 return new LexiconDocument(
304 lexicon: 1,
305 id: Nsid::parse($nsid),
306 defs: ['main' => $mainDef],
307 description: $description,
308 source: null,
309 raw: [
310 'lexicon' => 1,
311 'id' => $nsid,
312 'defs' => ['main' => $mainDef],
313 ]
314 );
315 }
316}