@ebarahona/loopback-openapi-v3 1.0.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.
@@ -0,0 +1,879 @@
1
+ import {describe, it, expect} from 'vitest';
2
+ import {transformOpenApiSpec, parseVersion} from '../transform';
3
+
4
+ // -------------------------------------------------------------------
5
+ // parseVersion
6
+ // -------------------------------------------------------------------
7
+
8
+ describe('parseVersion', () => {
9
+ it('parses valid versions', () => {
10
+ expect(parseVersion('3.0.0')).toEqual({major: 3, minor: 0, patch: 0});
11
+ expect(parseVersion('3.1.0')).toEqual({major: 3, minor: 1, patch: 0});
12
+ expect(parseVersion('3.2.0')).toEqual({major: 3, minor: 2, patch: 0});
13
+ expect(parseVersion('3.1.1')).toEqual({major: 3, minor: 1, patch: 1});
14
+ });
15
+
16
+ it('rejects invalid formats', () => {
17
+ expect(() => parseVersion('banana')).toThrow('Invalid OpenAPI version');
18
+ expect(() => parseVersion('3.0')).toThrow('Invalid OpenAPI version');
19
+ expect(() => parseVersion('3')).toThrow('Invalid OpenAPI version');
20
+ expect(() => parseVersion('')).toThrow('Invalid OpenAPI version');
21
+ });
22
+
23
+ it('rejects non-v3 versions', () => {
24
+ expect(() => parseVersion('2.0.0')).toThrow('Unsupported OpenAPI major version');
25
+ expect(() => parseVersion('4.0.0')).toThrow('Unsupported OpenAPI major version');
26
+ });
27
+ });
28
+
29
+ // -------------------------------------------------------------------
30
+ // Deep clone (no mutation)
31
+ // -------------------------------------------------------------------
32
+
33
+ describe('deep clone safety', () => {
34
+ it('does not mutate the original spec', () => {
35
+ const original = {
36
+ openapi: '3.0.0',
37
+ info: {title: 'Test', version: '1.0.0'},
38
+ paths: {},
39
+ components: {
40
+ schemas: {
41
+ Item: {
42
+ type: 'object',
43
+ properties: {
44
+ name: {type: 'string', nullable: true},
45
+ },
46
+ },
47
+ },
48
+ },
49
+ };
50
+
51
+ const frozen = JSON.parse(JSON.stringify(original));
52
+ transformOpenApiSpec(original, {version: '3.1.0'});
53
+ expect(original).toEqual(frozen);
54
+ });
55
+ });
56
+
57
+ // -------------------------------------------------------------------
58
+ // Nullable: edge cases
59
+ // -------------------------------------------------------------------
60
+
61
+ describe('nullable edge cases', () => {
62
+ it('handles nullable with oneOf', () => {
63
+ const spec = {
64
+ openapi: '3.0.0',
65
+ info: {title: 'T', version: '1'},
66
+ paths: {},
67
+ components: {
68
+ schemas: {
69
+ Mixed: {
70
+ nullable: true,
71
+ oneOf: [{type: 'string'}, {type: 'integer'}],
72
+ },
73
+ },
74
+ },
75
+ };
76
+
77
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
78
+ const mixed = (result.components as Record<string, unknown>)?.schemas as Record<string, unknown>;
79
+ const schema = mixed?.Mixed as Record<string, unknown>;
80
+ expect(schema).not.toHaveProperty('nullable');
81
+ expect(schema.oneOf).toEqual([
82
+ {type: 'string'},
83
+ {type: 'integer'},
84
+ {type: 'null'},
85
+ ]);
86
+ });
87
+
88
+ it('handles nullable with anyOf', () => {
89
+ const spec = {
90
+ openapi: '3.0.0',
91
+ info: {title: 'T', version: '1'},
92
+ paths: {},
93
+ components: {
94
+ schemas: {
95
+ Mixed: {
96
+ nullable: true,
97
+ anyOf: [{type: 'string'}],
98
+ },
99
+ },
100
+ },
101
+ };
102
+
103
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
104
+ const schema = ((result.components as Record<string, unknown>)?.schemas as Record<string, unknown>)?.Mixed as Record<string, unknown>;
105
+ expect(schema.anyOf).toEqual([{type: 'string'}, {type: 'null'}]);
106
+ });
107
+
108
+ it('handles nullable with allOf (wraps in anyOf)', () => {
109
+ const spec = {
110
+ openapi: '3.0.0',
111
+ info: {title: 'T', version: '1'},
112
+ paths: {},
113
+ components: {
114
+ schemas: {
115
+ Mixed: {
116
+ nullable: true,
117
+ allOf: [{type: 'object'}, {properties: {id: {type: 'integer'}}}],
118
+ },
119
+ },
120
+ },
121
+ };
122
+
123
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
124
+ const schema = ((result.components as Record<string, unknown>)?.schemas as Record<string, unknown>)?.Mixed as Record<string, unknown>;
125
+ expect(schema).not.toHaveProperty('allOf');
126
+ expect(schema.anyOf).toEqual([
127
+ {allOf: [{type: 'object'}, {properties: {id: {type: 'integer'}}}]},
128
+ {type: 'null'},
129
+ ]);
130
+ });
131
+
132
+ it('handles nullable with no type', () => {
133
+ const spec = {
134
+ openapi: '3.0.0',
135
+ info: {title: 'T', version: '1'},
136
+ paths: {},
137
+ components: {
138
+ schemas: {
139
+ Empty: {nullable: true},
140
+ },
141
+ },
142
+ };
143
+
144
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
145
+ const schema = ((result.components as Record<string, unknown>)?.schemas as Record<string, unknown>)?.Empty as Record<string, unknown>;
146
+ expect(schema.type).toBe('null');
147
+ expect(schema).not.toHaveProperty('nullable');
148
+ });
149
+
150
+ it('downgrades oneOf with null type back to nullable', () => {
151
+ const spec = {
152
+ openapi: '3.1.0',
153
+ info: {title: 'T', version: '1'},
154
+ paths: {},
155
+ components: {
156
+ schemas: {
157
+ Mixed: {
158
+ oneOf: [{type: 'string'}, {type: 'integer'}, {type: 'null'}],
159
+ },
160
+ },
161
+ },
162
+ };
163
+
164
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.0.0'});
165
+ const schema = ((result.components as Record<string, unknown>)?.schemas as Record<string, unknown>)?.Mixed as Record<string, unknown>;
166
+ expect(schema.nullable).toBe(true);
167
+ expect(schema.oneOf).toEqual([{type: 'string'}, {type: 'integer'}]);
168
+ });
169
+ });
170
+
171
+ // -------------------------------------------------------------------
172
+ // Components: parameters, requestBodies, responses, headers
173
+ // -------------------------------------------------------------------
174
+
175
+ describe('component-level schema traversal', () => {
176
+ it('transforms schemas in components.parameters', () => {
177
+ const spec = {
178
+ openapi: '3.0.0',
179
+ info: {title: 'T', version: '1'},
180
+ paths: {},
181
+ components: {
182
+ parameters: {
183
+ FilterParam: {
184
+ name: 'filter',
185
+ in: 'query',
186
+ schema: {type: 'string', nullable: true},
187
+ },
188
+ },
189
+ },
190
+ };
191
+
192
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
193
+ const params = (result.components as Record<string, unknown>)?.parameters as Record<string, unknown>;
194
+ const param = params?.FilterParam as Record<string, unknown>;
195
+ const schema = param?.schema as Record<string, unknown>;
196
+ expect(schema?.type).toEqual(['string', 'null']);
197
+ });
198
+
199
+ it('transforms schemas in components.requestBodies', () => {
200
+ const spec = {
201
+ openapi: '3.0.0',
202
+ info: {title: 'T', version: '1'},
203
+ paths: {},
204
+ components: {
205
+ requestBodies: {
206
+ CreateItem: {
207
+ content: {
208
+ 'application/json': {
209
+ schema: {
210
+ type: 'object',
211
+ properties: {
212
+ name: {type: 'string', nullable: true},
213
+ },
214
+ },
215
+ },
216
+ },
217
+ },
218
+ },
219
+ },
220
+ };
221
+
222
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
223
+ const bodies = (result.components as Record<string, unknown>)?.requestBodies as Record<string, unknown>;
224
+ const body = bodies?.CreateItem as Record<string, unknown>;
225
+ const content = body?.content as Record<string, unknown>;
226
+ const json = content?.['application/json'] as Record<string, unknown>;
227
+ const schema = json?.schema as Record<string, unknown>;
228
+ const props = schema?.properties as Record<string, unknown>;
229
+ const name = props?.name as Record<string, unknown>;
230
+ expect(name?.type).toEqual(['string', 'null']);
231
+ });
232
+
233
+ it('transforms schemas in components.responses', () => {
234
+ const spec = {
235
+ openapi: '3.0.0',
236
+ info: {title: 'T', version: '1'},
237
+ paths: {},
238
+ components: {
239
+ responses: {
240
+ ItemResponse: {
241
+ description: 'OK',
242
+ content: {
243
+ 'application/json': {
244
+ schema: {type: 'string', nullable: true},
245
+ },
246
+ },
247
+ },
248
+ },
249
+ },
250
+ };
251
+
252
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
253
+ const responses = (result.components as Record<string, unknown>)?.responses as Record<string, unknown>;
254
+ const resp = responses?.ItemResponse as Record<string, unknown>;
255
+ const content = resp?.content as Record<string, unknown>;
256
+ const json = content?.['application/json'] as Record<string, unknown>;
257
+ const schema = json?.schema as Record<string, unknown>;
258
+ expect(schema?.type).toEqual(['string', 'null']);
259
+ });
260
+
261
+ it('transforms schemas in components.headers', () => {
262
+ const spec = {
263
+ openapi: '3.0.0',
264
+ info: {title: 'T', version: '1'},
265
+ paths: {},
266
+ components: {
267
+ headers: {
268
+ XRateLimit: {
269
+ schema: {type: 'integer', nullable: true},
270
+ },
271
+ },
272
+ },
273
+ };
274
+
275
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
276
+ const headers = (result.components as Record<string, unknown>)?.headers as Record<string, unknown>;
277
+ const header = headers?.XRateLimit as Record<string, unknown>;
278
+ const schema = header?.schema as Record<string, unknown>;
279
+ expect(schema?.type).toEqual(['integer', 'null']);
280
+ });
281
+ });
282
+
283
+ // -------------------------------------------------------------------
284
+ // Webhooks
285
+ // -------------------------------------------------------------------
286
+
287
+ describe('webhooks', () => {
288
+ it('transforms schemas in webhooks', () => {
289
+ const spec = {
290
+ openapi: '3.0.0',
291
+ info: {title: 'T', version: '1'},
292
+ paths: {},
293
+ webhooks: {
294
+ newItem: {
295
+ post: {
296
+ requestBody: {
297
+ content: {
298
+ 'application/json': {
299
+ schema: {type: 'string', nullable: true},
300
+ },
301
+ },
302
+ },
303
+ responses: {'200': {description: 'OK'}},
304
+ },
305
+ },
306
+ },
307
+ };
308
+
309
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
310
+ const webhooks = result.webhooks as Record<string, unknown>;
311
+ const hook = webhooks?.newItem as Record<string, unknown>;
312
+ const post = hook?.post as Record<string, unknown>;
313
+ const reqBody = post?.requestBody as Record<string, unknown>;
314
+ const content = reqBody?.content as Record<string, unknown>;
315
+ const json = content?.['application/json'] as Record<string, unknown>;
316
+ const schema = json?.schema as Record<string, unknown>;
317
+ expect(schema?.type).toEqual(['string', 'null']);
318
+ });
319
+
320
+ it('strips 3.2 features from webhooks when targeting 3.1', () => {
321
+ const spec = {
322
+ openapi: '3.2.0',
323
+ info: {title: 'T', version: '1'},
324
+ paths: {},
325
+ webhooks: {
326
+ newItem: {
327
+ query: {
328
+ responses: {'200': {description: 'OK'}},
329
+ },
330
+ },
331
+ },
332
+ };
333
+
334
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
335
+ const webhooks = result.webhooks as Record<string, unknown>;
336
+ const hook = webhooks?.newItem as Record<string, unknown>;
337
+ expect(hook).not.toHaveProperty('query');
338
+ });
339
+
340
+ it('strips webhooks entirely when targeting 3.0', () => {
341
+ const spec = {
342
+ openapi: '3.2.0',
343
+ info: {title: 'T', version: '1'},
344
+ paths: {},
345
+ webhooks: {
346
+ newItem: {
347
+ post: {
348
+ responses: {'200': {description: 'OK'}},
349
+ },
350
+ },
351
+ },
352
+ };
353
+
354
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.0.0'});
355
+ expect(result).not.toHaveProperty('webhooks');
356
+ expect(warnings.some(w => w.field === 'webhooks')).toBe(true);
357
+ });
358
+ });
359
+
360
+ // -------------------------------------------------------------------
361
+ // Callbacks
362
+ // -------------------------------------------------------------------
363
+
364
+ describe('callbacks', () => {
365
+ it('transforms schemas in callbacks', () => {
366
+ const spec = {
367
+ openapi: '3.0.0',
368
+ info: {title: 'T', version: '1'},
369
+ paths: {
370
+ '/subscribe': {
371
+ post: {
372
+ operationId: 'subscribe',
373
+ callbacks: {
374
+ onEvent: {
375
+ '{$request.body#/callbackUrl}': {
376
+ post: {
377
+ requestBody: {
378
+ content: {
379
+ 'application/json': {
380
+ schema: {type: 'string', nullable: true},
381
+ },
382
+ },
383
+ },
384
+ responses: {'200': {description: 'OK'}},
385
+ },
386
+ },
387
+ },
388
+ },
389
+ responses: {'200': {description: 'OK'}},
390
+ },
391
+ },
392
+ },
393
+ };
394
+
395
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
396
+ const paths = result.paths as Record<string, unknown>;
397
+ const sub = paths?.['/subscribe'] as Record<string, unknown>;
398
+ const post = sub?.post as Record<string, unknown>;
399
+ const callbacks = post?.callbacks as Record<string, unknown>;
400
+ const onEvent = callbacks?.onEvent as Record<string, unknown>;
401
+ const cbPath = onEvent?.['{$request.body#/callbackUrl}'] as Record<string, unknown>;
402
+ const cbPost = cbPath?.post as Record<string, unknown>;
403
+ const reqBody = cbPost?.requestBody as Record<string, unknown>;
404
+ const content = reqBody?.content as Record<string, unknown>;
405
+ const json = content?.['application/json'] as Record<string, unknown>;
406
+ const schema = json?.schema as Record<string, unknown>;
407
+ expect(schema?.type).toEqual(['string', 'null']);
408
+ });
409
+ });
410
+
411
+ // -------------------------------------------------------------------
412
+ // Path-level parameters
413
+ // -------------------------------------------------------------------
414
+
415
+ describe('path-level parameters', () => {
416
+ it('transforms schemas in path-level parameters', () => {
417
+ const spec = {
418
+ openapi: '3.0.0',
419
+ info: {title: 'T', version: '1'},
420
+ paths: {
421
+ '/items/{id}': {
422
+ parameters: [
423
+ {name: 'id', in: 'path', schema: {type: 'string', nullable: true}},
424
+ ],
425
+ get: {
426
+ responses: {'200': {description: 'OK'}},
427
+ },
428
+ },
429
+ },
430
+ };
431
+
432
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
433
+ const paths = result.paths as Record<string, unknown>;
434
+ const pathItem = paths?.['/items/{id}'] as Record<string, unknown>;
435
+ const params = pathItem?.parameters as Record<string, unknown>[];
436
+ const schema = params?.[0]?.schema as Record<string, unknown>;
437
+ expect(schema?.type).toEqual(['string', 'null']);
438
+ });
439
+ });
440
+
441
+ // -------------------------------------------------------------------
442
+ // Circular references
443
+ // -------------------------------------------------------------------
444
+
445
+ describe('circular references', () => {
446
+ it('handles circular schema references without infinite loop', () => {
447
+ const node: Record<string, unknown> = {
448
+ type: 'object',
449
+ properties: {},
450
+ };
451
+ // Create circular reference
452
+ (node.properties as Record<string, unknown>).self = node;
453
+
454
+ const spec = {
455
+ openapi: '3.0.0',
456
+ info: {title: 'T', version: '1'},
457
+ paths: {},
458
+ components: {schemas: {Node: node}},
459
+ };
460
+
461
+ // structuredClone breaks on circular refs, so test the walkSchemas
462
+ // logic separately. The transform will clone first which breaks cycles,
463
+ // but the walk itself should handle cycles via WeakSet.
464
+ expect(() => {
465
+ // This would infinite loop without the WeakSet guard
466
+ transformOpenApiSpec(
467
+ JSON.parse(JSON.stringify(spec, (key, value) => {
468
+ // Break circular ref for JSON.stringify
469
+ if (key === 'self' && value === node) return {$ref: '#/components/schemas/Node'};
470
+ return value;
471
+ })),
472
+ {version: '3.1.0'},
473
+ );
474
+ }).not.toThrow();
475
+ });
476
+ });
477
+
478
+ // -------------------------------------------------------------------
479
+ // 3.2 specific: jsonSchemaDialect, mediaTypes
480
+ // -------------------------------------------------------------------
481
+
482
+ describe('3.2 additional fields', () => {
483
+ it('strips jsonSchemaDialect when downgrading', () => {
484
+ const spec = {
485
+ openapi: '3.2.0',
486
+ info: {title: 'T', version: '1'},
487
+ paths: {},
488
+ jsonSchemaDialect: 'https://json-schema.org/draft/2020-12/schema',
489
+ };
490
+
491
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.0.0'});
492
+ expect(result).not.toHaveProperty('jsonSchemaDialect');
493
+ });
494
+
495
+ it('strips components.mediaTypes when downgrading', () => {
496
+ const spec = {
497
+ openapi: '3.2.0',
498
+ info: {title: 'T', version: '1'},
499
+ paths: {},
500
+ components: {
501
+ mediaTypes: {
502
+ JsonResponse: {
503
+ schema: {type: 'object'},
504
+ },
505
+ },
506
+ },
507
+ };
508
+
509
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
510
+ const components = result.components as Record<string, unknown>;
511
+ expect(components).not.toHaveProperty('mediaTypes');
512
+ });
513
+ });
514
+
515
+ // -------------------------------------------------------------------
516
+ // Warnings diagnostics
517
+ // -------------------------------------------------------------------
518
+
519
+ describe('warnings', () => {
520
+ it('emits warnings when stripping 3.2 features', () => {
521
+ const spec = {
522
+ openapi: '3.2.0',
523
+ info: {title: 'T', version: '1'},
524
+ paths: {},
525
+ $self: 'https://example.com/api',
526
+ components: {
527
+ mediaTypes: {JsonResponse: {schema: {type: 'object'}}},
528
+ },
529
+ tags: [{name: 'test', summary: 'Test tag', parent: 'root', kind: 'nav'}],
530
+ };
531
+
532
+ const {warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
533
+ expect(warnings.length).toBeGreaterThan(0);
534
+ expect(warnings.some(w => w.field === '$self')).toBe(true);
535
+ expect(warnings.some(w => w.field === 'components.mediaTypes')).toBe(true);
536
+ expect(warnings.some(w => w.field === 'tags')).toBe(true);
537
+ });
538
+
539
+ it('emits no warnings for same-version transform', () => {
540
+ const spec = {
541
+ openapi: '3.0.0',
542
+ info: {title: 'T', version: '1'},
543
+ paths: {},
544
+ };
545
+
546
+ const {warnings} = transformOpenApiSpec(spec, {version: '3.0.0'});
547
+ expect(warnings).toEqual([]);
548
+ });
549
+
550
+ it('emits no warnings for upgrades', () => {
551
+ const spec = {
552
+ openapi: '3.0.0',
553
+ info: {title: 'T', version: '1'},
554
+ paths: {},
555
+ components: {
556
+ schemas: {
557
+ Item: {type: 'string', nullable: true},
558
+ },
559
+ },
560
+ };
561
+
562
+ const {warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
563
+ expect(warnings).toEqual([]);
564
+ });
565
+
566
+ it('emits warnings for jsonSchemaDialect when targeting 3.0', () => {
567
+ const spec = {
568
+ openapi: '3.1.0',
569
+ info: {title: 'T', version: '1'},
570
+ paths: {},
571
+ jsonSchemaDialect: 'https://json-schema.org/draft/2020-12/schema',
572
+ };
573
+
574
+ const {warnings} = transformOpenApiSpec(spec, {version: '3.0.0'});
575
+ expect(warnings.some(w => w.field === 'jsonSchemaDialect')).toBe(true);
576
+ });
577
+
578
+ it('preserves jsonSchemaDialect when targeting 3.1', () => {
579
+ const spec = {
580
+ openapi: '3.2.0',
581
+ info: {title: 'T', version: '1'},
582
+ paths: {},
583
+ jsonSchemaDialect: 'https://json-schema.org/draft/2020-12/schema',
584
+ };
585
+
586
+ const {spec: result} = transformOpenApiSpec(spec, {version: '3.1.0'});
587
+ expect(result.jsonSchemaDialect).toBe('https://json-schema.org/draft/2020-12/schema');
588
+ });
589
+ });
590
+
591
+ // -------------------------------------------------------------------
592
+ // Version validation
593
+ // -------------------------------------------------------------------
594
+
595
+ describe('version validation', () => {
596
+ it('rejects unsupported minor version 3.99.0', () => {
597
+ const spec = {openapi: '3.0.0', info: {title: 'T', version: '1'}, paths: {}};
598
+ expect(() => transformOpenApiSpec(spec, {version: '3.99.0' as '3.0.0'})).toThrow(
599
+ 'Unsupported OpenAPI minor version',
600
+ );
601
+ });
602
+
603
+ it('rejects invalid source version', () => {
604
+ const spec = {openapi: 'banana', info: {title: 'T', version: '1'}, paths: {}};
605
+ expect(() => transformOpenApiSpec(spec, {version: '3.1.0'})).toThrow(
606
+ 'Invalid OpenAPI version',
607
+ );
608
+ });
609
+ });
610
+
611
+ // -------------------------------------------------------------------
612
+ // itemEncoding, prefixEncoding (3.2 media fields)
613
+ // -------------------------------------------------------------------
614
+
615
+ describe('3.2 media fields', () => {
616
+ it('strips itemEncoding and prefixEncoding when downgrading', () => {
617
+ const spec = {
618
+ openapi: '3.2.0',
619
+ info: {title: 'T', version: '1'},
620
+ paths: {
621
+ '/stream': {
622
+ get: {
623
+ responses: {
624
+ '200': {
625
+ description: 'OK',
626
+ content: {
627
+ 'text/event-stream': {
628
+ schema: {type: 'string'},
629
+ itemSchema: {type: 'object'},
630
+ itemEncoding: {contentType: 'application/json'},
631
+ prefixEncoding: {contentType: 'text/plain'},
632
+ },
633
+ },
634
+ },
635
+ },
636
+ },
637
+ },
638
+ },
639
+ };
640
+
641
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
642
+ const getOp = (result.paths as Record<string, unknown>)?.['/stream'] as Record<string, unknown>;
643
+ const get = getOp?.get as Record<string, unknown>;
644
+ const resp = (get?.responses as Record<string, unknown>)?.['200'] as Record<string, unknown>;
645
+ const content = resp?.content as Record<string, unknown>;
646
+ const sse = content?.['text/event-stream'] as Record<string, unknown>;
647
+ expect(sse).not.toHaveProperty('itemSchema');
648
+ expect(sse).not.toHaveProperty('itemEncoding');
649
+ expect(sse).not.toHaveProperty('prefixEncoding');
650
+ expect(sse).toHaveProperty('schema');
651
+ expect(warnings.some(w => w.message.includes('itemSchema'))).toBe(true);
652
+ });
653
+ });
654
+
655
+ // -------------------------------------------------------------------
656
+ // Server.name (3.2)
657
+ // -------------------------------------------------------------------
658
+
659
+ describe('Server.name', () => {
660
+ it('strips server name when downgrading from 3.2', () => {
661
+ const spec = {
662
+ openapi: '3.2.0',
663
+ info: {title: 'T', version: '1'},
664
+ paths: {},
665
+ servers: [{url: 'https://api.example.com', name: 'production'}],
666
+ };
667
+
668
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
669
+ const servers = result.servers as Record<string, unknown>[];
670
+ expect(servers[0]).not.toHaveProperty('name');
671
+ expect(servers[0].url).toBe('https://api.example.com');
672
+ expect(warnings.some(w => w.field === 'servers')).toBe(true);
673
+ });
674
+ });
675
+
676
+ // -------------------------------------------------------------------
677
+ // License.identifier (3.1+)
678
+ // -------------------------------------------------------------------
679
+
680
+ describe('License.identifier', () => {
681
+ it('strips license identifier when targeting 3.0', () => {
682
+ const spec = {
683
+ openapi: '3.1.0',
684
+ info: {title: 'T', version: '1', license: {name: 'MIT', identifier: 'MIT'}},
685
+ paths: {},
686
+ };
687
+
688
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.0.0'});
689
+ const info = result.info as Record<string, unknown>;
690
+ const license = info?.license as Record<string, unknown>;
691
+ expect(license).not.toHaveProperty('identifier');
692
+ expect(license?.name).toBe('MIT');
693
+ });
694
+
695
+ it('preserves license identifier when targeting 3.1', () => {
696
+ const spec = {
697
+ openapi: '3.2.0',
698
+ info: {title: 'T', version: '1', license: {name: 'MIT', identifier: 'MIT'}},
699
+ paths: {},
700
+ };
701
+
702
+ const {spec: result} = transformOpenApiSpec(spec, {version: '3.1.0'});
703
+ const info = result.info as Record<string, unknown>;
704
+ const license = info?.license as Record<string, unknown>;
705
+ expect(license?.identifier).toBe('MIT');
706
+ });
707
+ });
708
+
709
+ // -------------------------------------------------------------------
710
+ // components.pathItems (3.1+)
711
+ // -------------------------------------------------------------------
712
+
713
+ describe('components.pathItems', () => {
714
+ it('strips pathItems when targeting 3.0', () => {
715
+ const spec = {
716
+ openapi: '3.1.0',
717
+ info: {title: 'T', version: '1'},
718
+ paths: {},
719
+ components: {
720
+ pathItems: {
721
+ CommonItem: {
722
+ get: {responses: {'200': {description: 'OK'}}},
723
+ },
724
+ },
725
+ },
726
+ };
727
+
728
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.0.0'});
729
+ const components = result.components as Record<string, unknown>;
730
+ expect(components).not.toHaveProperty('pathItems');
731
+ expect(warnings.some(w => w.field === 'components.pathItems')).toBe(true);
732
+ });
733
+
734
+ it('preserves pathItems when targeting 3.1', () => {
735
+ const spec = {
736
+ openapi: '3.2.0',
737
+ info: {title: 'T', version: '1'},
738
+ paths: {},
739
+ components: {
740
+ pathItems: {
741
+ CommonItem: {
742
+ get: {responses: {'200': {description: 'OK'}}},
743
+ },
744
+ },
745
+ },
746
+ };
747
+
748
+ const {spec: result} = transformOpenApiSpec(spec, {version: '3.1.0'});
749
+ const components = result.components as Record<string, unknown>;
750
+ expect(components).toHaveProperty('pathItems');
751
+ });
752
+ });
753
+
754
+ // -------------------------------------------------------------------
755
+ // XML Object text field (3.2)
756
+ // -------------------------------------------------------------------
757
+
758
+ describe('XML Object', () => {
759
+ it('strips xml.text when downgrading from 3.2', () => {
760
+ const spec = {
761
+ openapi: '3.2.0',
762
+ info: {title: 'T', version: '1'},
763
+ paths: {},
764
+ components: {
765
+ schemas: {
766
+ Item: {
767
+ type: 'object',
768
+ xml: {name: 'item', text: true},
769
+ properties: {
770
+ name: {type: 'string', xml: {attribute: true}},
771
+ content: {type: 'string', xml: {text: true}},
772
+ },
773
+ },
774
+ },
775
+ },
776
+ };
777
+
778
+ const {spec: result, warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
779
+ const item = (result.components as Record<string, unknown>)?.schemas as Record<string, unknown>;
780
+ const schema = item?.Item as Record<string, unknown>;
781
+ const xml = schema?.xml as Record<string, unknown>;
782
+ expect(xml).not.toHaveProperty('text');
783
+ expect(xml?.name).toBe('item');
784
+
785
+ const props = schema?.properties as Record<string, unknown>;
786
+ const content = props?.content as Record<string, unknown>;
787
+ const contentXml = content?.xml as Record<string, unknown>;
788
+ expect(contentXml).not.toHaveProperty('text');
789
+
790
+ expect(warnings.some(w => w.field === 'xml.text')).toBe(true);
791
+ });
792
+ });
793
+
794
+ // -------------------------------------------------------------------
795
+ // Transform ordering: 3.2 strip before 3.1 strip
796
+ // -------------------------------------------------------------------
797
+
798
+ describe('transform ordering', () => {
799
+ it('strips 3.2 fields from webhooks before webhooks are removed for 3.0', () => {
800
+ const spec = {
801
+ openapi: '3.2.0',
802
+ info: {title: 'T', version: '1'},
803
+ paths: {},
804
+ webhooks: {
805
+ onEvent: {
806
+ query: {responses: {'200': {description: 'OK'}}},
807
+ post: {responses: {'200': {description: 'OK'}}},
808
+ },
809
+ },
810
+ };
811
+
812
+ const {warnings} = transformOpenApiSpec(spec, {version: '3.0.0'});
813
+ const queryWarning = warnings.find(w => w.field.includes('webhooks.onEvent.query'));
814
+ const webhookWarning = warnings.find(w => w.field === 'webhooks');
815
+ expect(queryWarning).toBeDefined();
816
+ expect(webhookWarning).toBeDefined();
817
+ });
818
+ });
819
+
820
+ // -------------------------------------------------------------------
821
+ // Warning deduplication
822
+ // -------------------------------------------------------------------
823
+
824
+ describe('warning deduplication', () => {
825
+ it('dedupes repeated warnings', () => {
826
+ const spec = {
827
+ openapi: '3.2.0',
828
+ info: {title: 'T', version: '1'},
829
+ paths: {
830
+ '/a': {
831
+ get: {
832
+ responses: {
833
+ '200': {
834
+ description: 'OK',
835
+ content: {
836
+ 'text/event-stream': {
837
+ schema: {type: 'string'},
838
+ itemSchema: {type: 'object'},
839
+ },
840
+ },
841
+ },
842
+ },
843
+ },
844
+ },
845
+ '/b': {
846
+ get: {
847
+ responses: {
848
+ '200': {
849
+ description: 'OK',
850
+ content: {
851
+ 'text/event-stream': {
852
+ schema: {type: 'string'},
853
+ itemSchema: {type: 'object'},
854
+ },
855
+ },
856
+ },
857
+ },
858
+ },
859
+ },
860
+ },
861
+ servers: [
862
+ {url: 'https://a.example.com', name: 'a'},
863
+ {url: 'https://b.example.com', name: 'b'},
864
+ ],
865
+ components: {
866
+ schemas: {
867
+ A: {type: 'object', xml: {text: true}},
868
+ B: {type: 'object', xml: {text: true}},
869
+ },
870
+ },
871
+ };
872
+
873
+ const {warnings} = transformOpenApiSpec(spec, {version: '3.1.0'});
874
+ const serverWarnings = warnings.filter(w => w.field === 'servers');
875
+ const xmlWarnings = warnings.filter(w => w.field === 'xml.text');
876
+ expect(serverWarnings.length).toBe(1);
877
+ expect(xmlWarnings.length).toBe(1);
878
+ });
879
+ });