@workos/oagen-emitters 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +8 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +10 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +11893 -3226
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/go.md +338 -0
  10. package/docs/sdk-architecture/php.md +315 -0
  11. package/docs/sdk-architecture/python.md +511 -0
  12. package/oagen.config.ts +298 -2
  13. package/package.json +9 -5
  14. package/scripts/generate-php.js +13 -0
  15. package/scripts/git-push-with-published-oagen.sh +21 -0
  16. package/smoke/sdk-go.ts +116 -42
  17. package/smoke/sdk-php.ts +28 -26
  18. package/smoke/sdk-python.ts +5 -2
  19. package/src/go/client.ts +141 -0
  20. package/src/go/enums.ts +196 -0
  21. package/src/go/fixtures.ts +212 -0
  22. package/src/go/index.ts +81 -0
  23. package/src/go/manifest.ts +36 -0
  24. package/src/go/models.ts +254 -0
  25. package/src/go/naming.ts +191 -0
  26. package/src/go/resources.ts +827 -0
  27. package/src/go/tests.ts +751 -0
  28. package/src/go/type-map.ts +82 -0
  29. package/src/go/wrappers.ts +261 -0
  30. package/src/index.ts +3 -0
  31. package/src/node/client.ts +78 -115
  32. package/src/node/enums.ts +9 -0
  33. package/src/node/errors.ts +37 -232
  34. package/src/node/field-plan.ts +726 -0
  35. package/src/node/fixtures.ts +9 -1
  36. package/src/node/index.ts +2 -9
  37. package/src/node/models.ts +178 -21
  38. package/src/node/naming.ts +49 -111
  39. package/src/node/resources.ts +374 -364
  40. package/src/node/sdk-errors.ts +41 -0
  41. package/src/node/tests.ts +32 -12
  42. package/src/node/type-map.ts +4 -2
  43. package/src/node/utils.ts +13 -71
  44. package/src/node/wrappers.ts +151 -0
  45. package/src/php/client.ts +171 -0
  46. package/src/php/enums.ts +67 -0
  47. package/src/php/errors.ts +9 -0
  48. package/src/php/fixtures.ts +181 -0
  49. package/src/php/index.ts +96 -0
  50. package/src/php/manifest.ts +36 -0
  51. package/src/php/models.ts +310 -0
  52. package/src/php/naming.ts +298 -0
  53. package/src/php/resources.ts +561 -0
  54. package/src/php/tests.ts +533 -0
  55. package/src/php/type-map.ts +90 -0
  56. package/src/php/utils.ts +18 -0
  57. package/src/php/wrappers.ts +151 -0
  58. package/src/python/client.ts +337 -0
  59. package/src/python/enums.ts +313 -0
  60. package/src/python/fixtures.ts +196 -0
  61. package/src/python/index.ts +95 -0
  62. package/src/python/manifest.ts +38 -0
  63. package/src/python/models.ts +688 -0
  64. package/src/python/naming.ts +209 -0
  65. package/src/python/resources.ts +1322 -0
  66. package/src/python/tests.ts +1335 -0
  67. package/src/python/type-map.ts +93 -0
  68. package/src/python/wrappers.ts +191 -0
  69. package/src/shared/model-utils.ts +255 -0
  70. package/src/shared/naming-utils.ts +107 -0
  71. package/src/shared/non-spec-services.ts +54 -0
  72. package/src/shared/resolved-ops.ts +109 -0
  73. package/src/shared/wrapper-utils.ts +59 -0
  74. package/test/go/client.test.ts +92 -0
  75. package/test/go/enums.test.ts +132 -0
  76. package/test/go/errors.test.ts +9 -0
  77. package/test/go/models.test.ts +265 -0
  78. package/test/go/resources.test.ts +408 -0
  79. package/test/go/tests.test.ts +143 -0
  80. package/test/node/client.test.ts +18 -12
  81. package/test/node/enums.test.ts +2 -0
  82. package/test/node/errors.test.ts +2 -41
  83. package/test/node/models.test.ts +2 -0
  84. package/test/node/naming.test.ts +23 -0
  85. package/test/node/resources.test.ts +99 -69
  86. package/test/node/serializers.test.ts +3 -1
  87. package/test/node/type-map.test.ts +11 -0
  88. package/test/php/client.test.ts +94 -0
  89. package/test/php/enums.test.ts +173 -0
  90. package/test/php/errors.test.ts +9 -0
  91. package/test/php/models.test.ts +497 -0
  92. package/test/php/resources.test.ts +644 -0
  93. package/test/php/tests.test.ts +118 -0
  94. package/test/python/client.test.ts +200 -0
  95. package/test/python/enums.test.ts +228 -0
  96. package/test/python/errors.test.ts +16 -0
  97. package/test/python/manifest.test.ts +74 -0
  98. package/test/python/models.test.ts +716 -0
  99. package/test/python/resources.test.ts +617 -0
  100. package/test/python/tests.test.ts +202 -0
  101. package/src/node/common.ts +0 -273
  102. package/src/node/config.ts +0 -71
  103. package/src/node/serializers.ts +0 -746
@@ -0,0 +1,497 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { EmitterContext, ApiSpec, Model } from '@workos/oagen';
3
+ import { defaultSdkBehavior } from '@workos/oagen';
4
+ import { generateModels } from '../../src/php/models.js';
5
+
6
+ const emptySpec: ApiSpec = {
7
+ name: 'Test',
8
+ version: '1.0.0',
9
+ baseUrl: '',
10
+ services: [],
11
+ models: [],
12
+ enums: [],
13
+ sdk: defaultSdkBehavior(),
14
+ };
15
+
16
+ const ctx: EmitterContext = {
17
+ namespace: 'workos',
18
+ namespacePascal: 'WorkOS',
19
+ spec: emptySpec,
20
+ };
21
+
22
+ /** Find the model file for a given class name (skipping the trait file). */
23
+ function findModel(result: ReturnType<typeof generateModels>, name: string) {
24
+ return result.find((f) => f.path === `lib/Resource/${name}.php`);
25
+ }
26
+
27
+ describe('generateModels', () => {
28
+ it('returns empty array for no models', () => {
29
+ expect(generateModels([], ctx)).toEqual([]);
30
+ });
31
+
32
+ it('generates a readonly class with constructor promotion', () => {
33
+ const models: Model[] = [
34
+ {
35
+ name: 'Organization',
36
+ fields: [
37
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
38
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
39
+ { name: 'slug', type: { kind: 'primitive', type: 'string' }, required: false },
40
+ ],
41
+ },
42
+ ];
43
+
44
+ const specWithModels = { ...emptySpec, models };
45
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
46
+
47
+ const file = findModel(result, 'Organization');
48
+ expect(file).toBeDefined();
49
+ expect(file!.content).toContain('readonly class Organization');
50
+ expect(file!.content).toContain('public string $id,');
51
+ expect(file!.content).toContain('public string $name,');
52
+ expect(file!.content).toContain('public ?string $slug = null,');
53
+ expect(file!.content).toContain('public static function fromArray(array $data): self');
54
+ expect(file!.content).toContain('public function toArray(): array');
55
+ expect(file!.content).toContain('implements \\JsonSerializable');
56
+ expect(file!.content).toContain('use JsonSerializableTrait;');
57
+ });
58
+
59
+ it('generates JsonSerializableTrait file', () => {
60
+ const models: Model[] = [
61
+ {
62
+ name: 'Item',
63
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
64
+ },
65
+ ];
66
+
67
+ const specWithModels = { ...emptySpec, models };
68
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
69
+
70
+ const trait = result.find((f) => f.path === 'lib/Resource/JsonSerializableTrait.php');
71
+ expect(trait).toBeDefined();
72
+ expect(trait!.content).toContain('trait JsonSerializableTrait');
73
+ expect(trait!.content).toContain('return $this->toArray();');
74
+ });
75
+
76
+ it('handles required date-time fields without fallback', () => {
77
+ const models: Model[] = [
78
+ {
79
+ name: 'Event',
80
+ fields: [
81
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
82
+ { name: 'created_at', type: { kind: 'primitive', type: 'string', format: 'date-time' }, required: true },
83
+ ],
84
+ },
85
+ ];
86
+
87
+ const specWithModels = { ...emptySpec, models };
88
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
89
+
90
+ const file = findModel(result, 'Event');
91
+ expect(file).toBeDefined();
92
+ expect(file!.content).toContain('\\DateTimeImmutable $createdAt');
93
+ expect(file!.content).toContain("new \\DateTimeImmutable($data['created_at'])");
94
+ expect(file!.content).not.toContain("?? 'now'");
95
+ });
96
+
97
+ it('handles optional date-time fields with isset guard and no fallback', () => {
98
+ const models: Model[] = [
99
+ {
100
+ name: 'Session',
101
+ fields: [
102
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
103
+ {
104
+ name: 'last_sign_in_at',
105
+ type: { kind: 'primitive', type: 'string', format: 'date-time' },
106
+ required: false,
107
+ },
108
+ ],
109
+ },
110
+ ];
111
+
112
+ const specWithModels = { ...emptySpec, models };
113
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
114
+
115
+ const file = findModel(result, 'Session');
116
+ expect(file).toBeDefined();
117
+ expect(file!.content).toContain(
118
+ "isset($data['last_sign_in_at']) ? new \\DateTimeImmutable($data['last_sign_in_at']) : null",
119
+ );
120
+ expect(file!.content).not.toContain("?? 'now'");
121
+ });
122
+
123
+ it('handles enum fields with ::from() not ::tryFrom()', () => {
124
+ const models: Model[] = [
125
+ {
126
+ name: 'Connection',
127
+ fields: [
128
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
129
+ { name: 'status', type: { kind: 'enum', name: 'ConnectionStatus' }, required: true },
130
+ ],
131
+ },
132
+ ];
133
+
134
+ const specWithModels = { ...emptySpec, models };
135
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
136
+
137
+ const file = findModel(result, 'Connection');
138
+ expect(file).toBeDefined();
139
+ expect(file!.content).toContain("ConnectionStatus::from($data['status'])");
140
+ expect(file!.content).not.toContain('tryFrom');
141
+ });
142
+
143
+ it('handles model references in fromArray', () => {
144
+ const models: Model[] = [
145
+ {
146
+ name: 'User',
147
+ fields: [
148
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
149
+ { name: 'profile', type: { kind: 'model', name: 'Profile' }, required: false },
150
+ ],
151
+ },
152
+ {
153
+ name: 'Profile',
154
+ fields: [{ name: 'bio', type: { kind: 'primitive', type: 'string' }, required: true }],
155
+ },
156
+ ];
157
+
158
+ const specWithModels = { ...emptySpec, models };
159
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
160
+
161
+ const userFile = findModel(result, 'User');
162
+ expect(userFile).toBeDefined();
163
+ expect(userFile!.content).toContain('Profile::fromArray');
164
+ });
165
+
166
+ it('handles required nullable model fields with isset guard', () => {
167
+ const models: Model[] = [
168
+ {
169
+ name: 'FeatureFlag',
170
+ fields: [
171
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
172
+ {
173
+ name: 'owner',
174
+ type: { kind: 'nullable', inner: { kind: 'model', name: 'Owner' } },
175
+ required: true,
176
+ },
177
+ ],
178
+ },
179
+ {
180
+ name: 'Owner',
181
+ fields: [{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true }],
182
+ },
183
+ ];
184
+
185
+ const specWithModels = { ...emptySpec, models };
186
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
187
+
188
+ const flagFile = findModel(result, 'FeatureFlag');
189
+ expect(flagFile).toBeDefined();
190
+ expect(flagFile!.content).toContain("isset($data['owner']) ? Owner::fromArray($data['owner']) : null");
191
+ });
192
+
193
+ it('skips list wrapper models', () => {
194
+ const models: Model[] = [
195
+ {
196
+ name: 'OrganizationList',
197
+ fields: [
198
+ {
199
+ name: 'data',
200
+ type: { kind: 'array', items: { kind: 'model', name: 'Organization' } },
201
+ required: true,
202
+ },
203
+ {
204
+ name: 'list_metadata',
205
+ type: { kind: 'model', name: 'ListMetadata' },
206
+ required: true,
207
+ },
208
+ ],
209
+ },
210
+ ];
211
+
212
+ const specWithModels = { ...emptySpec, models };
213
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
214
+
215
+ // Only the trait file should be present — no model files
216
+ const modelFiles = result.filter((f) => !f.path.includes('Trait'));
217
+ expect(modelFiles).toHaveLength(0);
218
+ });
219
+
220
+ it('skips prefixed list metadata models like ApiKeyListListMetadata', () => {
221
+ const models: Model[] = [
222
+ {
223
+ name: 'ApiKeyListListMetadata',
224
+ fields: [
225
+ { name: 'after', type: { kind: 'primitive', type: 'string' }, required: false },
226
+ { name: 'before', type: { kind: 'primitive', type: 'string' }, required: false },
227
+ ],
228
+ },
229
+ ];
230
+
231
+ const specWithModels = { ...emptySpec, models };
232
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
233
+
234
+ // Only the trait file should be present — no model files
235
+ const modelFiles = result.filter((f) => !f.path.includes('Trait'));
236
+ expect(modelFiles).toHaveLength(0);
237
+ });
238
+
239
+ it('generates correct namespace', () => {
240
+ const models: Model[] = [
241
+ {
242
+ name: 'Item',
243
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
244
+ },
245
+ ];
246
+
247
+ const specWithModels = { ...emptySpec, models };
248
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
249
+
250
+ const file = findModel(result, 'Item');
251
+ expect(file).toBeDefined();
252
+ expect(file!.content).toContain('namespace WorkOS\\Resource;');
253
+ });
254
+
255
+ it('adds PHPDoc @deprecated for deprecated fields', () => {
256
+ const models: Model[] = [
257
+ {
258
+ name: 'Connection',
259
+ fields: [
260
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
261
+ { name: 'old_field', type: { kind: 'primitive', type: 'string' }, required: false, deprecated: true },
262
+ ],
263
+ },
264
+ ];
265
+
266
+ const specWithModels = { ...emptySpec, models };
267
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
268
+
269
+ const file = findModel(result, 'Connection');
270
+ expect(file).toBeDefined();
271
+ expect(file!.content).toContain('/** @deprecated */');
272
+ // The deprecated PHPDoc should come before the property
273
+ const lines = file!.content.split('\n');
274
+ const deprecatedIdx = lines.findIndex((l: string) => l.includes('@deprecated'));
275
+ const propertyIdx = lines.findIndex((l: string) => l.includes('$oldField'));
276
+ expect(deprecatedIdx).toBeGreaterThan(-1);
277
+ expect(propertyIdx).toBeGreaterThan(-1);
278
+ expect(deprecatedIdx).toBeLessThan(propertyIdx);
279
+ });
280
+
281
+ it('adds PHPDoc with description and @deprecated for fields', () => {
282
+ const models: Model[] = [
283
+ {
284
+ name: 'Connection',
285
+ fields: [
286
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
287
+ {
288
+ name: 'legacy_name',
289
+ type: { kind: 'primitive', type: 'string' },
290
+ required: false,
291
+ description: 'Use name instead',
292
+ deprecated: true,
293
+ },
294
+ ],
295
+ },
296
+ ];
297
+
298
+ const specWithModels = { ...emptySpec, models };
299
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
300
+
301
+ const file = findModel(result, 'Connection');
302
+ expect(file).toBeDefined();
303
+ expect(file!.content).toContain('Use name instead');
304
+ expect(file!.content).toContain('@deprecated');
305
+ });
306
+
307
+ it('adds @var PHPDoc for array-typed properties', () => {
308
+ const models: Model[] = [
309
+ {
310
+ name: 'Connection',
311
+ fields: [
312
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
313
+ {
314
+ name: 'domains',
315
+ type: { kind: 'array', items: { kind: 'model', name: 'ConnectionDomain' } },
316
+ required: true,
317
+ },
318
+ ],
319
+ },
320
+ {
321
+ name: 'ConnectionDomain',
322
+ fields: [{ name: 'domain', type: { kind: 'primitive', type: 'string' }, required: true }],
323
+ },
324
+ ];
325
+
326
+ const specWithModels = { ...emptySpec, models };
327
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
328
+
329
+ const file = findModel(result, 'Connection');
330
+ expect(file).toBeDefined();
331
+ expect(file!.content).toContain('@var array<\\WorkOS\\Resource\\ConnectionDomain>');
332
+ });
333
+
334
+ it('adds @var PHPDoc for nullable array-typed properties', () => {
335
+ const models: Model[] = [
336
+ {
337
+ name: 'User',
338
+ fields: [
339
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
340
+ {
341
+ name: 'roles',
342
+ type: { kind: 'nullable', inner: { kind: 'array', items: { kind: 'primitive', type: 'string' } } },
343
+ required: false,
344
+ },
345
+ ],
346
+ },
347
+ ];
348
+
349
+ const specWithModels = { ...emptySpec, models };
350
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
351
+
352
+ const file = findModel(result, 'User');
353
+ expect(file).toBeDefined();
354
+ expect(file!.content).toContain('@var array<string>|null');
355
+ });
356
+
357
+ it('hydrates optional array-of-model fields via array_map in fromArray', () => {
358
+ const models: Model[] = [
359
+ {
360
+ name: 'DirectoryUser',
361
+ fields: [
362
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
363
+ {
364
+ name: 'emails',
365
+ type: { kind: 'array', items: { kind: 'model', name: 'DirectoryUserEmail' } },
366
+ required: false,
367
+ },
368
+ ],
369
+ },
370
+ {
371
+ name: 'DirectoryUserEmail',
372
+ fields: [{ name: 'value', type: { kind: 'primitive', type: 'string' }, required: true }],
373
+ },
374
+ ];
375
+
376
+ const specWithModels = { ...emptySpec, models };
377
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
378
+
379
+ const file = findModel(result, 'DirectoryUser');
380
+ expect(file).toBeDefined();
381
+ // fromArray should use isset guard + array_map for optional array-of-model
382
+ expect(file!.content).toContain(
383
+ "isset($data['emails']) ? array_map(fn ($item) => DirectoryUserEmail::fromArray($item), $data['emails']) : null",
384
+ );
385
+ // toArray should call ->toArray() on each item
386
+ expect(file!.content).toContain(
387
+ '$this->emails !== null ? array_map(fn ($item) => $item->toArray(), $this->emails) : null',
388
+ );
389
+ });
390
+
391
+ it('hydrates optional array-of-enum fields via array_map in fromArray', () => {
392
+ const models: Model[] = [
393
+ {
394
+ name: 'Profile',
395
+ fields: [
396
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
397
+ {
398
+ name: 'roles',
399
+ type: { kind: 'array', items: { kind: 'enum', name: 'RoleType' } },
400
+ required: false,
401
+ },
402
+ ],
403
+ },
404
+ ];
405
+
406
+ const specWithModels = { ...emptySpec, models };
407
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
408
+
409
+ const file = findModel(result, 'Profile');
410
+ expect(file).toBeDefined();
411
+ expect(file!.content).toContain(
412
+ "isset($data['roles']) ? array_map(fn ($item) => RoleType::from($item), $data['roles']) : null",
413
+ );
414
+ expect(file!.content).toContain(
415
+ '$this->roles !== null ? array_map(fn ($item) => $item->value, $this->roles) : null',
416
+ );
417
+ });
418
+
419
+ it('uses nullsafe operator for nullable enum in toArray', () => {
420
+ const models: Model[] = [
421
+ {
422
+ name: 'Connection',
423
+ fields: [
424
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
425
+ { name: 'status', type: { kind: 'enum', name: 'ConnectionStatus' }, required: false },
426
+ ],
427
+ },
428
+ ];
429
+
430
+ const specWithModels = { ...emptySpec, models };
431
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
432
+
433
+ const file = findModel(result, 'Connection');
434
+ expect(file).toBeDefined();
435
+ expect(file!.content).toContain('$this->status?->value');
436
+ expect(file!.content).not.toContain('instanceof \\BackedEnum');
437
+ });
438
+
439
+ it('deduplicates structurally identical models', () => {
440
+ const models: Model[] = [
441
+ {
442
+ name: 'FlagCreatedContextActor',
443
+ fields: [
444
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
445
+ { name: 'type', type: { kind: 'primitive', type: 'string' }, required: true },
446
+ ],
447
+ },
448
+ {
449
+ name: 'FlagUpdatedContextActor',
450
+ fields: [
451
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
452
+ { name: 'type', type: { kind: 'primitive', type: 'string' }, required: true },
453
+ ],
454
+ },
455
+ {
456
+ name: 'FlagDeletedContextActor',
457
+ fields: [
458
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
459
+ { name: 'type', type: { kind: 'primitive', type: 'string' }, required: true },
460
+ ],
461
+ },
462
+ ];
463
+
464
+ const specWithModels = { ...emptySpec, models };
465
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
466
+
467
+ // Only the trait + one canonical model file should be emitted (not 3)
468
+ const modelFiles = result.filter((f) => !f.path.includes('Trait'));
469
+ expect(modelFiles).toHaveLength(1);
470
+ // Shortest class name wins as canonical
471
+ expect(modelFiles[0].path).toContain('FlagCreatedContextActor');
472
+ });
473
+
474
+ it('does not produce double |null in @var for nullable optional arrays', () => {
475
+ const models: Model[] = [
476
+ {
477
+ name: 'User',
478
+ fields: [
479
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
480
+ {
481
+ name: 'tags',
482
+ type: { kind: 'nullable', inner: { kind: 'array', items: { kind: 'primitive', type: 'string' } } },
483
+ required: false,
484
+ },
485
+ ],
486
+ },
487
+ ];
488
+
489
+ const specWithModels = { ...emptySpec, models };
490
+ const result = generateModels(models, { ...ctx, spec: specWithModels });
491
+
492
+ const file = findModel(result, 'User');
493
+ expect(file).toBeDefined();
494
+ expect(file!.content).toContain('@var array<string>|null');
495
+ expect(file!.content).not.toContain('|null|null');
496
+ });
497
+ });