@workos/oagen-emitters 0.3.0 → 0.4.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 (65) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +7 -0
  3. package/dist/index.d.mts +4 -1
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +3288 -791
  6. package/dist/index.mjs.map +1 -1
  7. package/docs/sdk-architecture/dotnet.md +336 -0
  8. package/oagen.config.ts +42 -12
  9. package/package.json +2 -2
  10. package/smoke/sdk-dotnet.ts +45 -12
  11. package/src/dotnet/client.ts +89 -0
  12. package/src/dotnet/enums.ts +323 -0
  13. package/src/dotnet/fixtures.ts +236 -0
  14. package/src/dotnet/index.ts +246 -0
  15. package/src/dotnet/manifest.ts +36 -0
  16. package/src/dotnet/models.ts +344 -0
  17. package/src/dotnet/naming.ts +330 -0
  18. package/src/dotnet/resources.ts +622 -0
  19. package/src/dotnet/tests.ts +693 -0
  20. package/src/dotnet/type-map.ts +201 -0
  21. package/src/dotnet/wrappers.ts +186 -0
  22. package/src/go/index.ts +5 -2
  23. package/src/go/naming.ts +5 -17
  24. package/src/index.ts +1 -0
  25. package/src/kotlin/client.ts +53 -0
  26. package/src/kotlin/enums.ts +162 -0
  27. package/src/kotlin/index.ts +92 -0
  28. package/src/kotlin/manifest.ts +55 -0
  29. package/src/kotlin/models.ts +395 -0
  30. package/src/kotlin/naming.ts +223 -0
  31. package/src/kotlin/overrides.ts +25 -0
  32. package/src/kotlin/resources.ts +667 -0
  33. package/src/kotlin/tests.ts +1019 -0
  34. package/src/kotlin/type-map.ts +123 -0
  35. package/src/kotlin/wrappers.ts +168 -0
  36. package/src/node/client.ts +50 -0
  37. package/src/node/index.ts +1 -0
  38. package/src/node/resources.ts +164 -44
  39. package/src/node/tests.ts +37 -7
  40. package/src/php/client.ts +11 -3
  41. package/src/php/naming.ts +2 -21
  42. package/src/php/resources.ts +81 -6
  43. package/src/php/tests.ts +93 -17
  44. package/src/php/wrappers.ts +1 -0
  45. package/src/python/client.ts +37 -29
  46. package/src/python/enums.ts +7 -7
  47. package/src/python/models.ts +1 -1
  48. package/src/python/naming.ts +2 -22
  49. package/src/shared/model-utils.ts +232 -15
  50. package/src/shared/naming-utils.ts +47 -0
  51. package/src/shared/wrapper-utils.ts +12 -1
  52. package/test/dotnet/client.test.ts +121 -0
  53. package/test/dotnet/enums.test.ts +193 -0
  54. package/test/dotnet/errors.test.ts +9 -0
  55. package/test/dotnet/manifest.test.ts +82 -0
  56. package/test/dotnet/models.test.ts +260 -0
  57. package/test/dotnet/resources.test.ts +255 -0
  58. package/test/dotnet/tests.test.ts +202 -0
  59. package/test/kotlin/models.test.ts +135 -0
  60. package/test/kotlin/tests.test.ts +176 -0
  61. package/test/node/client.test.ts +74 -0
  62. package/test/node/resources.test.ts +216 -15
  63. package/test/php/client.test.ts +2 -1
  64. package/test/php/resources.test.ts +38 -0
  65. package/test/php/tests.test.ts +67 -0
@@ -0,0 +1,693 @@
1
+ import type { ApiSpec, Service, Operation, EmitterContext, GeneratedFile } from '@workos/oagen';
2
+ import { planOperation } from '@workos/oagen';
3
+ import {
4
+ className,
5
+ fixtureFileName,
6
+ fieldName as csFieldName,
7
+ methodName as csMethodName,
8
+ resolveMethodName,
9
+ serviceTypeName,
10
+ } from './naming.js';
11
+ import { resolveResourceClassName, sortPathParamsByTemplateOrder, optionsClassName } from './resources.js';
12
+ import { generateFixtures, generateModelFixture } from './fixtures.js';
13
+ import { isListWrapperModel } from './models.js';
14
+ import { groupByMount, buildResolvedLookup, lookupResolved, buildHiddenParams } from '../shared/resolved-ops.js';
15
+
16
+ /**
17
+ * Generate C# test files and JSON fixtures.
18
+ */
19
+ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
20
+ const files: GeneratedFile[] = [];
21
+
22
+ // Generate fixture JSON files
23
+ const fixtures = generateFixtures(spec);
24
+ for (const fixture of fixtures) {
25
+ files.push({
26
+ path: fixture.path,
27
+ content: fixture.content,
28
+ headerPlacement: 'skip',
29
+ });
30
+ }
31
+
32
+ // Generate per-mount-target test files
33
+ const mountGroups = groupByMount(ctx);
34
+ const testEntries: Array<{ name: string; operations: Operation[] }> =
35
+ mountGroups.size > 0
36
+ ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
37
+ : spec.services.map((s) => ({
38
+ name: resolveResourceClassName(s, ctx),
39
+ operations: s.operations,
40
+ }));
41
+
42
+ for (const { name: mountName, operations } of testEntries) {
43
+ if (operations.length === 0) continue;
44
+ const mergedService: Service = { name: mountName, operations };
45
+ const testFile = generateServiceTest(mergedService, spec, ctx);
46
+ if (testFile) files.push(testFile);
47
+ }
48
+
49
+ return files;
50
+ }
51
+
52
+ function generateServiceTest(service: Service, spec: ApiSpec, ctx: EmitterContext): GeneratedFile | null {
53
+ if (service.operations.length === 0) return null;
54
+
55
+ const resolvedName = resolveResourceClassName(service, ctx);
56
+ const svcType = serviceTypeName(resolvedName);
57
+ const testClassName = `${svcType}Test`;
58
+ const testFile = `Tests/${testClassName}.cs`;
59
+
60
+ const lines: string[] = [];
61
+ lines.push(`namespace ${ctx.namespacePascal}Tests`);
62
+ lines.push('{');
63
+ lines.push(' using System.Collections.Generic;');
64
+ lines.push(' using System.Net;');
65
+ lines.push(' using System.Net.Http;');
66
+ lines.push(' using System.Threading.Tasks;');
67
+ lines.push(` using ${ctx.namespacePascal};`);
68
+ lines.push(' using Xunit;');
69
+ lines.push('');
70
+ lines.push(` public class ${testClassName}`);
71
+ lines.push(' {');
72
+ lines.push(' private readonly HttpMock httpMock;');
73
+ lines.push(` private readonly ${svcType} service;`);
74
+ lines.push('');
75
+ lines.push(` public ${testClassName}()`);
76
+ lines.push(' {');
77
+ lines.push(' this.httpMock = new HttpMock();');
78
+ lines.push(` var client = new WorkOSClient(new WorkOSOptions`);
79
+ lines.push(' {');
80
+ lines.push(' ApiKey = "sk_test",');
81
+ lines.push(' ClientId = "client_test",');
82
+ lines.push(' HttpClient = this.httpMock.HttpClient,');
83
+ lines.push(' });');
84
+ lines.push(` this.service = new ${svcType}(client);`);
85
+ lines.push(' }');
86
+
87
+ const emittedTestMethods = new Set<string>();
88
+ const resolvedLookupForTests = buildResolvedLookup(ctx);
89
+
90
+ for (const op of service.operations) {
91
+ const plan = planOperation(op);
92
+ const method = resolveCsMethodName(op, resolvedName, ctx);
93
+ const isPaginated = plan.isPaginated;
94
+ const isDelete = plan.isDelete;
95
+ const resolvedOp = lookupResolved(op, resolvedLookupForTests);
96
+ const isUrlBuilder = resolvedOp?.urlBuilder ?? false;
97
+ const isUnionSplit = (resolvedOp?.wrappers?.length ?? 0) > 0;
98
+
99
+ // Union-split operations (e.g. POST /user_management/authenticate) don't
100
+ // expose the base method or options class — only the typed wrappers —
101
+ // so skip the generic base test; the wrapper loop below emits tests for
102
+ // each AuthenticateWith* / CreateOAuthApplication variant instead.
103
+ if (isUnionSplit) continue;
104
+
105
+ if (emittedTestMethods.has(method)) continue;
106
+ emittedTestMethods.add(method);
107
+
108
+ const testName = `Test${method}`;
109
+ const expectedPath = buildExpectedPath(op);
110
+ if (isUrlBuilder) {
111
+ // URL-builder operations return a string synchronously without issuing
112
+ // an HTTP request. Assert the URL structure instead of mocking HTTP.
113
+ const callArgs = buildMethodCallArgs(op, plan, ctx, resolvedName);
114
+ lines.push('');
115
+ lines.push(' [Fact]');
116
+ lines.push(` public void ${testName}()`);
117
+ lines.push(' {');
118
+ lines.push(` var url = this.service.${method}(${callArgs});`);
119
+ lines.push(' Assert.NotNull(url);');
120
+ lines.push(` Assert.Contains("${expectedPath}", url);`);
121
+ lines.push(' Assert.Empty(this.httpMock.CapturedRequests);');
122
+ lines.push(' }');
123
+ continue;
124
+ }
125
+ if (isPaginated && op.pagination) {
126
+ // Paginated test
127
+ let fixturePath: string | null = null;
128
+ const paginationItemType = op.pagination.itemType;
129
+ if (paginationItemType.kind === 'model') {
130
+ const itemModel = spec.models.find((m) => m.name === paginationItemType.name);
131
+ if (itemModel) {
132
+ let resolved = itemModel;
133
+ if (isListWrapperModel(itemModel)) {
134
+ const dataField = itemModel.fields.find((f) => f.name === 'data');
135
+ if (dataField && dataField.type.kind === 'array' && dataField.type.items.kind === 'model') {
136
+ const inner = spec.models.find((m) => m.name === (dataField.type as any).items.name);
137
+ if (inner) resolved = inner;
138
+ }
139
+ }
140
+ fixturePath = `testdata/list_${fixtureFileName(resolved.name)}.json`;
141
+ }
142
+ }
143
+
144
+ lines.push('');
145
+ lines.push(' [Fact]');
146
+ lines.push(` public async Task ${testName}()`);
147
+ lines.push(' {');
148
+ if (fixturePath) {
149
+ lines.push(` var fixture = System.IO.File.ReadAllText("${fixturePath}");`);
150
+ lines.push(
151
+ ` this.httpMock.MockResponse(HttpMethod.Get, "${expectedPath}", HttpStatusCode.OK, fixture);`,
152
+ );
153
+ } else {
154
+ lines.push(
155
+ ` this.httpMock.MockResponse(HttpMethod.Get, "${expectedPath}", HttpStatusCode.OK, "{\\"data\\":[],\\"list_metadata\\":{\\"before\\":null,\\"after\\":null}}");`,
156
+ );
157
+ }
158
+ const callArgs = buildMethodCallArgs(op, plan, ctx, resolvedName);
159
+ lines.push(` var result = await this.service.${method}(${callArgs});`);
160
+ lines.push(' Assert.NotNull(result);');
161
+ if (fixturePath) {
162
+ lines.push(' Assert.NotEmpty(result.Data);');
163
+ }
164
+ lines.push(` this.httpMock.AssertRequestWasMade(HttpMethod.Get, "${expectedPath}");`);
165
+ lines.push(' }');
166
+
167
+ // Empty list test
168
+ const emptyTestName = `Test${method}Empty`;
169
+ if (!emittedTestMethods.has(emptyTestName)) {
170
+ emittedTestMethods.add(emptyTestName);
171
+ const callArgsEmpty = buildMethodCallArgs(op, plan, ctx, resolvedName);
172
+ lines.push('');
173
+ lines.push(' [Fact]');
174
+ lines.push(` public async Task ${emptyTestName}()`);
175
+ lines.push(' {');
176
+ lines.push(
177
+ ` this.httpMock.MockResponse(HttpMethod.Get, "${expectedPath}", HttpStatusCode.OK, "{\\"data\\":[],\\"list_metadata\\":{\\"before\\":null,\\"after\\":null}}");`,
178
+ );
179
+ lines.push(` var result = await this.service.${method}(${callArgsEmpty});`);
180
+ lines.push(' Assert.NotNull(result);');
181
+ lines.push(' Assert.Empty(result.Data);');
182
+ lines.push(' }');
183
+ }
184
+ } else if (isDelete) {
185
+ lines.push('');
186
+ lines.push(' [Fact]');
187
+ lines.push(` public async Task ${testName}()`);
188
+ lines.push(' {');
189
+ lines.push(
190
+ ` this.httpMock.MockResponse(HttpMethod.Delete, "${expectedPath}", HttpStatusCode.NoContent, "");`,
191
+ );
192
+ const callArgs = buildMethodCallArgs(op, plan, ctx, resolvedName);
193
+ lines.push(` await this.service.${method}(${callArgs});`);
194
+ lines.push(` this.httpMock.AssertRequestWasMade(HttpMethod.Delete, "${expectedPath}");`);
195
+ lines.push(' }');
196
+ } else if (plan.responseModelName) {
197
+ const respModel = plan.responseModelName;
198
+ const fixturePath = `testdata/${fixtureFileName(respModel)}.json`;
199
+ const httpMethodCs = op.httpMethod.charAt(0).toUpperCase() + op.httpMethod.slice(1).toLowerCase();
200
+
201
+ const isArrayResp = !isPaginated && op.response?.kind === 'array';
202
+ const shapeSeed = buildRequestShapeSeed(op, plan, ctx, resolvedName);
203
+
204
+ lines.push('');
205
+ lines.push(' [Fact]');
206
+ lines.push(` public async Task ${testName}()`);
207
+ lines.push(' {');
208
+ lines.push(` var fixture = System.IO.File.ReadAllText("${fixturePath}");`);
209
+ if (isArrayResp) {
210
+ // Wrap single-object fixture in array for List<T> deserialization
211
+ lines.push(
212
+ ` this.httpMock.MockResponse(HttpMethod.${httpMethodCs}, "${expectedPath}", HttpStatusCode.OK, "[" + fixture + "]");`,
213
+ );
214
+ } else {
215
+ lines.push(
216
+ ` this.httpMock.MockResponse(HttpMethod.${httpMethodCs}, "${expectedPath}", HttpStatusCode.OK, fixture);`,
217
+ );
218
+ }
219
+ const callArgs = shapeSeed.seededCallArgs ?? buildMethodCallArgs(op, plan, ctx, resolvedName);
220
+ if (shapeSeed.setupLines.length > 0) {
221
+ for (const setupLine of shapeSeed.setupLines) {
222
+ lines.push(` ${setupLine}`);
223
+ }
224
+ }
225
+ lines.push(` var result = await this.service.${method}(${callArgs});`);
226
+ lines.push(' Assert.NotNull(result);');
227
+ if (!isArrayResp) {
228
+ const respModelDef = spec.models.find((m) => m.name === respModel);
229
+ if (respModelDef) {
230
+ const assertions = buildFixtureAssertions(respModelDef, spec);
231
+ for (const assertion of assertions) {
232
+ lines.push(` ${assertion}`);
233
+ }
234
+ }
235
+ }
236
+
237
+ lines.push(` this.httpMock.AssertRequestWasMade(HttpMethod.${httpMethodCs}, "${expectedPath}");`);
238
+ for (const assertLine of shapeSeed.assertLines) {
239
+ lines.push(` ${assertLine}`);
240
+ }
241
+ lines.push(' }');
242
+ } else {
243
+ lines.push('');
244
+ lines.push(' [Fact]');
245
+ lines.push(` public async Task ${testName}()`);
246
+ lines.push(' {');
247
+ const httpMethodCs = op.httpMethod.charAt(0).toUpperCase() + op.httpMethod.slice(1).toLowerCase();
248
+ lines.push(
249
+ ` this.httpMock.MockResponse(HttpMethod.${httpMethodCs}, "${expectedPath}", HttpStatusCode.OK, "");`,
250
+ );
251
+ const callArgs = buildMethodCallArgs(op, plan, ctx, resolvedName);
252
+ lines.push(` await this.service.${method}(${callArgs});`);
253
+ lines.push(` this.httpMock.AssertRequestWasMade(HttpMethod.${httpMethodCs}, "${expectedPath}");`);
254
+ lines.push(' }');
255
+ }
256
+ }
257
+
258
+ // Auto-paging tests (P0-5)
259
+ const resolvedLookupForPaging = buildResolvedLookup(ctx);
260
+ for (const op of service.operations) {
261
+ const plan = planOperation(op);
262
+ if (!plan.isPaginated || !op.pagination) continue;
263
+
264
+ const method = resolveCsMethodName(op, resolvedName, ctx);
265
+ const autoPagingTestName = `Test${method}AutoPagingAsync`;
266
+ if (emittedTestMethods.has(autoPagingTestName)) continue;
267
+ emittedTestMethods.add(autoPagingTestName);
268
+
269
+ const expectedPath = buildExpectedPath(op);
270
+ const paginationItemType = op.pagination.itemType;
271
+ let itemTypeName: string | null = null;
272
+ let fixtureName: string | null = null;
273
+
274
+ if (paginationItemType.kind === 'model') {
275
+ const itemModel = spec.models.find((m) => m.name === paginationItemType.name);
276
+ if (itemModel) {
277
+ let resolved = itemModel;
278
+ if (isListWrapperModel(itemModel)) {
279
+ const dataField = itemModel.fields.find((f) => f.name === 'data');
280
+ if (dataField && dataField.type.kind === 'array' && dataField.type.items.kind === 'model') {
281
+ const inner = spec.models.find((m) => m.name === (dataField.type as any).items.name);
282
+ if (inner) resolved = inner;
283
+ }
284
+ }
285
+ itemTypeName = className(resolved.name);
286
+ fixtureName = fixtureFileName(resolved.name);
287
+ }
288
+ }
289
+
290
+ if (!itemTypeName || !fixtureName) continue;
291
+
292
+ const callArgs = buildMethodCallArgs(op, plan, ctx, resolvedName);
293
+ // Remove the trailing options arg since auto-paging uses the same options
294
+ const autoPagingArgs = callArgs;
295
+
296
+ // Test with two pages
297
+ lines.push('');
298
+ lines.push(' [Fact]');
299
+ lines.push(` public async Task ${autoPagingTestName}()`);
300
+ lines.push(' {');
301
+ lines.push(` var fixture = System.IO.File.ReadAllText("testdata/${fixtureName}.json");`);
302
+ lines.push(
303
+ ` var page1 = "{\\"data\\":[" + fixture + "],\\"list_metadata\\":{\\"before\\":null,\\"after\\":\\"cursor_123\\"}}";`,
304
+ );
305
+ lines.push(
306
+ ` var page2 = "{\\"data\\":[" + fixture + "],\\"list_metadata\\":{\\"before\\":null,\\"after\\":null}}";`,
307
+ );
308
+ lines.push(
309
+ ` this.httpMock.MockSequentialResponses(HttpMethod.Get, "${expectedPath}", HttpStatusCode.OK, new[] { page1, page2 });`,
310
+ );
311
+ lines.push('');
312
+ lines.push(` var items = new List<${itemTypeName}>();`);
313
+ lines.push(` await foreach (var item in this.service.${method}AutoPagingAsync(${autoPagingArgs}))`);
314
+ lines.push(' {');
315
+ lines.push(' items.Add(item);');
316
+ lines.push(' }');
317
+ lines.push('');
318
+ lines.push(' Assert.Equal(2, items.Count);');
319
+ lines.push(' }');
320
+
321
+ // Test with empty first page
322
+ const emptyTestName = `Test${method}AutoPagingAsyncEmpty`;
323
+ if (!emittedTestMethods.has(emptyTestName)) {
324
+ emittedTestMethods.add(emptyTestName);
325
+ lines.push('');
326
+ lines.push(' [Fact]');
327
+ lines.push(` public async Task ${emptyTestName}()`);
328
+ lines.push(' {');
329
+ lines.push(` var empty = "{\\"data\\":[],\\"list_metadata\\":{\\"before\\":null,\\"after\\":null}}";`);
330
+ lines.push(
331
+ ` this.httpMock.MockSequentialResponses(HttpMethod.Get, "${expectedPath}", HttpStatusCode.OK, new[] { empty });`,
332
+ );
333
+ lines.push('');
334
+ lines.push(` var items = new List<${itemTypeName}>();`);
335
+ lines.push(` await foreach (var item in this.service.${method}AutoPagingAsync(${autoPagingArgs}))`);
336
+ lines.push(' {');
337
+ lines.push(' items.Add(item);');
338
+ lines.push(' }');
339
+ lines.push('');
340
+ lines.push(' Assert.Empty(items);');
341
+ lines.push(' }');
342
+ }
343
+ }
344
+
345
+ // Wrapper/convenience method tests (P0-6)
346
+ for (const op of service.operations) {
347
+ const resolvedOp = lookupResolved(op, resolvedLookupForPaging);
348
+ if (!resolvedOp?.wrappers || resolvedOp.wrappers.length === 0) continue;
349
+
350
+ for (const wrapper of resolvedOp.wrappers) {
351
+ const wrapperMethod = csMethodName(wrapper.name);
352
+ const wrapperTestName = `Test${wrapperMethod}`;
353
+ if (emittedTestMethods.has(wrapperTestName)) continue;
354
+ emittedTestMethods.add(wrapperTestName);
355
+
356
+ const expectedPath = buildExpectedPath(op);
357
+ const httpMethodCs = op.httpMethod.charAt(0).toUpperCase() + op.httpMethod.slice(1).toLowerCase();
358
+ const responseType = wrapper.responseModelName;
359
+
360
+ lines.push('');
361
+ lines.push(' [Fact]');
362
+ lines.push(` public async Task ${wrapperTestName}()`);
363
+ lines.push(' {');
364
+
365
+ if (responseType) {
366
+ const fixturePath = `testdata/${fixtureFileName(responseType)}.json`;
367
+ lines.push(` var fixture = System.IO.File.ReadAllText("${fixturePath}");`);
368
+ lines.push(
369
+ ` this.httpMock.MockResponse(HttpMethod.${httpMethodCs}, "${expectedPath}", HttpStatusCode.OK, fixture);`,
370
+ );
371
+ } else {
372
+ lines.push(
373
+ ` this.httpMock.MockResponse(HttpMethod.${httpMethodCs}, "${expectedPath}", HttpStatusCode.OK, "");`,
374
+ );
375
+ }
376
+
377
+ // Build wrapper call args
378
+ const wrapperArgs: string[] = [];
379
+ for (const p of sortPathParamsByTemplateOrder(op)) {
380
+ wrapperArgs.push(`"test_${p.name}"`);
381
+ }
382
+ wrapperArgs.push(`new ${wrapperMethod}Options()`);
383
+
384
+ if (responseType) {
385
+ lines.push(` var result = await this.service.${wrapperMethod}(${wrapperArgs.join(', ')});`);
386
+ lines.push(' Assert.NotNull(result);');
387
+ } else {
388
+ lines.push(` await this.service.${wrapperMethod}(${wrapperArgs.join(', ')});`);
389
+ }
390
+
391
+ lines.push(` this.httpMock.AssertRequestWasMade(HttpMethod.${httpMethodCs}, "${expectedPath}");`);
392
+ lines.push(' }');
393
+ }
394
+ }
395
+
396
+ // Error tests — pick the first non-URL-builder operation so the error
397
+ // assertions run against a real HTTP call.
398
+ const sampleOp = service.operations.find((o) => !(lookupResolved(o, resolvedLookupForTests)?.urlBuilder ?? false));
399
+ if (sampleOp) {
400
+ const plan = planOperation(sampleOp);
401
+ const method = resolveCsMethodName(sampleOp, resolvedName, ctx);
402
+ const callArgs = buildMethodCallArgs(sampleOp, plan, ctx, resolvedName);
403
+
404
+ // 401
405
+ lines.push('');
406
+ lines.push(' [Fact]');
407
+ lines.push(` public async Task TestError401()`);
408
+ lines.push(' {');
409
+ lines.push(
410
+ ` this.httpMock.MockResponseForAnyRequest(HttpStatusCode.Unauthorized, "{\\"code\\":\\"unauthorized\\",\\"message\\":\\"Unauthorized\\"}");`,
411
+ );
412
+ if (plan.isPaginated || plan.isDelete || !plan.responseModelName) {
413
+ lines.push(
414
+ ` await Assert.ThrowsAsync<AuthenticationError>(() => this.service.${method}(${callArgs}));`,
415
+ );
416
+ } else {
417
+ lines.push(
418
+ ` await Assert.ThrowsAsync<AuthenticationError>(() => this.service.${method}(${callArgs}));`,
419
+ );
420
+ }
421
+ lines.push(' }');
422
+
423
+ // 404
424
+ lines.push('');
425
+ lines.push(' [Fact]');
426
+ lines.push(` public async Task TestError404()`);
427
+ lines.push(' {');
428
+ lines.push(
429
+ ` this.httpMock.MockResponseForAnyRequest(HttpStatusCode.NotFound, "{\\"code\\":\\"not_found\\",\\"message\\":\\"Not Found\\"}");`,
430
+ );
431
+ lines.push(` await Assert.ThrowsAsync<NotFoundError>(() => this.service.${method}(${callArgs}));`);
432
+ lines.push(' }');
433
+
434
+ // 422
435
+ lines.push('');
436
+ lines.push(' [Fact]');
437
+ lines.push(` public async Task TestError422()`);
438
+ lines.push(' {');
439
+ lines.push(
440
+ ` this.httpMock.MockResponseForAnyRequest((HttpStatusCode)422, "{\\"code\\":\\"unprocessable_entity\\",\\"message\\":\\"Unprocessable\\"}");`,
441
+ );
442
+ lines.push(
443
+ ` await Assert.ThrowsAsync<UnprocessableEntityError>(() => this.service.${method}(${callArgs}));`,
444
+ );
445
+ lines.push(' }');
446
+
447
+ // 429
448
+ lines.push('');
449
+ lines.push(' [Fact]');
450
+ lines.push(` public async Task TestError429()`);
451
+ lines.push(' {');
452
+ lines.push(
453
+ ` this.httpMock.MockResponseForAnyRequest((HttpStatusCode)429, "{\\"code\\":\\"too_many_requests\\",\\"message\\":\\"Too Many Requests\\"}");`,
454
+ );
455
+ lines.push(
456
+ ` await Assert.ThrowsAsync<RateLimitExceededError>(() => this.service.${method}(${callArgs}));`,
457
+ );
458
+ lines.push(' }');
459
+
460
+ // 500
461
+ lines.push('');
462
+ lines.push(' [Fact]');
463
+ lines.push(` public async Task TestError500()`);
464
+ lines.push(' {');
465
+ lines.push(
466
+ ` this.httpMock.MockResponseForAnyRequest(HttpStatusCode.InternalServerError, "{\\"code\\":\\"server_error\\",\\"message\\":\\"Server Error\\"}");`,
467
+ );
468
+ lines.push(` await Assert.ThrowsAsync<ServerError>(() => this.service.${method}(${callArgs}));`);
469
+ lines.push(' }');
470
+ }
471
+
472
+ lines.push(' }');
473
+ lines.push('}');
474
+
475
+ return {
476
+ path: testFile,
477
+ content: lines.join('\n'),
478
+ overwriteExisting: true,
479
+ };
480
+ }
481
+
482
+ function resolveCsMethodName(op: Operation, mountName: string, ctx: EmitterContext): string {
483
+ return resolveMethodName(op, { name: mountName, operations: [op] }, ctx);
484
+ }
485
+
486
+ function buildMethodCallArgs(op: Operation, plan: any, ctx: EmitterContext, mountName: string): string {
487
+ const args: string[] = [];
488
+
489
+ // Path params
490
+ for (const p of sortPathParamsByTemplateOrder(op)) {
491
+ args.push(`"test_${p.name}"`);
492
+ }
493
+
494
+ // Bearer auth override param (e.g., SSO GetProfile uses access_token)
495
+ const hasBearerOverride = op.security?.some((s: any) => s.schemeName !== 'bearerAuth') ?? false;
496
+ if (hasBearerOverride) {
497
+ const bearerParamName = op.security!.find((s: any) => s.schemeName !== 'bearerAuth')!.schemeName;
498
+ args.push(`"test_${bearerParamName}"`);
499
+ }
500
+
501
+ // Options struct if needed
502
+ const resolvedLookup = buildResolvedLookup(ctx);
503
+ const resolvedOp = lookupResolved(op, resolvedLookup);
504
+ const hidden = buildHiddenParams(resolvedOp);
505
+ const hasVisibleQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name)).length > 0;
506
+ const hasBody = plan.hasBody && op.requestBody;
507
+ let hasVisibleBodyFields = false;
508
+ if (hasBody && op.requestBody?.kind === 'model') {
509
+ const bodyModel = ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
510
+ if (bodyModel) hasVisibleBodyFields = bodyModel.fields.some((f) => !hidden.has(f.name));
511
+ } else if (hasBody) {
512
+ hasVisibleBodyFields = true;
513
+ }
514
+
515
+ if (hasVisibleBodyFields || hasVisibleQueryParams) {
516
+ const method = resolveCsMethodName(op, mountName, ctx);
517
+ const optName = optionsClassName(mountName, method);
518
+ args.push(`new ${optName}()`);
519
+ }
520
+
521
+ return args.join(', ');
522
+ }
523
+
524
+ /**
525
+ * Seed required request fields on the generated options expression and
526
+ * produce matching body/query assertions. Catches snake_case mapping
527
+ * regressions and missing-required-field bugs without requiring
528
+ * hand-written tests.
529
+ */
530
+ interface RequestShapeSeed {
531
+ setupLines: string[];
532
+ seededCallArgs: string | null;
533
+ assertLines: string[];
534
+ }
535
+
536
+ function buildRequestShapeSeed(op: Operation, plan: any, ctx: EmitterContext, mountName: string): RequestShapeSeed {
537
+ const resolvedLookup = buildResolvedLookup(ctx);
538
+ const resolvedOp = lookupResolved(op, resolvedLookup);
539
+ const hidden = buildHiddenParams(resolvedOp);
540
+
541
+ // Collect required simple fields that we can seed with a string literal.
542
+ const bodySeeds: Array<{ wire: string; prop: string; value: string }> = [];
543
+ const querySeeds: Array<{ wire: string; prop: string; value: string }> = [];
544
+
545
+ const hasBody = plan.hasBody && op.requestBody;
546
+ if (hasBody && op.requestBody?.kind === 'model') {
547
+ const bodyModel = ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
548
+ if (bodyModel) {
549
+ for (const field of bodyModel.fields) {
550
+ if (hidden.has(field.name)) continue;
551
+ if (!field.required) continue;
552
+ if (!isSeedableStringRef(field.type)) continue;
553
+ bodySeeds.push({
554
+ wire: field.name,
555
+ prop: csFieldName(field.name),
556
+ value: `test_${field.name}`,
557
+ });
558
+ if (bodySeeds.length >= 2) break;
559
+ }
560
+ }
561
+ }
562
+
563
+ // Wire names already covered by body seeds. For operations that duplicate a
564
+ // body field as a query param (e.g. POST /sso/token lists `code` in both),
565
+ // the generated options class only exposes the field once and the service
566
+ // call sends it via the body — so skip the query assertion to avoid a
567
+ // false-failing `AssertQueryParam`.
568
+ const bodyWireNames = new Set(bodySeeds.map((s) => s.wire));
569
+ for (const param of op.queryParams) {
570
+ if (hidden.has(param.name)) continue;
571
+ if (!param.required) continue;
572
+ if (!isSeedableStringRef(param.type)) continue;
573
+ // Skip pagination fields — they're set by the caller or the autopaging loop
574
+ if (['before', 'after', 'limit', 'order'].includes(param.name)) continue;
575
+ if (bodyWireNames.has(param.name)) continue;
576
+ querySeeds.push({
577
+ wire: param.name,
578
+ prop: csFieldName(param.name),
579
+ value: `test_${param.name}`,
580
+ });
581
+ if (querySeeds.length >= 2) break;
582
+ }
583
+
584
+ if (bodySeeds.length === 0 && querySeeds.length === 0) {
585
+ return { setupLines: [], seededCallArgs: null, assertLines: [] };
586
+ }
587
+
588
+ const method = resolveCsMethodName(op, mountName, ctx);
589
+ const optName = optionsClassName(mountName, method);
590
+
591
+ // Rebuild call args with a seeded options variable named `options`.
592
+ const args: string[] = [];
593
+ for (const p of sortPathParamsByTemplateOrder(op)) {
594
+ args.push(`"test_${p.name}"`);
595
+ }
596
+ const hasBearerOverride = op.security?.some((s: any) => s.schemeName !== 'bearerAuth') ?? false;
597
+ if (hasBearerOverride) {
598
+ const bearerParamName = op.security!.find((s: any) => s.schemeName !== 'bearerAuth')!.schemeName;
599
+ args.push(`"test_${bearerParamName}"`);
600
+ }
601
+ args.push('options');
602
+
603
+ const setupLines: string[] = [`var options = new ${optName}();`];
604
+ for (const s of [...bodySeeds, ...querySeeds]) {
605
+ setupLines.push(`options.${s.prop} = "${s.value}";`);
606
+ }
607
+
608
+ const assertLines: string[] = [];
609
+ for (const s of bodySeeds) {
610
+ assertLines.push(`await this.httpMock.AssertRequestBodyContainsAsync("${s.wire}", "${s.value}");`);
611
+ }
612
+ for (const s of querySeeds) {
613
+ assertLines.push(`this.httpMock.AssertQueryParam("${s.wire}", "${s.value}");`);
614
+ }
615
+
616
+ return { setupLines, seededCallArgs: args.join(', '), assertLines };
617
+ }
618
+
619
+ /**
620
+ * A TypeRef is seedable as a string literal in a generated test when it maps
621
+ * to C# `string` (plain strings, formats like email/uuid, dates-as-strings).
622
+ * Enums and numeric types need dedicated representations and are skipped here.
623
+ */
624
+ function isSeedableStringRef(ref: import('@workos/oagen').TypeRef): boolean {
625
+ if (ref.kind !== 'primitive') return false;
626
+ if (ref.type !== 'string') return false;
627
+ // `binary` maps to byte[], not a string literal
628
+ if (ref.format === 'binary') return false;
629
+ return true;
630
+ }
631
+
632
+ function buildExpectedPath(op: Operation): string {
633
+ let expected = op.path;
634
+ for (const p of sortPathParamsByTemplateOrder(op)) {
635
+ expected = expected.replace(`{${p.name}}`, `test_${p.name}`);
636
+ }
637
+ return expected;
638
+ }
639
+
640
+ function buildFixtureAssertions(model: import('@workos/oagen').Model, spec: ApiSpec): string[] {
641
+ const assertions: string[] = [];
642
+
643
+ // Compute the exact fixture payload the generator emits for this model so
644
+ // we can assert against those values verbatim. Mapping regressions
645
+ // (snake_case drift, nested field loss) fail deterministically instead of
646
+ // silently passing NotEmpty checks.
647
+ const modelMap = new Map(spec.models.map((m) => [m.name, m]));
648
+ const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
649
+ let fixture: Record<string, unknown> = {};
650
+ try {
651
+ fixture = generateModelFixture(model, modelMap, enumMap);
652
+ } catch {
653
+ // Fall back to shape-only assertions if the fixture builder throws.
654
+ }
655
+
656
+ const idField = model.fields.find((f) => f.required && f.name === 'id');
657
+ if (idField) {
658
+ const idVal = fixture['id'];
659
+ if (typeof idVal === 'string' && idVal.length > 0) {
660
+ assertions.push(`Assert.Equal(${csStringLiteral(idVal)}, result.Id);`);
661
+ } else {
662
+ assertions.push(`Assert.NotEmpty(result.Id);`);
663
+ }
664
+ }
665
+
666
+ // Assert up to 2 additional required simple fields using the exact fixture
667
+ // value so snake_case mapping is verified. Skip date-time, binary, and
668
+ // anything that doesn't come out of the fixture as a non-empty string.
669
+ let extraCount = 0;
670
+ for (const field of model.fields) {
671
+ if (extraCount >= 2) break;
672
+ if (field.name === 'id') continue;
673
+ if (!field.required) continue;
674
+ if (field.type.kind !== 'primitive' || field.type.type !== 'string') continue;
675
+ if (field.type.format === 'date-time' || field.type.format === 'date') continue;
676
+ if (field.type.format === 'binary') continue;
677
+ const csField = csFieldName(field.name);
678
+ const val = fixture[field.name];
679
+ if (typeof val === 'string' && val.length > 0) {
680
+ assertions.push(`Assert.Equal(${csStringLiteral(val)}, result.${csField});`);
681
+ } else {
682
+ assertions.push(`Assert.NotEmpty(result.${csField});`);
683
+ }
684
+ extraCount++;
685
+ }
686
+
687
+ return assertions;
688
+ }
689
+
690
+ /** Escape a JS string for use as a C# verbatim-friendly string literal. */
691
+ function csStringLiteral(s: string): string {
692
+ return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r')}"`;
693
+ }