@contractkit/plugin-bruno 0.9.1

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,1008 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateOpenCollection, sanitizePath, MANIFEST_FILENAME, parseManifest } from '../src/codegen-bruno.js';
3
+ import {
4
+ opRoot,
5
+ opRoute,
6
+ opOperation,
7
+ opParam,
8
+ opResponse,
9
+ paramNodes,
10
+ paramRef,
11
+ paramType,
12
+ opRequest,
13
+ scalarType,
14
+ enumType,
15
+ inlineObjectType,
16
+ field,
17
+ arrayType,
18
+ refType,
19
+ model,
20
+ contractRoot,
21
+ } from './helpers.js';
22
+
23
+ describe('generateOpenCollection', () => {
24
+ it('generates opencollection.yml with correct spec version and collection name', () => {
25
+ const root = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
26
+ const files = generateOpenCollection([root], { collectionName: 'My API' });
27
+ const rootFile = files.find(f => f.relativePath === 'opencollection.yml');
28
+ expect(rootFile).toBeDefined();
29
+ expect(rootFile!.content).toContain('opencollection: "1.0.0"');
30
+ expect(rootFile!.content).toContain('info:');
31
+ expect(rootFile!.content).toContain('name: My API');
32
+ });
33
+
34
+ it('generates Local environment file with baseUrl variable', () => {
35
+ const root = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
36
+ const files = generateOpenCollection([root], { collectionName: 'API' });
37
+ const envFile = files.find(f => f.relativePath === 'environments/local.yml');
38
+ expect(envFile).toBeDefined();
39
+ expect(envFile!.content).toContain('name: Local');
40
+ expect(envFile!.content).toContain('variables:');
41
+ expect(envFile!.content).toContain('- name: baseUrl');
42
+ expect(envFile!.content).toContain('value: "http://localhost:3000"');
43
+ expect(envFile!.content).not.toContain('enabled:');
44
+ expect(envFile!.content).not.toContain('secret:');
45
+ });
46
+
47
+ it('emits requests for internal operations by default', () => {
48
+ const root = opRoot(
49
+ [opRoute('/secret', [opOperation('get', { name: 'Get Secret' })], undefined, ['internal'])],
50
+ 'secret.op',
51
+ );
52
+ const files = generateOpenCollection([root], { collectionName: 'API' });
53
+ expect(files.some(f => f.relativePath.endsWith('get-secret.yml'))).toBe(true);
54
+ });
55
+
56
+ it('skips internal operations when includeInternal is false', () => {
57
+ const root = opRoot(
58
+ [opRoute('/secret', [opOperation('get', { name: 'Get Secret' })], undefined, ['internal'])],
59
+ 'secret.op',
60
+ );
61
+ const files = generateOpenCollection([root], { collectionName: 'API', includeInternal: false });
62
+ expect(files.some(f => f.relativePath.endsWith('get-secret.yml'))).toBe(false);
63
+ });
64
+
65
+ it('creates one folder per op root file', () => {
66
+ const usersRoot = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
67
+ const paymentsRoot = opRoot([opRoute('/payments', [opOperation('get')])], 'payments.op');
68
+ const files = generateOpenCollection([usersRoot, paymentsRoot], { collectionName: 'API' });
69
+ expect(files.some(f => f.relativePath === 'users/folder.yml')).toBe(true);
70
+ expect(files.some(f => f.relativePath === 'payments/folder.yml')).toBe(true);
71
+ });
72
+
73
+ it('folder.yml has info block with name, type: folder, and seq', () => {
74
+ const root = opRoot([opRoute('/users', [opOperation('get')])], 'src/users.op');
75
+ const files = generateOpenCollection([root], { collectionName: 'API' });
76
+ const folderFile = files.find(f => f.relativePath === 'users/folder.yml');
77
+ expect(folderFile!.content).toContain('info:');
78
+ expect(folderFile!.content).toContain('name: Users');
79
+ expect(folderFile!.content).toContain('type: folder');
80
+ expect(folderFile!.content).toContain('seq: 1');
81
+ });
82
+
83
+ it('generates one .yml file per route+method combination', () => {
84
+ const root = opRoot(
85
+ [
86
+ opRoute('/users', [opOperation('get'), opOperation('post')]),
87
+ opRoute('/users/{id}', [opOperation('get'), opOperation('delete')]),
88
+ ],
89
+ 'users.op',
90
+ );
91
+ const files = generateOpenCollection([root], { collectionName: 'API' });
92
+ expect(files.some(f => f.relativePath === 'users/get-users.yml')).toBe(true);
93
+ expect(files.some(f => f.relativePath === 'users/post-users.yml')).toBe(true);
94
+ expect(files.some(f => f.relativePath === 'users/get-users-id.yml')).toBe(true);
95
+ expect(files.some(f => f.relativePath === 'users/delete-users-id.yml')).toBe(true);
96
+ });
97
+
98
+ it('request info block has name, type: http, and seq', () => {
99
+ const root = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
100
+ const files = generateOpenCollection([root], { collectionName: 'API' });
101
+ const yml = files.find(f => f.relativePath === 'users/get-users.yml');
102
+ expect(yml!.content).toContain('name: /users');
103
+ expect(yml!.content).toContain('type: http');
104
+ expect(yml!.content).toContain('seq: 1');
105
+ });
106
+
107
+ it('seq increments across operations within a folder', () => {
108
+ const root = opRoot([opRoute('/users', [opOperation('get'), opOperation('post')])], 'users.op');
109
+ const files = generateOpenCollection([root], { collectionName: 'API' });
110
+ expect(files.find(f => f.relativePath === 'users/get-users.yml')!.content).toContain('seq: 1');
111
+ expect(files.find(f => f.relativePath === 'users/post-users.yml')!.content).toContain('seq: 2');
112
+ });
113
+
114
+ it('uses slugified name as filename when op.name is set', () => {
115
+ const root = opRoot([opRoute('/offers', [opOperation('post', { name: 'Create an Offer' })])], 'offers.op');
116
+ const files = generateOpenCollection([root], { collectionName: 'API' });
117
+ expect(files.some(f => f.relativePath === 'offers/create-an-offer.yml')).toBe(true);
118
+ });
119
+
120
+ it('falls back to method-path filename when op.name is not set', () => {
121
+ const root = opRoot([opRoute('/offers', [opOperation('post')])], 'offers.op');
122
+ const files = generateOpenCollection([root], { collectionName: 'API' });
123
+ expect(files.some(f => f.relativePath === 'offers/post-offers.yml')).toBe(true);
124
+ });
125
+
126
+ // ─── Subarea ────────────────────────────────────────────────────────────
127
+
128
+ it('uses area meta as the top-level folder name', () => {
129
+ const root = opRoot([opRoute('/offers', [opOperation('get')])], 'capital.op', { area: 'payments' });
130
+ const files = generateOpenCollection([root], { collectionName: 'API' });
131
+ expect(files.some(f => f.relativePath === 'payments/folder.yml')).toBe(true);
132
+ expect(files.some(f => f.relativePath === 'payments/get-offers.yml')).toBe(true);
133
+ expect(files.some(f => f.relativePath === 'capital/get-offers.yml')).toBe(false);
134
+ });
135
+
136
+ it('falls back to filename when no area meta', () => {
137
+ const root = opRoot([opRoute('/offers', [opOperation('get')])], 'capital.op');
138
+ const files = generateOpenCollection([root], { collectionName: 'API' });
139
+ expect(files.some(f => f.relativePath === 'capital/get-offers.yml')).toBe(true);
140
+ });
141
+
142
+ it('places request files in subfolder when subarea meta is set', () => {
143
+ const root = opRoot([opRoute('/offers', [opOperation('post')])], 'capital.op', { subarea: 'expansion' });
144
+ const files = generateOpenCollection([root], { collectionName: 'API' });
145
+ expect(files.some(f => f.relativePath === 'capital/expansion/post-offers.yml')).toBe(true);
146
+ expect(files.some(f => f.relativePath === 'capital/post-offers.yml')).toBe(false);
147
+ });
148
+
149
+ it('generates folder.yml for subarea with correct name', () => {
150
+ const root = opRoot([opRoute('/offers', [opOperation('post')])], 'capital.op', { subarea: 'expansion' });
151
+ const files = generateOpenCollection([root], { collectionName: 'API' });
152
+ const subfolderFile = files.find(f => f.relativePath === 'capital/expansion/folder.yml');
153
+ expect(subfolderFile).toBeDefined();
154
+ expect(subfolderFile!.content).toContain('name: Expansion');
155
+ expect(subfolderFile!.content).toContain('type: folder');
156
+ });
157
+
158
+ it('still generates top-level folder.yml when subarea is set', () => {
159
+ const root = opRoot([opRoute('/offers', [opOperation('post')])], 'capital.op', { subarea: 'expansion' });
160
+ const files = generateOpenCollection([root], { collectionName: 'API' });
161
+ expect(files.some(f => f.relativePath === 'capital/folder.yml')).toBe(true);
162
+ });
163
+
164
+ it('slugifies subarea for the folder path', () => {
165
+ const root = opRoot([opRoute('/offers', [opOperation('get')])], 'capital.op', { subarea: 'Expansion Capital' });
166
+ const files = generateOpenCollection([root], { collectionName: 'API' });
167
+ expect(files.some(f => f.relativePath === 'capital/expansion-capital/get-offers.yml')).toBe(true);
168
+ });
169
+
170
+ it('places request files directly in folder when no subarea', () => {
171
+ const root = opRoot([opRoute('/offers', [opOperation('get')])], 'capital.op');
172
+ const files = generateOpenCollection([root], { collectionName: 'API' });
173
+ expect(files.some(f => f.relativePath === 'capital/get-offers.yml')).toBe(true);
174
+ expect(files.every(f => f.relativePath !== 'capital/folder.yml' || !f.relativePath.includes('/capital/'))).toBe(true);
175
+ });
176
+
177
+ it('uses {{baseUrl}} prefix and Bruno :param syntax for path params', () => {
178
+ const root = opRoot([opRoute('/users/{id}', [opOperation('get')])], 'users.op');
179
+ const files = generateOpenCollection([root], { collectionName: 'API' });
180
+ const yml = files.find(f => f.relativePath === 'users/get-users-id.yml');
181
+ expect(yml!.content).toContain('url: "{{baseUrl}}/users/:id"');
182
+ });
183
+
184
+ // ─── Path params ────────────────────────────────────────────────────────
185
+
186
+ it('generates path params as flat array entries with type: path', () => {
187
+ const root = opRoot([opRoute('/users/{id}', [opOperation('get')])], 'users.op');
188
+ const files = generateOpenCollection([root], { collectionName: 'API' });
189
+ const yml = files.find(f => f.relativePath === 'users/get-users-id.yml');
190
+ expect(yml!.content).toContain('- name: id');
191
+ expect(yml!.content).toContain('type: path');
192
+ expect(yml!.content).not.toMatch(/^\s+path:\s*$/m);
193
+ });
194
+
195
+ it('uses uuid example value for uuid path params', () => {
196
+ const root = opRoot(
197
+ [opRoute('/users/{id}', [opOperation('get')], paramNodes([opParam('id', scalarType('uuid'))]))],
198
+ 'users.op',
199
+ );
200
+ const files = generateOpenCollection([root], { collectionName: 'API' });
201
+ const yml = files.find(f => f.relativePath === 'users/get-users-id.yml');
202
+ expect(yml!.content).toContain('value: "00000000-0000-0000-0000-000000000000"');
203
+ });
204
+
205
+ it('uses typed example values for scalar path params', () => {
206
+ const root = opRoot(
207
+ [
208
+ opRoute('/reports/{date}', [opOperation('get')], paramNodes([opParam('date', scalarType('date'))])),
209
+ ],
210
+ 'reports.op',
211
+ );
212
+ const files = generateOpenCollection([root], { collectionName: 'API' });
213
+ const yml = files.find(f => f.relativePath === 'reports/get-reports-date.yml');
214
+ expect(yml!.content).toContain('value: "2024-01-01"');
215
+ });
216
+
217
+ it('uses ISO 8601 duration example value for duration path params', () => {
218
+ const root = opRoot(
219
+ [opRoute('/jobs/{timeout}', [opOperation('get')], paramNodes([opParam('timeout', scalarType('duration'))]))],
220
+ 'jobs.op',
221
+ );
222
+ const files = generateOpenCollection([root], { collectionName: 'API' });
223
+ const yml = files.find(f => f.relativePath === 'jobs/get-jobs-timeout.yml');
224
+ expect(yml!.content).toContain('value: "PT1H"');
225
+ });
226
+
227
+ it('uses first enum value as example for enum path params', () => {
228
+ const root = opRoot(
229
+ [opRoute('/items/{status}', [opOperation('get')], paramNodes([opParam('status', enumType('active', 'archived'))]))],
230
+ 'items.op',
231
+ );
232
+ const files = generateOpenCollection([root], { collectionName: 'API' });
233
+ const yml = files.find(f => f.relativePath === 'items/get-items-status.yml');
234
+ expect(yml!.content).toContain('value: "active"');
235
+ });
236
+
237
+ it('falls back to empty string for untyped path params', () => {
238
+ // params not declared — route.params is undefined
239
+ const root = opRoot([opRoute('/users/{id}', [opOperation('get')])], 'users.op');
240
+ const files = generateOpenCollection([root], { collectionName: 'API' });
241
+ const yml = files.find(f => f.relativePath === 'users/get-users-id.yml');
242
+ expect(yml!.content).toContain('value: ""');
243
+ });
244
+
245
+ it('does not generate params block when path has no params and no query', () => {
246
+ const root = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
247
+ const files = generateOpenCollection([root], { collectionName: 'API' });
248
+ expect(files.find(f => f.relativePath === 'users/get-users.yml')!.content).not.toContain('params:');
249
+ });
250
+
251
+ // ─── Query params ───────────────────────────────────────────────────────
252
+
253
+ it('generates query params as flat array entries with type: query', () => {
254
+ const root = opRoot(
255
+ [
256
+ opRoute('/users', [
257
+ opOperation('get', {
258
+ query: paramNodes([opParam('limit', scalarType('int')), opParam('offset', scalarType('int'))]),
259
+ }),
260
+ ]),
261
+ ],
262
+ 'users.op',
263
+ );
264
+ const files = generateOpenCollection([root], { collectionName: 'API' });
265
+ const yml = files.find(f => f.relativePath === 'users/get-users.yml');
266
+ expect(yml!.content).toContain('- name: limit');
267
+ expect(yml!.content).toContain('- name: offset');
268
+ expect(yml!.content).toContain('type: query');
269
+ expect(yml!.content).toContain('value: "0"');
270
+ });
271
+
272
+ it('uses typed example values for query params', () => {
273
+ const root = opRoot(
274
+ [
275
+ opRoute('/users', [
276
+ opOperation('get', {
277
+ query: paramNodes([opParam('email', scalarType('email'))]),
278
+ }),
279
+ ]),
280
+ ],
281
+ 'users.op',
282
+ );
283
+ const files = generateOpenCollection([root], { collectionName: 'API' });
284
+ const yml = files.find(f => f.relativePath === 'users/get-users.yml');
285
+ expect(yml!.content).toContain('value: "user@example.com"');
286
+ });
287
+
288
+ it('falls back to single placeholder entry for ref query params with no registry', () => {
289
+ const root = opRoot(
290
+ [opRoute('/users', [opOperation('get', { query: paramRef('UserQuery') })])],
291
+ 'users.op',
292
+ );
293
+ const files = generateOpenCollection([root], { collectionName: 'API' });
294
+ const yml = files.find(f => f.relativePath === 'users/get-users.yml');
295
+ expect(yml!.content).toContain('- name: userQuery');
296
+ expect(yml!.content).toContain('type: query');
297
+ });
298
+
299
+ it('expands ref query params into individual fields when model registry provided', () => {
300
+ const paginationModel = model('Pagination', [
301
+ field('page', scalarType('int'), { optional: true }),
302
+ field('pageSize', scalarType('int'), { optional: true }),
303
+ field('total', scalarType('int'), { visibility: 'readonly' }),
304
+ ]);
305
+ const root = opRoot(
306
+ [opRoute('/users', [opOperation('get', { query: paramRef('Pagination') })])],
307
+ 'users.op',
308
+ );
309
+ const files = generateOpenCollection([root], { collectionName: 'API', contractRoots: [contractRoot([paginationModel])] });
310
+ const yml = files.find(f => f.relativePath === 'users/get-users.yml');
311
+ expect(yml!.content).toContain('- name: page');
312
+ expect(yml!.content).toContain('- name: pageSize');
313
+ expect(yml!.content).not.toContain('- name: total');
314
+ expect(yml!.content).not.toContain('- name: pagination');
315
+ });
316
+
317
+ it('mixes path and query params in the same flat array', () => {
318
+ const root = opRoot(
319
+ [
320
+ opRoute('/users/{id}', [
321
+ opOperation('get', {
322
+ query: paramNodes([opParam('include', scalarType('string'))]),
323
+ }),
324
+ ]),
325
+ ],
326
+ 'users.op',
327
+ );
328
+ const files = generateOpenCollection([root], { collectionName: 'API' });
329
+ const yml = files.find(f => f.relativePath === 'users/get-users-id.yml');
330
+ expect(yml!.content).toContain('type: path');
331
+ expect(yml!.content).toContain('type: query');
332
+ });
333
+
334
+ // ─── Headers ────────────────────────────────────────────────────────────
335
+
336
+ it('generates headers block from op.headers inline params', () => {
337
+ const root = opRoot(
338
+ [
339
+ opRoute('/events', [
340
+ opOperation('post', {
341
+ headers: paramNodes([opParam('X-Idempotency-Key', scalarType('uuid'))]),
342
+ request: opRequest('EventInput'),
343
+ }),
344
+ ]),
345
+ ],
346
+ 'events.op',
347
+ );
348
+ const files = generateOpenCollection([root], { collectionName: 'API' });
349
+ const yml = files.find(f => f.relativePath === 'events/post-events.yml');
350
+ expect(yml!.content).toContain('headers:');
351
+ expect(yml!.content).toContain('- name: X-Idempotency-Key');
352
+ expect(yml!.content).toContain('value: "00000000-0000-0000-0000-000000000000"');
353
+ });
354
+
355
+ it('falls back to single placeholder entry for ref header source with no registry', () => {
356
+ const root = opRoot(
357
+ [opRoute('/items', [opOperation('get', { headers: paramRef('AuthHeaders') })])],
358
+ 'items.op',
359
+ );
360
+ const files = generateOpenCollection([root], { collectionName: 'API' });
361
+ const yml = files.find(f => f.relativePath === 'items/get-items.yml');
362
+ expect(yml!.content).toContain('headers:');
363
+ expect(yml!.content).toContain('- name: authHeaders');
364
+ });
365
+
366
+ it('expands ref header source into individual fields when model registry provided', () => {
367
+ const headersModel = model('AuthHeaders', [
368
+ field('X-Api-Key', scalarType('string')),
369
+ field('X-Idempotency-Key', scalarType('uuid'), { optional: true }),
370
+ ]);
371
+ const root = opRoot(
372
+ [opRoute('/items', [opOperation('post', { headers: paramRef('AuthHeaders') })])],
373
+ 'items.op',
374
+ );
375
+ const files = generateOpenCollection([root], { collectionName: 'API', contractRoots: [contractRoot([headersModel])] });
376
+ const yml = files.find(f => f.relativePath === 'items/post-items.yml');
377
+ expect(yml!.content).toContain('- name: X-Api-Key');
378
+ expect(yml!.content).toContain('- name: X-Idempotency-Key');
379
+ expect(yml!.content).not.toContain('- name: authHeaders');
380
+ });
381
+
382
+ it('does not generate headers block when op has no headers', () => {
383
+ const root = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
384
+ const files = generateOpenCollection([root], { collectionName: 'API' });
385
+ const yml = files.find(f => f.relativePath === 'users/get-users.yml');
386
+ expect(yml!.content).not.toContain('headers:');
387
+ });
388
+
389
+ // ─── Request body ────────────────────────────────────────────────────────
390
+
391
+ it('generates body with type: json and data block literal for JSON requests', () => {
392
+ const root = opRoot(
393
+ [opRoute('/users', [opOperation('post', { request: opRequest('CreateUserInput') })])],
394
+ 'users.op',
395
+ );
396
+ const files = generateOpenCollection([root], { collectionName: 'API' });
397
+ const yml = files.find(f => f.relativePath === 'users/post-users.yml');
398
+ expect(yml!.content).toContain('type: json');
399
+ expect(yml!.content).toContain('data: |');
400
+ });
401
+
402
+ it('expands inline object body type into a JSON skeleton', () => {
403
+ const bodyType = inlineObjectType([
404
+ field('name', scalarType('string')),
405
+ field('email', scalarType('email')),
406
+ field('age', scalarType('int')),
407
+ ]);
408
+ const root = opRoot(
409
+ [opRoute('/users', [opOperation('post', { request: opRequest(bodyType) })])],
410
+ 'users.op',
411
+ );
412
+ const files = generateOpenCollection([root], { collectionName: 'API' });
413
+ const yml = files.find(f => f.relativePath === 'users/post-users.yml');
414
+ expect(yml!.content).toContain('"name": ""');
415
+ expect(yml!.content).toContain('"email": "user@example.com"');
416
+ expect(yml!.content).toContain('"age": 0');
417
+ });
418
+
419
+ it('uses ISO 8601 duration example value in body skeleton', () => {
420
+ const bodyType = inlineObjectType([field('timeout', scalarType('duration'))]);
421
+ const root = opRoot(
422
+ [opRoute('/jobs', [opOperation('post', { request: opRequest(bodyType) })])],
423
+ 'jobs.op',
424
+ );
425
+ const files = generateOpenCollection([root], { collectionName: 'API' });
426
+ const yml = files.find(f => f.relativePath === 'jobs/post-jobs.yml');
427
+ expect(yml!.content).toContain('"timeout": "PT1H"');
428
+ });
429
+
430
+ it('excludes readonly fields from inline object body skeleton', () => {
431
+ const bodyType = inlineObjectType([
432
+ field('id', scalarType('uuid'), { visibility: 'readonly' }),
433
+ field('name', scalarType('string')),
434
+ ]);
435
+ const root = opRoot(
436
+ [opRoute('/users', [opOperation('post', { request: opRequest(bodyType) })])],
437
+ 'users.op',
438
+ );
439
+ const files = generateOpenCollection([root], { collectionName: 'API' });
440
+ const yml = files.find(f => f.relativePath === 'users/post-users.yml');
441
+ expect(yml!.content).not.toContain('"id"');
442
+ expect(yml!.content).toContain('"name": ""');
443
+ });
444
+
445
+ it('uses empty object for ref body types when no contractRoots provided', () => {
446
+ const root = opRoot(
447
+ [opRoute('/users', [opOperation('post', { request: opRequest('CreateUserInput') })])],
448
+ 'users.op',
449
+ );
450
+ const files = generateOpenCollection([root], { collectionName: 'API' });
451
+ const yml = files.find(f => f.relativePath === 'users/post-users.yml');
452
+ expect(yml!.content).toContain('data: |');
453
+ expect(yml!.content).toContain('{}');
454
+ });
455
+
456
+ it('expands ref body type into a JSON skeleton when contractRoots provided', () => {
457
+ const userModel = model('CreateUserInput', [
458
+ field('name', scalarType('string')),
459
+ field('email', scalarType('email')),
460
+ ]);
461
+ const root = opRoot(
462
+ [opRoute('/users', [opOperation('post', { request: opRequest('CreateUserInput') })])],
463
+ 'users.op',
464
+ );
465
+ const files = generateOpenCollection([root], { collectionName: 'API', contractRoots: [contractRoot([userModel])] });
466
+ const yml = files.find(f => f.relativePath === 'users/post-users.yml');
467
+ expect(yml!.content).toContain('"name": ""');
468
+ expect(yml!.content).toContain('"email": "user@example.com"');
469
+ });
470
+
471
+ it('excludes readonly fields from expanded ref body', () => {
472
+ const userModel = model('CreateUserInput', [
473
+ field('id', scalarType('uuid'), { visibility: 'readonly' }),
474
+ field('name', scalarType('string')),
475
+ ]);
476
+ const root = opRoot(
477
+ [opRoute('/users', [opOperation('post', { request: opRequest('CreateUserInput') })])],
478
+ 'users.op',
479
+ );
480
+ const files = generateOpenCollection([root], { collectionName: 'API', contractRoots: [contractRoot([userModel])] });
481
+ const yml = files.find(f => f.relativePath === 'users/post-users.yml');
482
+ expect(yml!.content).not.toContain('"id"');
483
+ expect(yml!.content).toContain('"name": ""');
484
+ });
485
+
486
+ it('sets optional fields to null in expanded ref body', () => {
487
+ const userModel = model('CreateUserInput', [
488
+ field('name', scalarType('string')),
489
+ field('nickname', scalarType('string'), { optional: true }),
490
+ ]);
491
+ const root = opRoot(
492
+ [opRoute('/users', [opOperation('post', { request: opRequest('CreateUserInput') })])],
493
+ 'users.op',
494
+ );
495
+ const files = generateOpenCollection([root], { collectionName: 'API', contractRoots: [contractRoot([userModel])] });
496
+ const yml = files.find(f => f.relativePath === 'users/post-users.yml');
497
+ expect(yml!.content).toContain('"name": ""');
498
+ expect(yml!.content).toContain('"nickname": null');
499
+ });
500
+
501
+ it('expands inherited fields from base model in ref body', () => {
502
+ const baseModel = model('BaseEntity', [field('id', scalarType('uuid'), { visibility: 'readonly' })]);
503
+ const userModel = model('CreateUserInput', [field('name', scalarType('string'))], { bases: ['BaseEntity'] });
504
+ const root = opRoot(
505
+ [opRoute('/users', [opOperation('post', { request: opRequest('CreateUserInput') })])],
506
+ 'users.op',
507
+ );
508
+ const files = generateOpenCollection([root], { collectionName: 'API', contractRoots: [contractRoot([baseModel, userModel])] });
509
+ const yml = files.find(f => f.relativePath === 'users/post-users.yml');
510
+ // readonly id from base is excluded
511
+ expect(yml!.content).not.toContain('"id"');
512
+ expect(yml!.content).toContain('"name": ""');
513
+ });
514
+
515
+ it('uses field default value in body for non-optional fields', () => {
516
+ const bodyType = inlineObjectType([
517
+ field('status', enumType('pending', 'active'), { default: 'pending' }),
518
+ field('priority', scalarType('int'), { default: 1 }),
519
+ ]);
520
+ const root = opRoot(
521
+ [opRoute('/items', [opOperation('post', { request: opRequest(bodyType) })])],
522
+ 'items.op',
523
+ );
524
+ const files = generateOpenCollection([root], { collectionName: 'API' });
525
+ const yml = files.find(f => f.relativePath === 'items/post-items.yml');
526
+ expect(yml!.content).toContain('"status": "pending"');
527
+ expect(yml!.content).toContain('"priority": 1');
528
+ });
529
+
530
+ it('uses field default value in body for optional fields', () => {
531
+ const bodyType = inlineObjectType([
532
+ field('status', enumType('pending', 'active'), { optional: true, default: 'pending' }),
533
+ ]);
534
+ const root = opRoot(
535
+ [opRoute('/items', [opOperation('post', { request: opRequest(bodyType) })])],
536
+ 'items.op',
537
+ );
538
+ const files = generateOpenCollection([root], { collectionName: 'API' });
539
+ const yml = files.find(f => f.relativePath === 'items/post-items.yml');
540
+ expect(yml!.content).toContain('"status": "pending"');
541
+ });
542
+
543
+ it('uses field defaults from expanded ref model body', () => {
544
+ const itemModel = model('CreateItemInput', [
545
+ field('status', enumType('draft', 'published'), { default: 'draft' }),
546
+ field('count', scalarType('int'), { default: 0 }),
547
+ ]);
548
+ const root = opRoot(
549
+ [opRoute('/items', [opOperation('post', { request: opRequest('CreateItemInput') })])],
550
+ 'items.op',
551
+ );
552
+ const files = generateOpenCollection([root], { collectionName: 'API', contractRoots: [contractRoot([itemModel])] });
553
+ const yml = files.find(f => f.relativePath === 'items/post-items.yml');
554
+ expect(yml!.content).toContain('"status": "draft"');
555
+ expect(yml!.content).toContain('"count": 0');
556
+ });
557
+
558
+ it('uses first enum value in body', () => {
559
+ const bodyType = inlineObjectType([field('status', enumType('pending', 'active', 'archived'))]);
560
+ const root = opRoot(
561
+ [opRoute('/items', [opOperation('post', { request: opRequest(bodyType) })])],
562
+ 'items.op',
563
+ );
564
+ const files = generateOpenCollection([root], { collectionName: 'API' });
565
+ const yml = files.find(f => f.relativePath === 'items/post-items.yml');
566
+ expect(yml!.content).toContain('"status": "pending"');
567
+ });
568
+
569
+ it('uses example values for nested array fields in body', () => {
570
+ const bodyType = inlineObjectType([field('tags', arrayType(scalarType('string')))]);
571
+ const root = opRoot(
572
+ [opRoute('/posts', [opOperation('post', { request: opRequest(bodyType) })])],
573
+ 'posts.op',
574
+ );
575
+ const files = generateOpenCollection([root], { collectionName: 'API' });
576
+ const yml = files.find(f => f.relativePath === 'posts/post-posts.yml');
577
+ expect(yml!.content).toContain('"tags": [');
578
+ });
579
+
580
+ it('leaves ref fields as empty objects in body', () => {
581
+ const bodyType = inlineObjectType([field('address', refType('Address'))]);
582
+ const root = opRoot(
583
+ [opRoute('/users', [opOperation('post', { request: opRequest(bodyType) })])],
584
+ 'users.op',
585
+ );
586
+ const files = generateOpenCollection([root], { collectionName: 'API' });
587
+ const yml = files.find(f => f.relativePath === 'users/post-users.yml');
588
+ expect(yml!.content).toContain('"address": {}');
589
+ });
590
+
591
+ it('generates body with type: multipart-form for multipart requests', () => {
592
+ const root = opRoot(
593
+ [opRoute('/uploads', [opOperation('post', { request: opRequest('UploadInput', 'multipart/form-data') })])],
594
+ 'uploads.op',
595
+ );
596
+ const files = generateOpenCollection([root], { collectionName: 'API' });
597
+ const yml = files.find(f => f.relativePath === 'uploads/post-uploads.yml');
598
+ expect(yml!.content).toContain('type: multipart-form');
599
+ expect(yml!.content).not.toContain('type: json');
600
+ });
601
+
602
+ it('does not generate body block when no request body', () => {
603
+ const root = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
604
+ const files = generateOpenCollection([root], { collectionName: 'API' });
605
+ expect(files.find(f => f.relativePath === 'users/get-users.yml')!.content).not.toContain('body:');
606
+ });
607
+
608
+ // ─── Misc ────────────────────────────────────────────────────────────────
609
+
610
+ it('handles empty roots array', () => {
611
+ const files = generateOpenCollection([], { collectionName: 'Empty' });
612
+ // opencollection.yml + environments/local.yml + manifest
613
+ expect(files).toHaveLength(3);
614
+ expect(files.some(f => f.relativePath === MANIFEST_FILENAME)).toBe(true);
615
+ });
616
+
617
+ it('derives folder name from file path with directory prefix', () => {
618
+ const root = opRoot([opRoute('/payments', [opOperation('get')])], 'src/api/payments.op');
619
+ const files = generateOpenCollection([root], { collectionName: 'API' });
620
+ expect(files.some(f => f.relativePath.startsWith('payments/'))).toBe(true);
621
+ });
622
+
623
+ it('does not generate any .bru files', () => {
624
+ const root = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
625
+ const files = generateOpenCollection([root], { collectionName: 'API' });
626
+ expect(files.every(f => !f.relativePath.endsWith('.bru'))).toBe(true);
627
+ });
628
+
629
+ // ─── Auth ─────────────────────────────────────────────────────────────────
630
+
631
+ const bearerAuth = { defaultScheme: 'bearerAuth', schemes: { bearerAuth: { type: 'http', scheme: 'bearer' } } };
632
+ const apiKeyAuth = { defaultScheme: 'apiKey', schemes: { apiKey: { type: 'apiKey', in: 'header', name: 'X-Api-Key' } } };
633
+ const basicAuth = { defaultScheme: 'basicAuth', schemes: { basicAuth: { type: 'http', scheme: 'basic' } } };
634
+
635
+ it('adds bearer auth block to opencollection.yml when security config provided', () => {
636
+ const root = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
637
+ const files = generateOpenCollection([root], { collectionName: 'API', auth: bearerAuth });
638
+ const col = files.find(f => f.relativePath === 'opencollection.yml');
639
+ expect(col!.content).toContain('request:');
640
+ expect(col!.content).toContain(' auth:');
641
+ expect(col!.content).toContain('type: bearer');
642
+ expect(col!.content).toContain('token: "{{token}}"');
643
+ });
644
+
645
+ it('adds apikey auth block to opencollection.yml', () => {
646
+ const root = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
647
+ const files = generateOpenCollection([root], { collectionName: 'API', auth: apiKeyAuth });
648
+ const col = files.find(f => f.relativePath === 'opencollection.yml');
649
+ expect(col!.content).toContain('request:');
650
+ expect(col!.content).toContain(' auth:');
651
+ expect(col!.content).toContain('type: apikey');
652
+ expect(col!.content).toContain('key: X-Api-Key');
653
+ expect(col!.content).toContain('value: "{{apiKey}}"');
654
+ });
655
+
656
+ it('adds basic auth block to opencollection.yml', () => {
657
+ const root = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
658
+ const files = generateOpenCollection([root], { collectionName: 'API', auth: basicAuth });
659
+ const col = files.find(f => f.relativePath === 'opencollection.yml');
660
+ expect(col!.content).toContain('request:');
661
+ expect(col!.content).toContain(' auth:');
662
+ expect(col!.content).toContain('type: basic');
663
+ expect(col!.content).toContain('username: "{{username}}"');
664
+ expect(col!.content).toContain('password: "{{password}}"');
665
+ });
666
+
667
+ it('adds auth env vars to local.yml for bearer', () => {
668
+ const root = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
669
+ const files = generateOpenCollection([root], { collectionName: 'API', auth: bearerAuth });
670
+ const env = files.find(f => f.relativePath === 'environments/local.yml');
671
+ expect(env!.content).toContain('- name: token');
672
+ });
673
+
674
+ it('does not add request or auth to opencollection.yml when no security config', () => {
675
+ const root = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
676
+ const files = generateOpenCollection([root], { collectionName: 'API' });
677
+ const col = files.find(f => f.relativePath === 'opencollection.yml');
678
+ expect(col!.content).not.toContain('request:');
679
+ expect(col!.content).not.toContain('auth:');
680
+ });
681
+
682
+ it('adds auth: none inside http block when operation security is none', () => {
683
+ const root = opRoot([opRoute('/public', [opOperation('get', { security: 'none' })])], 'public.op');
684
+ const files = generateOpenCollection([root], { collectionName: 'API', auth: bearerAuth });
685
+ const yml = files.find(f => f.relativePath === 'public/get-public.yml');
686
+ expect(yml!.content).toContain(' auth:');
687
+ expect(yml!.content).toContain(' type: none');
688
+ expect(yml!.content).not.toContain('request:');
689
+ });
690
+
691
+ it('adds auth: inherit inside http block for normal operations when default scheme is set', () => {
692
+ const root = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
693
+ const files = generateOpenCollection([root], { collectionName: 'API', auth: bearerAuth });
694
+ const yml = files.find(f => f.relativePath === 'users/get-users.yml');
695
+ expect(yml!.content).toContain(' auth: inherit');
696
+ });
697
+
698
+ it('does not add auth when no default scheme is set', () => {
699
+ const root = opRoot([opRoute('/public', [opOperation('get', { security: 'none' })])], 'public.op');
700
+ const files = generateOpenCollection([root], { collectionName: 'API' });
701
+ const yml = files.find(f => f.relativePath === 'public/get-public.yml');
702
+ expect(yml!.content).not.toContain('auth:');
703
+ });
704
+
705
+ // ─── runtime.assertions (response status check) ─────────────────────────
706
+
707
+ it('emits a status-code assertion using the first declared 2xx response', () => {
708
+ const root = opRoot(
709
+ [opRoute('/users', [opOperation('post', { responses: [opResponse(201), opResponse(400)] })])],
710
+ 'users.op',
711
+ );
712
+ const files = generateOpenCollection([root], { collectionName: 'API' });
713
+ const yml = files.find(f => f.relativePath === 'users/post-users.yml');
714
+ expect(yml!.content).toContain('runtime:');
715
+ expect(yml!.content).toContain(' assertions:');
716
+ expect(yml!.content).toContain(' - expression: res.status');
717
+ expect(yml!.content).toContain(' operator: eq');
718
+ expect(yml!.content).toContain(' value: "201"');
719
+ });
720
+
721
+ it('falls back to the first response when no 2xx is declared', () => {
722
+ const root = opRoot(
723
+ [opRoute('/users', [opOperation('get', { responses: [opResponse(404)] })])],
724
+ 'users.op',
725
+ );
726
+ const files = generateOpenCollection([root], { collectionName: 'API' });
727
+ const yml = files.find(f => f.relativePath === 'users/get-users.yml');
728
+ expect(yml!.content).toContain('value: "404"');
729
+ });
730
+
731
+ it('does not emit a runtime block when the operation declares no responses', () => {
732
+ const root = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
733
+ const files = generateOpenCollection([root], { collectionName: 'API' });
734
+ const yml = files.find(f => f.relativePath === 'users/get-users.yml');
735
+ expect(yml!.content).not.toContain('runtime:');
736
+ });
737
+
738
+ it('emits assertions for required response headers and lists them in the docs', () => {
739
+ const root = opRoot(
740
+ [
741
+ opRoute(
742
+ '/transfers/{id}',
743
+ [
744
+ opOperation('get', {
745
+ responses: [
746
+ {
747
+ statusCode: 200,
748
+ contentType: 'application/json',
749
+ bodyType: { kind: 'ref', name: 'Transfer' },
750
+ headers: [
751
+ { name: 'preference-applied', optional: true, type: { kind: 'scalar', name: 'string' } },
752
+ { name: 'ETag', optional: false, type: { kind: 'scalar', name: 'string' }, description: 'cache validator' },
753
+ ],
754
+ },
755
+ ],
756
+ }),
757
+ ],
758
+ ),
759
+ ],
760
+ 'transfers.op',
761
+ );
762
+ const files = generateOpenCollection([root], { collectionName: 'API' });
763
+ const yml = files.find(f => f.relativePath === 'transfers/get-transfers-id.yml');
764
+ expect(yml!.content).toContain('value: "200"');
765
+ // Required header gets an assertion using lowercased name; optional one does not.
766
+ expect(yml!.content).toContain(' - expression: res.headers["etag"]');
767
+ expect(yml!.content).toContain(' operator: isDefined');
768
+ expect(yml!.content).not.toContain('res.headers["preference-applied"]');
769
+ // Both headers documented.
770
+ expect(yml!.content).toContain('**Response headers**');
771
+ expect(yml!.content).toContain('- `preference-applied` (optional)');
772
+ expect(yml!.content).toContain('- `ETag` (required) — cache validator');
773
+ });
774
+
775
+ // ─── docs ──────────────────────────────────────────────────────────────
776
+
777
+ it('emits a docs block from the operation description', () => {
778
+ const root = opRoot(
779
+ [opRoute('/users', [opOperation('get', { description: 'Lists every user.' })])],
780
+ 'users.op',
781
+ );
782
+ const files = generateOpenCollection([root], { collectionName: 'API' });
783
+ const yml = files.find(f => f.relativePath === 'users/get-users.yml');
784
+ expect(yml!.content).toContain('docs: |-');
785
+ expect(yml!.content).toContain(' Lists every user.');
786
+ });
787
+
788
+ it('combines route and operation descriptions into the docs block', () => {
789
+ const root = opRoot(
790
+ [
791
+ opRoute(
792
+ '/users',
793
+ [opOperation('get', { description: 'GET semantics.' })],
794
+ undefined,
795
+ undefined,
796
+ { description: 'User-management endpoints.' },
797
+ ),
798
+ ],
799
+ 'users.op',
800
+ );
801
+ const files = generateOpenCollection([root], { collectionName: 'API' });
802
+ const yml = files.find(f => f.relativePath === 'users/get-users.yml');
803
+ expect(yml!.content).toContain(' User-management endpoints.');
804
+ expect(yml!.content).toContain(' GET semantics.');
805
+ });
806
+
807
+ it('does not emit a docs block when no description is set', () => {
808
+ const root = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
809
+ const files = generateOpenCollection([root], { collectionName: 'API' });
810
+ const yml = files.find(f => f.relativePath === 'users/get-users.yml');
811
+ expect(yml!.content).not.toContain('docs:');
812
+ });
813
+
814
+ // ─── disabled flag for optional params/headers ────────────────────────
815
+
816
+ it('marks optional query params with disabled: true', () => {
817
+ const root = opRoot(
818
+ [
819
+ opRoute('/users', [
820
+ opOperation('get', {
821
+ query: paramNodes([
822
+ opParam('limit', scalarType('int'), { optional: true }),
823
+ opParam('cursor', scalarType('string')),
824
+ ]),
825
+ }),
826
+ ]),
827
+ ],
828
+ 'users.op',
829
+ );
830
+ const files = generateOpenCollection([root], { collectionName: 'API' });
831
+ const yml = files.find(f => f.relativePath === 'users/get-users.yml');
832
+ // limit (optional) is disabled; cursor (required) is not
833
+ const limitBlock = yml!.content.match(/- name: limit[\s\S]*?(?=- name:|headers:|body:|runtime:|docs:|$)/)?.[0] ?? '';
834
+ const cursorBlock = yml!.content.match(/- name: cursor[\s\S]*?(?=- name:|headers:|body:|runtime:|docs:|$)/)?.[0] ?? '';
835
+ expect(limitBlock).toContain('disabled: true');
836
+ expect(cursorBlock).not.toContain('disabled: true');
837
+ });
838
+
839
+ it('marks optional headers with disabled: true', () => {
840
+ const root = opRoot(
841
+ [
842
+ opRoute('/events', [
843
+ opOperation('post', {
844
+ headers: paramNodes([
845
+ opParam('X-Idempotency-Key', scalarType('uuid'), { optional: true }),
846
+ opParam('X-Trace-Id', scalarType('string')),
847
+ ]),
848
+ }),
849
+ ]),
850
+ ],
851
+ 'events.op',
852
+ );
853
+ const files = generateOpenCollection([root], { collectionName: 'API' });
854
+ const yml = files.find(f => f.relativePath === 'events/post-events.yml');
855
+ const idemBlock = yml!.content.match(/- name: X-Idempotency-Key[\s\S]*?(?=- name:|body:|runtime:|docs:|$)/)?.[0] ?? '';
856
+ const traceBlock = yml!.content.match(/- name: X-Trace-Id[\s\S]*?(?=- name:|body:|runtime:|docs:|$)/)?.[0] ?? '';
857
+ expect(idemBlock).toContain('disabled: true');
858
+ expect(traceBlock).not.toContain('disabled: true');
859
+ });
860
+
861
+ it('does not mark path params as disabled even though they have no optional flag', () => {
862
+ const root = opRoot([opRoute('/users/{id}', [opOperation('get')])], 'users.op');
863
+ const files = generateOpenCollection([root], { collectionName: 'API' });
864
+ const yml = files.find(f => f.relativePath === 'users/get-users-id.yml');
865
+ expect(yml!.content).not.toContain('disabled:');
866
+ });
867
+
868
+ it('marks optional fields from a ref-expanded query model as disabled', () => {
869
+ const queryModel = model('UserQuery', [
870
+ field('limit', scalarType('int'), { optional: true }),
871
+ field('search', scalarType('string')),
872
+ ]);
873
+ const root = opRoot(
874
+ [opRoute('/users', [opOperation('get', { query: paramRef('UserQuery') })])],
875
+ 'users.op',
876
+ );
877
+ const files = generateOpenCollection([root], { collectionName: 'API', contractRoots: [contractRoot([queryModel])] });
878
+ const yml = files.find(f => f.relativePath === 'users/get-users.yml');
879
+ const limitBlock = yml!.content.match(/- name: limit[\s\S]*?(?=- name:|headers:|body:|runtime:|docs:|$)/)?.[0] ?? '';
880
+ const searchBlock = yml!.content.match(/- name: search[\s\S]*?(?=- name:|headers:|body:|runtime:|docs:|$)/)?.[0] ?? '';
881
+ expect(limitBlock).toContain('disabled: true');
882
+ expect(searchBlock).not.toContain('disabled: true');
883
+ });
884
+
885
+ // ─── Manifest ──────────────────────────────────────────────────────────
886
+
887
+ it('emits a manifest listing every generated file', () => {
888
+ const root = opRoot([opRoute('/users', [opOperation('get'), opOperation('post')])], 'users.op');
889
+ const files = generateOpenCollection([root], { collectionName: 'API' });
890
+ const manifest = files.find(f => f.relativePath === MANIFEST_FILENAME);
891
+ expect(manifest).toBeDefined();
892
+ const tracked = parseManifest(manifest!.content);
893
+ expect(tracked).toContain('opencollection.yml');
894
+ expect(tracked).toContain('environments/local.yml');
895
+ expect(tracked).toContain('users/folder.yml');
896
+ expect(tracked).toContain('users/get-users.yml');
897
+ expect(tracked).toContain('users/post-users.yml');
898
+ expect(tracked).toContain(MANIFEST_FILENAME);
899
+ });
900
+
901
+ it('parseManifest returns [] for malformed input', () => {
902
+ expect(parseManifest('not json')).toEqual([]);
903
+ expect(parseManifest('{}')).toEqual([]);
904
+ expect(parseManifest('{"files": "nope"}')).toEqual([]);
905
+ expect(parseManifest('{"files": [1, 2, 3]}')).toEqual([]);
906
+ });
907
+
908
+ // ─── randomExamples ───────────────────────────────────────────────────
909
+
910
+ it('emits Bruno faker templates for compatible scalar params when randomExamples is true', () => {
911
+ const root = opRoot(
912
+ [
913
+ opRoute(
914
+ '/users/{id}',
915
+ [
916
+ opOperation('get', {
917
+ query: paramNodes([
918
+ opParam('email', scalarType('email')),
919
+ opParam('limit', scalarType('int')),
920
+ opParam('active', scalarType('boolean')),
921
+ opParam('since', scalarType('datetime')),
922
+ ]),
923
+ }),
924
+ ],
925
+ paramNodes([opParam('id', scalarType('uuid'))]),
926
+ ),
927
+ ],
928
+ 'users.op',
929
+ );
930
+ const files = generateOpenCollection([root], { collectionName: 'API', randomExamples: true });
931
+ const yml = files.find(f => f.relativePath === 'users/get-users-id.yml');
932
+ expect(yml!.content).toContain('value: "{{$randomUUID}}"');
933
+ expect(yml!.content).toContain('value: "{{$randomEmail}}"');
934
+ expect(yml!.content).toContain('value: "{{$randomInt}}"');
935
+ expect(yml!.content).toContain('value: "{{$randomBoolean}}"');
936
+ expect(yml!.content).toContain('value: "{{$isoTimestamp}}"');
937
+ });
938
+
939
+ it('keeps deterministic placeholders when randomExamples is false', () => {
940
+ const root = opRoot(
941
+ [opRoute('/users/{id}', [opOperation('get')], paramNodes([opParam('id', scalarType('uuid'))]))],
942
+ 'users.op',
943
+ );
944
+ const files = generateOpenCollection([root], { collectionName: 'API', randomExamples: false });
945
+ const yml = files.find(f => f.relativePath === 'users/get-users-id.yml');
946
+ expect(yml!.content).toContain('value: "00000000-0000-0000-0000-000000000000"');
947
+ expect(yml!.content).not.toContain('{{$randomUUID}}');
948
+ });
949
+
950
+ it('uses faker templates inside JSON body skeletons for string-valued scalars', () => {
951
+ const bodyType = inlineObjectType([
952
+ field('id', scalarType('uuid')),
953
+ field('email', scalarType('email')),
954
+ field('createdAt', scalarType('datetime')),
955
+ field('age', scalarType('int')),
956
+ field('active', scalarType('boolean')),
957
+ ]);
958
+ const root = opRoot(
959
+ [opRoute('/users', [opOperation('post', { request: opRequest(bodyType) })])],
960
+ 'users.op',
961
+ );
962
+ const files = generateOpenCollection([root], { collectionName: 'API', randomExamples: true });
963
+ const yml = files.find(f => f.relativePath === 'users/post-users.yml');
964
+ expect(yml!.content).toContain('"id": "{{$randomUUID}}"');
965
+ expect(yml!.content).toContain('"email": "{{$randomEmail}}"');
966
+ expect(yml!.content).toContain('"createdAt": "{{$isoTimestamp}}"');
967
+ // Numbers and booleans stay deterministic so the JSON skeleton is valid.
968
+ expect(yml!.content).toContain('"age": 0');
969
+ expect(yml!.content).toContain('"active": true');
970
+ });
971
+
972
+ it('does not override field defaults when randomExamples is true', () => {
973
+ const bodyType = inlineObjectType([
974
+ field('status', enumType('pending', 'active'), { default: 'pending' }),
975
+ field('id', scalarType('uuid')),
976
+ ]);
977
+ const root = opRoot(
978
+ [opRoute('/items', [opOperation('post', { request: opRequest(bodyType) })])],
979
+ 'items.op',
980
+ );
981
+ const files = generateOpenCollection([root], { collectionName: 'API', randomExamples: true });
982
+ const yml = files.find(f => f.relativePath === 'items/post-items.yml');
983
+ expect(yml!.content).toContain('"status": "pending"');
984
+ expect(yml!.content).toContain('"id": "{{$randomUUID}}"');
985
+ });
986
+ });
987
+
988
+ describe('sanitizePath', () => {
989
+ it('converts simple path to filename-safe string', () => {
990
+ expect(sanitizePath('/users')).toBe('users');
991
+ });
992
+
993
+ it('replaces path params with their names', () => {
994
+ expect(sanitizePath('/users/{id}')).toBe('users-id');
995
+ });
996
+
997
+ it('handles multiple segments and params', () => {
998
+ expect(sanitizePath('/orgs/{orgId}/users/{userId}')).toBe('orgs-orgId-users-userId');
999
+ });
1000
+
1001
+ it('returns root for bare slash', () => {
1002
+ expect(sanitizePath('/')).toBe('root');
1003
+ });
1004
+
1005
+ it('collapses consecutive dashes', () => {
1006
+ expect(sanitizePath('/users//posts')).toBe('users-posts');
1007
+ });
1008
+ });