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