@workos/oagen-emitters 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.oxfmtrc.json +8 -1
  3. package/.release-please-manifest.json +1 -1
  4. package/CHANGELOG.md +15 -0
  5. package/README.md +129 -0
  6. package/dist/index.d.mts +10 -1
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +11943 -2728
  9. package/dist/index.mjs.map +1 -1
  10. package/docs/sdk-architecture/go.md +338 -0
  11. package/docs/sdk-architecture/php.md +315 -0
  12. package/docs/sdk-architecture/python.md +511 -0
  13. package/oagen.config.ts +298 -2
  14. package/package.json +9 -5
  15. package/scripts/generate-php.js +13 -0
  16. package/scripts/git-push-with-published-oagen.sh +21 -0
  17. package/smoke/sdk-dotnet.ts +17 -3
  18. package/smoke/sdk-elixir.ts +17 -3
  19. package/smoke/sdk-go.ts +137 -46
  20. package/smoke/sdk-kotlin.ts +23 -4
  21. package/smoke/sdk-node.ts +15 -3
  22. package/smoke/sdk-php.ts +28 -26
  23. package/smoke/sdk-python.ts +5 -2
  24. package/smoke/sdk-ruby.ts +17 -3
  25. package/smoke/sdk-rust.ts +16 -3
  26. package/src/go/client.ts +141 -0
  27. package/src/go/enums.ts +196 -0
  28. package/src/go/fixtures.ts +212 -0
  29. package/src/go/index.ts +81 -0
  30. package/src/go/manifest.ts +36 -0
  31. package/src/go/models.ts +254 -0
  32. package/src/go/naming.ts +191 -0
  33. package/src/go/resources.ts +827 -0
  34. package/src/go/tests.ts +751 -0
  35. package/src/go/type-map.ts +82 -0
  36. package/src/go/wrappers.ts +261 -0
  37. package/src/index.ts +3 -0
  38. package/src/node/client.ts +167 -122
  39. package/src/node/enums.ts +13 -4
  40. package/src/node/errors.ts +42 -233
  41. package/src/node/field-plan.ts +726 -0
  42. package/src/node/fixtures.ts +15 -5
  43. package/src/node/index.ts +65 -16
  44. package/src/node/models.ts +264 -96
  45. package/src/node/naming.ts +52 -25
  46. package/src/node/resources.ts +621 -172
  47. package/src/node/sdk-errors.ts +41 -0
  48. package/src/node/tests.ts +71 -27
  49. package/src/node/type-map.ts +4 -2
  50. package/src/node/utils.ts +56 -64
  51. package/src/node/wrappers.ts +151 -0
  52. package/src/php/client.ts +171 -0
  53. package/src/php/enums.ts +67 -0
  54. package/src/php/errors.ts +9 -0
  55. package/src/php/fixtures.ts +181 -0
  56. package/src/php/index.ts +96 -0
  57. package/src/php/manifest.ts +36 -0
  58. package/src/php/models.ts +310 -0
  59. package/src/php/naming.ts +298 -0
  60. package/src/php/resources.ts +561 -0
  61. package/src/php/tests.ts +533 -0
  62. package/src/php/type-map.ts +90 -0
  63. package/src/php/utils.ts +18 -0
  64. package/src/php/wrappers.ts +151 -0
  65. package/src/python/client.ts +337 -0
  66. package/src/python/enums.ts +313 -0
  67. package/src/python/fixtures.ts +196 -0
  68. package/src/python/index.ts +95 -0
  69. package/src/python/manifest.ts +38 -0
  70. package/src/python/models.ts +688 -0
  71. package/src/python/naming.ts +209 -0
  72. package/src/python/resources.ts +1322 -0
  73. package/src/python/tests.ts +1335 -0
  74. package/src/python/type-map.ts +93 -0
  75. package/src/python/wrappers.ts +191 -0
  76. package/src/shared/model-utils.ts +255 -0
  77. package/src/shared/naming-utils.ts +107 -0
  78. package/src/shared/non-spec-services.ts +54 -0
  79. package/src/shared/resolved-ops.ts +109 -0
  80. package/src/shared/wrapper-utils.ts +59 -0
  81. package/test/go/client.test.ts +92 -0
  82. package/test/go/enums.test.ts +132 -0
  83. package/test/go/errors.test.ts +9 -0
  84. package/test/go/models.test.ts +265 -0
  85. package/test/go/resources.test.ts +408 -0
  86. package/test/go/tests.test.ts +143 -0
  87. package/test/node/client.test.ts +199 -94
  88. package/test/node/enums.test.ts +75 -3
  89. package/test/node/errors.test.ts +2 -41
  90. package/test/node/models.test.ts +109 -20
  91. package/test/node/naming.test.ts +37 -4
  92. package/test/node/resources.test.ts +662 -30
  93. package/test/node/serializers.test.ts +36 -7
  94. package/test/node/type-map.test.ts +11 -0
  95. package/test/php/client.test.ts +94 -0
  96. package/test/php/enums.test.ts +173 -0
  97. package/test/php/errors.test.ts +9 -0
  98. package/test/php/models.test.ts +497 -0
  99. package/test/php/resources.test.ts +644 -0
  100. package/test/php/tests.test.ts +118 -0
  101. package/test/python/client.test.ts +200 -0
  102. package/test/python/enums.test.ts +228 -0
  103. package/test/python/errors.test.ts +16 -0
  104. package/test/python/manifest.test.ts +74 -0
  105. package/test/python/models.test.ts +716 -0
  106. package/test/python/resources.test.ts +617 -0
  107. package/test/python/tests.test.ts +202 -0
  108. package/src/node/common.ts +0 -273
  109. package/src/node/config.ts +0 -71
  110. package/src/node/serializers.ts +0 -744
@@ -0,0 +1,1335 @@
1
+ import type {
2
+ ApiSpec,
3
+ Service,
4
+ Operation,
5
+ EmitterContext,
6
+ GeneratedFile,
7
+ TypeRef,
8
+ Model,
9
+ ResolvedOperation,
10
+ } from '@workos/oagen';
11
+ import { planOperation, toSnakeCase, assignModelsToServices } from '@workos/oagen';
12
+ import { className, fileName, fieldName, resolveMethodName, buildMountDirMap, dirToModule } from './naming.js';
13
+ import { resolveResourceClassName, bodyParamName } from './resources.js';
14
+ import { buildServiceAccessPaths } from './client.js';
15
+ import { generateFixtures, generateModelFixture } from './fixtures.js';
16
+ import { isListWrapperModel, isListMetadataModel } from './models.js';
17
+ import { assignEnumsToServices } from './enums.js';
18
+ import { groupByMount, buildResolvedLookup, lookupResolved, buildHiddenParams } from '../shared/resolved-ops.js';
19
+ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
20
+ import { pythonLiteral } from './wrappers.js';
21
+
22
+ /** Check if an operation is a redirect endpoint (same logic as resources.ts). */
23
+ function isRedirectEndpoint(op: Operation): boolean {
24
+ if (op.successResponses?.some((r) => r.statusCode >= 300 && r.statusCode < 400)) return true;
25
+ if (
26
+ op.httpMethod === 'get' &&
27
+ op.response.kind === 'primitive' &&
28
+ (op.response as any).type === 'unknown' &&
29
+ op.queryParams.length > 0
30
+ ) {
31
+ return true;
32
+ }
33
+ return false;
34
+ }
35
+
36
+ /** Push an async test method definition with @pytest.mark.asyncio decorator. */
37
+ function pushAsyncTestDef(lines: string[], def: string): void {
38
+ lines.push(' @pytest.mark.asyncio');
39
+ lines.push(def);
40
+ }
41
+
42
+ /**
43
+ * Generate pytest test files and JSON fixtures for the Python SDK.
44
+ */
45
+ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
46
+ const files: GeneratedFile[] = [];
47
+
48
+ // Generate fixture JSON files
49
+ const fixtures = generateFixtures(spec);
50
+ for (const fixture of fixtures) {
51
+ files.push({
52
+ path: fixture.path,
53
+ content: fixture.content,
54
+ headerPlacement: 'skip',
55
+ integrateTarget: true,
56
+ overwriteExisting: true,
57
+ });
58
+ }
59
+
60
+ // conftest.py, generated_helpers.py, test_pagination.py, and test_generated_client.py
61
+ // are now hand-maintained in the target SDK (@oagen-ignore-file).
62
+
63
+ // Build access path map for all services
64
+ const accessPaths = buildServiceAccessPaths(spec.services, ctx);
65
+
66
+ // Generate per-mount-target test files (merges all sub-services into one file)
67
+ const mountGroups = groupByMount(ctx);
68
+ const testEntries: Array<{ name: string; operations: Operation[]; resolvedOps?: ResolvedOperation[] }> =
69
+ mountGroups.size > 0
70
+ ? [...mountGroups].map(([name, group]) => ({
71
+ name,
72
+ operations: group.operations,
73
+ resolvedOps: group.resolvedOps,
74
+ }))
75
+ : spec.services.map((s) => ({ name: resolveResourceClassName(s, ctx), operations: s.operations }));
76
+
77
+ for (const { name: mountName, operations, resolvedOps } of testEntries) {
78
+ if (operations.length === 0) continue;
79
+ const mergedService: Service = { name: mountName, operations };
80
+ const testFile = generateServiceTest(mergedService, spec, ctx, accessPaths, resolvedOps);
81
+ if (testFile) files.push(testFile);
82
+ }
83
+
84
+ // Generate model round-trip tests (P3-7)
85
+ const modelTests = generateModelRoundTripTests(spec, ctx);
86
+ if (modelTests) files.push(modelTests);
87
+
88
+ return files;
89
+ }
90
+
91
+ function generateServiceTest(
92
+ service: Service,
93
+ spec: ApiSpec,
94
+ ctx: EmitterContext,
95
+ accessPaths: Map<string, string>,
96
+ resolvedOps?: ResolvedOperation[],
97
+ ): GeneratedFile | null {
98
+ if (service.operations.length === 0) return null;
99
+
100
+ const resolvedName = resolveResourceClassName(service, ctx);
101
+ const propName = accessPaths.get(service.name) ?? toSnakeCase(resolvedName);
102
+
103
+ const lines: string[] = [];
104
+
105
+ lines.push('import json');
106
+ lines.push('');
107
+ lines.push('import pytest');
108
+ lines.push(`from ${ctx.namespace} import WorkOSClient, AsyncWorkOSClient`);
109
+ lines.push('from tests.generated_helpers import load_fixture');
110
+ lines.push('');
111
+
112
+ // Collect model and enum imports needed (response models, body field models, and enum params)
113
+ const modelImports = new Set<string>();
114
+ const enumImports = new Set<string>();
115
+ for (const op of service.operations) {
116
+ const plan = planOperation(op);
117
+ if (plan.responseModelName) modelImports.add(plan.responseModelName);
118
+ if (op.pagination?.itemType.kind === 'model') {
119
+ modelImports.add(op.pagination.itemType.name);
120
+ }
121
+ // Collect model-typed and enum-typed body fields (used as method arguments)
122
+ if (plan.hasBody && op.requestBody?.kind === 'model') {
123
+ const bodyModel = spec.models.find((m) => m.name === (op.requestBody as any).name);
124
+ if (bodyModel) {
125
+ for (const f of bodyModel.fields) {
126
+ if (f.type.kind === 'model') modelImports.add(f.type.name);
127
+ if (f.type.kind === 'nullable' && f.type.inner.kind === 'model') modelImports.add(f.type.inner.name);
128
+ if (f.type.kind === 'array' && f.type.items.kind === 'model') modelImports.add(f.type.items.name);
129
+ if (f.type.kind === 'enum') enumImports.add(f.type.name);
130
+ if (f.type.kind === 'nullable' && f.type.inner.kind === 'enum') enumImports.add(f.type.inner.name);
131
+ }
132
+ }
133
+ }
134
+ // Collect enum-typed query params
135
+ for (const param of op.queryParams) {
136
+ if (param.type.kind === 'enum') enumImports.add(param.type.name);
137
+ if (param.type.kind === 'nullable' && param.type.inner.kind === 'enum') enumImports.add(param.type.inner.name);
138
+ }
139
+ }
140
+
141
+ // Filter out list wrapper models
142
+ const actualImports = [...modelImports].filter((name) => {
143
+ const model = spec.models.find((m) => m.name === name);
144
+ if (!model) return true;
145
+ return !isListWrapperModel(model);
146
+ });
147
+
148
+ // Group imports by their actual service directory (models may live in different services)
149
+ const modelToServiceMap = assignModelsToServices(spec.models, spec.services);
150
+ const mountDirMap = buildMountDirMap(ctx);
151
+ const resolveModelDir = (modelName: string) => {
152
+ const svc = modelToServiceMap.get(modelName);
153
+ return svc ? (mountDirMap.get(svc) ?? 'common') : 'common';
154
+ };
155
+
156
+ // Group enum imports by service directory
157
+ const enumToServiceMap = assignEnumsToServices(spec.enums, spec.services);
158
+ const resolveEnumDir = (enumName: string) => {
159
+ const svc = enumToServiceMap.get(enumName);
160
+ return svc ? (mountDirMap.get(svc) ?? 'common') : 'common';
161
+ };
162
+
163
+ const importsByDir = new Map<string, string[]>();
164
+ for (const name of actualImports.sort()) {
165
+ const modelDir = resolveModelDir(name);
166
+ if (!importsByDir.has(modelDir)) importsByDir.set(modelDir, []);
167
+ importsByDir.get(modelDir)!.push(className(name));
168
+ }
169
+ for (const name of [...enumImports].sort()) {
170
+ const enumDir = resolveEnumDir(name);
171
+ if (!importsByDir.has(enumDir)) importsByDir.set(enumDir, []);
172
+ const existing = importsByDir.get(enumDir)!;
173
+ const cn = className(name);
174
+ if (!existing.includes(cn)) existing.push(cn);
175
+ }
176
+
177
+ for (const [modelDir, names] of [...importsByDir].sort()) {
178
+ lines.push(`from ${ctx.namespace}.${dirToModule(modelDir)}.models import ${names.join(', ')}`);
179
+ }
180
+
181
+ const hasPaginated = service.operations.some((op) => op.pagination);
182
+ if (hasPaginated) {
183
+ lines.push(`from ${ctx.namespace}._pagination import AsyncPage, SyncPage`);
184
+ }
185
+ lines.push(
186
+ `from ${ctx.namespace}._errors import AuthenticationError, BadRequestError, NotFoundError, RateLimitExceededError, ServerError, UnprocessableEntityError`,
187
+ );
188
+
189
+ lines.push('');
190
+ lines.push('');
191
+ lines.push(`class Test${resolvedName}:`);
192
+
193
+ const resolvedLookup = buildResolvedLookup(ctx);
194
+ const emittedTestMethods = new Set<string>();
195
+ for (const op of service.operations) {
196
+ const plan = planOperation(op);
197
+ const resolvedOp = lookupResolved(op, resolvedLookup);
198
+ const hiddenParams = buildHiddenParams(resolvedOp);
199
+ let method = resolveMethodName(op, service, ctx);
200
+
201
+ // On name collision, fall back to the full snake_case operation name (match resource dedup)
202
+ if (emittedTestMethods.has(method)) {
203
+ const fallback = toSnakeCase(op.name);
204
+ if (fallback !== method && !emittedTestMethods.has(fallback)) {
205
+ method = fallback;
206
+ } else {
207
+ continue;
208
+ }
209
+ }
210
+ emittedTestMethods.add(method);
211
+
212
+ const isDelete = plan.isDelete;
213
+ const isPaginated = plan.isPaginated;
214
+ const isArrayResponse = op.response.kind === 'array' && op.response.items.kind === 'model';
215
+
216
+ lines.push('');
217
+
218
+ if (isPaginated) {
219
+ const itemType = op.pagination!.itemType;
220
+ let itemName = itemType.kind === 'model' ? itemType.name : null;
221
+ // Unwrap list wrapper models to their inner item type for fixture names
222
+ if (itemName) {
223
+ const wrapperModel = spec.models.find((m) => m.name === itemName);
224
+ if (wrapperModel && isListWrapperModel(wrapperModel)) {
225
+ const dataField = wrapperModel.fields.find((f) => f.name === 'data');
226
+ if (dataField && dataField.type.kind === 'array' && dataField.type.items.kind === 'model') {
227
+ itemName = dataField.type.items.name;
228
+ }
229
+ }
230
+ }
231
+ // Skip fixture-based testing for models with no fields (discriminated unions)
232
+ if (itemName) {
233
+ const itemModel = spec.models.find((m) => m.name === itemName);
234
+ if (itemModel && itemModel.fields.length === 0) itemName = null;
235
+ }
236
+ const fixtureName = itemName ? `list_${fileName(itemName)}.json` : null;
237
+
238
+ const paginatedArgs = buildTestArgs(op, spec, hiddenParams);
239
+ lines.push(` def test_${method}(self, workos, httpx_mock):`);
240
+ if (fixtureName) {
241
+ lines.push(` httpx_mock.add_response(`);
242
+ lines.push(` json=load_fixture("${fixtureName}"),`);
243
+ lines.push(' )');
244
+ lines.push(` page = workos.${propName}.${method}(${paginatedArgs})`);
245
+ lines.push(' assert isinstance(page, SyncPage)');
246
+ lines.push(' assert isinstance(page.data, list)');
247
+
248
+ lines.push('');
249
+ lines.push(` def test_${method}_empty_page(self, workos, httpx_mock):`);
250
+ lines.push(' httpx_mock.add_response(json={"data": [], "list_metadata": {}})');
251
+ lines.push(` page = workos.${propName}.${method}(${paginatedArgs})`);
252
+ lines.push(' assert isinstance(page, SyncPage)');
253
+ lines.push(' assert page.data == []');
254
+ } else {
255
+ lines.push(' httpx_mock.add_response(json={"data": [], "list_metadata": {}})');
256
+ lines.push(` page = workos.${propName}.${method}(${paginatedArgs})`);
257
+ lines.push(' assert isinstance(page, SyncPage)');
258
+ }
259
+ } else if (isDelete) {
260
+ lines.push(` def test_${method}(self, workos, httpx_mock):`);
261
+ lines.push(' httpx_mock.add_response(status_code=204)');
262
+ const args = buildTestArgs(op, spec, hiddenParams);
263
+ lines.push(` result = workos.${propName}.${method}(${args})`);
264
+ lines.push(' assert result is None');
265
+ // Request assertions for delete
266
+ const deletePath = buildExpectedPath(op);
267
+ lines.push(' request = httpx_mock.get_request()');
268
+ lines.push(` assert request.method == "DELETE"`);
269
+ lines.push(` assert request.url.path.endswith("/${deletePath}")`);
270
+ } else if (isRedirectEndpoint(op)) {
271
+ // Redirect endpoint: returns a URL string, no HTTP request made
272
+ const args = buildTestArgs(op, spec, hiddenParams);
273
+ lines.push(` def test_${method}(self, workos):`);
274
+ lines.push(` result = workos.${propName}.${method}(${args})`);
275
+ lines.push(' assert isinstance(result, str)');
276
+ lines.push(' assert result.startswith("http")');
277
+ } else if (isArrayResponse) {
278
+ // Array response: returns List[Model]
279
+ const modelClass = className(plan.responseModelName!);
280
+ const fixtureName = `${fileName(plan.responseModelName!)}.json`;
281
+ const args = buildTestArgs(op, spec, hiddenParams);
282
+ lines.push(` def test_${method}(self, workos, httpx_mock):`);
283
+ lines.push(` httpx_mock.add_response(json=[load_fixture("${fixtureName}")])`);
284
+ lines.push(` result = workos.${propName}.${method}(${args})`);
285
+ lines.push(' assert isinstance(result, list)');
286
+ lines.push(` assert len(result) == 1`);
287
+ lines.push(` assert isinstance(result[0], ${modelClass})`);
288
+ } else if (plan.responseModelName) {
289
+ const modelName = plan.responseModelName;
290
+ const fixtureName = `${fileName(modelName)}.json`;
291
+ const modelClass = className(modelName);
292
+
293
+ lines.push(` def test_${method}(self, workos, httpx_mock):`);
294
+ lines.push(` httpx_mock.add_response(`);
295
+ lines.push(` json=load_fixture("${fixtureName}"),`);
296
+ lines.push(' )');
297
+ const args = buildTestArgs(op, spec, hiddenParams);
298
+ lines.push(` result = workos.${propName}.${method}(${args})`);
299
+ lines.push(` assert isinstance(result, ${modelClass})`);
300
+
301
+ // Field-value assertions: verify at least 2 scalar fields from fixture
302
+ const assertFields = pickAssertableFields(modelName, spec);
303
+ for (const af of assertFields) {
304
+ const op_ = af.isBool ? 'is' : '==';
305
+ lines.push(` assert result.${af.field} ${op_} ${af.value}`);
306
+ }
307
+
308
+ // Request assertions: verify HTTP method and URL path
309
+ const expectedPath = buildExpectedPath(op);
310
+ lines.push(' request = httpx_mock.get_request()');
311
+ lines.push(` assert request.method == "${op.httpMethod.toUpperCase()}"`);
312
+ lines.push(` assert request.url.path.endswith("/${expectedPath}")`);
313
+ // For POST/PUT/PATCH with required body fields, verify specific field values
314
+ if (plan.hasBody && ['post', 'put', 'patch'].includes(op.httpMethod.toLowerCase())) {
315
+ const bodyModel = spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
316
+ const reqFields = bodyModel?.fields.filter((f) => f.required && !hiddenParams?.has(f.name)) ?? [];
317
+ if (reqFields.length > 0) {
318
+ lines.push(' body = json.loads(request.content)');
319
+ for (const f of reqFields) {
320
+ const testVal = generateTestValue(f.type, f.name);
321
+ // Only assert primitives (strings, numbers, booleans) — skip complex types
322
+ if (f.type.kind === 'primitive' || f.type.kind === 'enum' || f.type.kind === 'literal') {
323
+ lines.push(` assert body["${f.name}"] == ${testVal}`);
324
+ } else {
325
+ lines.push(` assert "${f.name}" in body`);
326
+ }
327
+ }
328
+ }
329
+ }
330
+ } else {
331
+ lines.push(` def test_${method}(self, workos, httpx_mock):`);
332
+ lines.push(' httpx_mock.add_response(json={})');
333
+ const args = buildTestArgs(op, spec, hiddenParams);
334
+ lines.push(` workos.${propName}.${method}(${args})`);
335
+ // Request assertions for void-returning methods
336
+ const voidPath = buildExpectedPath(op);
337
+ lines.push(' request = httpx_mock.get_request()');
338
+ lines.push(` assert request.method == "${op.httpMethod.toUpperCase()}"`);
339
+ lines.push(` assert request.url.path.endswith("/${voidPath}")`);
340
+ }
341
+
342
+ if (op.queryParams.length > 0 && !isRedirectEndpoint(op)) {
343
+ const queryArgs = buildQueryEncodingTestArgs(op, spec);
344
+ const queryAssertions = buildQueryEncodingAssertions(op, spec);
345
+ if (queryArgs && queryAssertions.length > 0) {
346
+ const responseSetup = buildQueryEncodingResponseSetup(op, plan);
347
+ lines.push('');
348
+ lines.push(` def test_${method}_encodes_query_params(self, workos, httpx_mock):`);
349
+ for (const setupLine of responseSetup) {
350
+ lines.push(` ${setupLine}`);
351
+ }
352
+ lines.push(` workos.${propName}.${method}(${queryArgs})`);
353
+ lines.push(' request = httpx_mock.get_request()');
354
+ for (const assertion of queryAssertions) {
355
+ lines.push(` ${assertion}`);
356
+ }
357
+ }
358
+ }
359
+ }
360
+
361
+ // Generate tests for wrapper (union-split) methods (sync)
362
+ emitWrapperTests(lines, resolvedOps, propName, spec, ctx, false);
363
+
364
+ // Add a RequestOptions propagation test for the first non-redirect operation
365
+ const firstRequestOptionsOp = service.operations.find((op) => !isRedirectEndpoint(op));
366
+ if (firstRequestOptionsOp) {
367
+ const roMethod = resolveMethodName(firstRequestOptionsOp, service, ctx);
368
+ const roPlan = planOperation(firstRequestOptionsOp);
369
+ const roResponseSetup = buildQueryEncodingResponseSetup(firstRequestOptionsOp, roPlan);
370
+ const roArgs = buildTestArgs(
371
+ firstRequestOptionsOp,
372
+ spec,
373
+ buildHiddenParams(lookupResolved(firstRequestOptionsOp, resolvedLookup)),
374
+ );
375
+ const roArgsWithOpts = roArgs
376
+ ? `${roArgs}, request_options={"extra_headers": {"X-Custom": "value"}}`
377
+ : 'request_options={"extra_headers": {"X-Custom": "value"}}';
378
+ lines.push('');
379
+ lines.push(` def test_${roMethod}_with_request_options(self, workos, httpx_mock):`);
380
+ for (const setupLine of roResponseSetup) {
381
+ lines.push(` ${setupLine}`);
382
+ }
383
+ lines.push(` workos.${propName}.${roMethod}(${roArgsWithOpts})`);
384
+ lines.push(' request = httpx_mock.get_request()');
385
+ lines.push(' assert request.headers["X-Custom"] == "value"');
386
+ }
387
+
388
+ // Add an error test for the first non-delete, non-redirect operation
389
+ const firstNonDelete = service.operations.find((op) => !planOperation(op).isDelete && !isRedirectEndpoint(op));
390
+ if (firstNonDelete) {
391
+ const method = resolveMethodName(firstNonDelete, service, ctx);
392
+ lines.push('');
393
+ lines.push(` def test_${method}_unauthorized(self, workos, httpx_mock):`);
394
+ lines.push(' httpx_mock.add_response(');
395
+ lines.push(' status_code=401,');
396
+ lines.push(' json={"message": "Unauthorized"},');
397
+ lines.push(' )');
398
+ lines.push(' with pytest.raises(AuthenticationError):');
399
+ const args = buildTestArgs(firstNonDelete, spec, buildHiddenParams(lookupResolved(firstNonDelete, resolvedLookup)));
400
+ lines.push(` workos.${propName}.${method}(${args})`);
401
+
402
+ lines.push('');
403
+ lines.push(` def test_${method}_not_found(self, httpx_mock):`);
404
+ lines.push(' workos = WorkOSClient(api_key="sk_test_123", client_id="client_test", max_retries=0)');
405
+ lines.push(' try:');
406
+ lines.push(' httpx_mock.add_response(status_code=404, json={"message": "Not found"})');
407
+ lines.push(' with pytest.raises(NotFoundError):');
408
+ lines.push(` workos.${propName}.${method}(${args})`);
409
+ lines.push(' finally:');
410
+ lines.push(' workos.close()');
411
+
412
+ lines.push('');
413
+ lines.push(` def test_${method}_rate_limited(self, httpx_mock):`);
414
+ lines.push(' workos = WorkOSClient(api_key="sk_test_123", client_id="client_test", max_retries=0)');
415
+ lines.push(' try:');
416
+ lines.push(
417
+ ' httpx_mock.add_response(status_code=429, headers={"Retry-After": "0"}, json={"message": "Slow down"})',
418
+ );
419
+ lines.push(' with pytest.raises(RateLimitExceededError):');
420
+ lines.push(` workos.${propName}.${method}(${args})`);
421
+ lines.push(' finally:');
422
+ lines.push(' workos.close()');
423
+
424
+ lines.push('');
425
+ lines.push(` def test_${method}_server_error(self, httpx_mock):`);
426
+ lines.push(' workos = WorkOSClient(api_key="sk_test_123", client_id="client_test", max_retries=0)');
427
+ lines.push(' try:');
428
+ lines.push(' httpx_mock.add_response(status_code=500, json={"message": "Server error"})');
429
+ lines.push(' with pytest.raises(ServerError):');
430
+ lines.push(` workos.${propName}.${method}(${args})`);
431
+ lines.push(' finally:');
432
+ lines.push(' workos.close()');
433
+ }
434
+
435
+ // Add 400/422 error tests for the first non-delete, non-redirect operation
436
+ const firstErrorTargetOp = service.operations.find((op) => !planOperation(op).isDelete && !isRedirectEndpoint(op));
437
+ if (firstErrorTargetOp) {
438
+ const writeMethod = resolveMethodName(firstErrorTargetOp, service, ctx);
439
+ const writeArgs = buildTestArgs(
440
+ firstErrorTargetOp,
441
+ spec,
442
+ buildHiddenParams(lookupResolved(firstErrorTargetOp, resolvedLookup)),
443
+ );
444
+
445
+ lines.push('');
446
+ lines.push(` def test_${writeMethod}_bad_request(self, httpx_mock):`);
447
+ lines.push(' workos = WorkOSClient(api_key="sk_test_123", client_id="client_test", max_retries=0)');
448
+ lines.push(' try:');
449
+ lines.push(' httpx_mock.add_response(status_code=400, json={"message": "Bad request"})');
450
+ lines.push(' with pytest.raises(BadRequestError):');
451
+ lines.push(` workos.${propName}.${writeMethod}(${writeArgs})`);
452
+ lines.push(' finally:');
453
+ lines.push(' workos.close()');
454
+
455
+ lines.push('');
456
+ lines.push(` def test_${writeMethod}_unprocessable(self, httpx_mock):`);
457
+ lines.push(' workos = WorkOSClient(api_key="sk_test_123", client_id="client_test", max_retries=0)');
458
+ lines.push(' try:');
459
+ lines.push(' httpx_mock.add_response(status_code=422, json={"message": "Unprocessable"})');
460
+ lines.push(' with pytest.raises(UnprocessableEntityError):');
461
+ lines.push(` workos.${propName}.${writeMethod}(${writeArgs})`);
462
+ lines.push(' finally:');
463
+ lines.push(' workos.close()');
464
+ }
465
+
466
+ // --- Async test class ---
467
+ lines.push('');
468
+ lines.push('');
469
+ lines.push(`class TestAsync${resolvedName}:`);
470
+
471
+ const asyncEmittedTestMethods = new Set<string>();
472
+ for (const op of service.operations) {
473
+ const plan = planOperation(op);
474
+ const asyncResolvedOp = lookupResolved(op, resolvedLookup);
475
+ const asyncHiddenParams = buildHiddenParams(asyncResolvedOp);
476
+ let method = resolveMethodName(op, service, ctx);
477
+
478
+ if (asyncEmittedTestMethods.has(method)) {
479
+ const fallback = toSnakeCase(op.name);
480
+ if (fallback !== method && !asyncEmittedTestMethods.has(fallback)) {
481
+ method = fallback;
482
+ } else {
483
+ continue;
484
+ }
485
+ }
486
+ asyncEmittedTestMethods.add(method);
487
+
488
+ const isDelete = plan.isDelete;
489
+ const isPaginated = plan.isPaginated;
490
+ const isAsyncArrayResponse = op.response.kind === 'array' && op.response.items.kind === 'model';
491
+ const asyncArgs = buildTestArgs(op, spec, asyncHiddenParams);
492
+
493
+ lines.push('');
494
+
495
+ if (isPaginated) {
496
+ const itemType = op.pagination!.itemType;
497
+ let itemName = itemType.kind === 'model' ? itemType.name : null;
498
+ if (itemName) {
499
+ const wrapperModel = spec.models.find((m) => m.name === itemName);
500
+ if (wrapperModel && isListWrapperModel(wrapperModel)) {
501
+ const dataField = wrapperModel.fields.find((f) => f.name === 'data');
502
+ if (dataField && dataField.type.kind === 'array' && dataField.type.items.kind === 'model') {
503
+ itemName = dataField.type.items.name;
504
+ }
505
+ }
506
+ }
507
+ // Skip fixture-based testing for models with no fields (discriminated unions)
508
+ if (itemName) {
509
+ const itemModel = spec.models.find((m) => m.name === itemName);
510
+ if (itemModel && itemModel.fields.length === 0) itemName = null;
511
+ }
512
+ const fixtureName = itemName ? `list_${fileName(itemName)}.json` : null;
513
+ pushAsyncTestDef(lines, ` async def test_${method}(self, async_workos, httpx_mock):`);
514
+ if (fixtureName) {
515
+ lines.push(` httpx_mock.add_response(json=load_fixture("${fixtureName}"))`);
516
+ lines.push(` page = await async_workos.${propName}.${method}(${asyncArgs})`);
517
+ lines.push(' assert isinstance(page, AsyncPage)');
518
+ lines.push(' assert isinstance(page.data, list)');
519
+
520
+ lines.push('');
521
+ pushAsyncTestDef(lines, ` async def test_${method}_empty_page(self, async_workos, httpx_mock):`);
522
+ lines.push(' httpx_mock.add_response(json={"data": [], "list_metadata": {}})');
523
+ lines.push(` page = await async_workos.${propName}.${method}(${asyncArgs})`);
524
+ lines.push(' assert isinstance(page, AsyncPage)');
525
+ lines.push(' assert page.data == []');
526
+ } else {
527
+ lines.push(' httpx_mock.add_response(json={"data": [], "list_metadata": {}})');
528
+ lines.push(` page = await async_workos.${propName}.${method}(${asyncArgs})`);
529
+ lines.push(' assert isinstance(page, AsyncPage)');
530
+ }
531
+ } else if (isDelete) {
532
+ const deletePath = buildExpectedPath(op);
533
+ pushAsyncTestDef(lines, ` async def test_${method}(self, async_workos, httpx_mock):`);
534
+ lines.push(' httpx_mock.add_response(status_code=204)');
535
+ lines.push(` result = await async_workos.${propName}.${method}(${asyncArgs})`);
536
+ lines.push(' assert result is None');
537
+ lines.push(' request = httpx_mock.get_request()');
538
+ lines.push(` assert request.method == "DELETE"`);
539
+ lines.push(` assert request.url.path.endswith("/${deletePath}")`);
540
+ } else if (isRedirectEndpoint(op)) {
541
+ // Redirect methods are sync (def, not async def) even in the async class
542
+ lines.push(` def test_${method}(self, async_workos):`);
543
+ lines.push(` result = async_workos.${propName}.${method}(${asyncArgs})`);
544
+ lines.push(' assert isinstance(result, str)');
545
+ lines.push(' assert result.startswith("http")');
546
+ } else if (isAsyncArrayResponse) {
547
+ const modelClass = className(plan.responseModelName!);
548
+ const fixtureName = `${fileName(plan.responseModelName!)}.json`;
549
+ pushAsyncTestDef(lines, ` async def test_${method}(self, async_workos, httpx_mock):`);
550
+ lines.push(` httpx_mock.add_response(json=[load_fixture("${fixtureName}")])`);
551
+ lines.push(` result = await async_workos.${propName}.${method}(${asyncArgs})`);
552
+ lines.push(' assert isinstance(result, list)');
553
+ lines.push(` assert len(result) == 1`);
554
+ lines.push(` assert isinstance(result[0], ${modelClass})`);
555
+ } else if (plan.responseModelName) {
556
+ const modelName = plan.responseModelName;
557
+ const fixtureName = `${fileName(modelName)}.json`;
558
+ const modelClass = className(modelName);
559
+ pushAsyncTestDef(lines, ` async def test_${method}(self, async_workos, httpx_mock):`);
560
+ lines.push(` httpx_mock.add_response(json=load_fixture("${fixtureName}"))`);
561
+ lines.push(` result = await async_workos.${propName}.${method}(${asyncArgs})`);
562
+ lines.push(` assert isinstance(result, ${modelClass})`);
563
+ // Field-value assertions
564
+ const assertFields = pickAssertableFields(modelName, spec);
565
+ for (const af of assertFields) {
566
+ const op_ = af.isBool ? 'is' : '==';
567
+ lines.push(` assert result.${af.field} ${op_} ${af.value}`);
568
+ }
569
+ // Request assertions
570
+ const expectedPath = buildExpectedPath(op);
571
+ lines.push(' request = httpx_mock.get_request()');
572
+ lines.push(` assert request.method == "${op.httpMethod.toUpperCase()}"`);
573
+ lines.push(` assert request.url.path.endswith("/${expectedPath}")`);
574
+ } else {
575
+ const voidPath = buildExpectedPath(op);
576
+ pushAsyncTestDef(lines, ` async def test_${method}(self, async_workos, httpx_mock):`);
577
+ lines.push(' httpx_mock.add_response(json={})');
578
+ lines.push(` await async_workos.${propName}.${method}(${asyncArgs})`);
579
+ lines.push(' request = httpx_mock.get_request()');
580
+ lines.push(` assert request.method == "${op.httpMethod.toUpperCase()}"`);
581
+ lines.push(` assert request.url.path.endswith("/${voidPath}")`);
582
+ }
583
+
584
+ if (op.queryParams.length > 0 && !isRedirectEndpoint(op)) {
585
+ const queryArgs = buildQueryEncodingTestArgs(op, spec);
586
+ const queryAssertions = buildQueryEncodingAssertions(op, spec);
587
+ if (queryArgs && queryAssertions.length > 0) {
588
+ const responseSetup = buildQueryEncodingResponseSetup(op, plan);
589
+ lines.push('');
590
+ pushAsyncTestDef(lines, ` async def test_${method}_encodes_query_params(self, async_workos, httpx_mock):`);
591
+ for (const setupLine of responseSetup) {
592
+ lines.push(` ${setupLine}`);
593
+ }
594
+ lines.push(` await async_workos.${propName}.${method}(${queryArgs})`);
595
+ lines.push(' request = httpx_mock.get_request()');
596
+ for (const assertion of queryAssertions) {
597
+ lines.push(` ${assertion}`);
598
+ }
599
+ }
600
+ }
601
+ }
602
+
603
+ // Generate tests for wrapper (union-split) methods (async)
604
+ emitWrapperTests(lines, resolvedOps, propName, spec, ctx, true);
605
+
606
+ // Add async RequestOptions propagation test
607
+ const asyncFirstRequestOptionsOp = service.operations.find((op) => !isRedirectEndpoint(op));
608
+ if (asyncFirstRequestOptionsOp) {
609
+ const asyncRoMethod = resolveMethodName(asyncFirstRequestOptionsOp, service, ctx);
610
+ const asyncRoPlan = planOperation(asyncFirstRequestOptionsOp);
611
+ const asyncRoResponseSetup = buildQueryEncodingResponseSetup(asyncFirstRequestOptionsOp, asyncRoPlan);
612
+ const asyncRoArgs = buildTestArgs(
613
+ asyncFirstRequestOptionsOp,
614
+ spec,
615
+ buildHiddenParams(lookupResolved(asyncFirstRequestOptionsOp, resolvedLookup)),
616
+ );
617
+ const asyncRoArgsWithOpts = asyncRoArgs
618
+ ? `${asyncRoArgs}, request_options={"extra_headers": {"X-Custom": "value"}}`
619
+ : 'request_options={"extra_headers": {"X-Custom": "value"}}';
620
+ lines.push('');
621
+ pushAsyncTestDef(
622
+ lines,
623
+ ` async def test_${asyncRoMethod}_with_request_options(self, async_workos, httpx_mock):`,
624
+ );
625
+ for (const setupLine of asyncRoResponseSetup) {
626
+ lines.push(` ${setupLine}`);
627
+ }
628
+ lines.push(` await async_workos.${propName}.${asyncRoMethod}(${asyncRoArgsWithOpts})`);
629
+ lines.push(' request = httpx_mock.get_request()');
630
+ lines.push(' assert request.headers["X-Custom"] == "value"');
631
+ }
632
+
633
+ // Async error tests for the first non-delete operation
634
+ const asyncFirstNonDelete = service.operations.find((op) => !planOperation(op).isDelete && !isRedirectEndpoint(op));
635
+ if (asyncFirstNonDelete) {
636
+ const asyncErrMethod = resolveMethodName(asyncFirstNonDelete, service, ctx);
637
+ const asyncErrArgs = buildTestArgs(
638
+ asyncFirstNonDelete,
639
+ spec,
640
+ buildHiddenParams(lookupResolved(asyncFirstNonDelete, resolvedLookup)),
641
+ );
642
+ lines.push('');
643
+ pushAsyncTestDef(lines, ` async def test_${asyncErrMethod}_unauthorized(self, async_workos, httpx_mock):`);
644
+ lines.push(' httpx_mock.add_response(status_code=401, json={"message": "Unauthorized"})');
645
+ lines.push(' with pytest.raises(AuthenticationError):');
646
+ lines.push(` await async_workos.${propName}.${asyncErrMethod}(${asyncErrArgs})`);
647
+ lines.push('');
648
+ pushAsyncTestDef(lines, ` async def test_${asyncErrMethod}_not_found(self, httpx_mock):`);
649
+ lines.push(' workos = AsyncWorkOSClient(api_key="sk_test_123", client_id="client_test", max_retries=0)');
650
+ lines.push(' try:');
651
+ lines.push(' httpx_mock.add_response(status_code=404, json={"message": "Not found"})');
652
+ lines.push(' with pytest.raises(NotFoundError):');
653
+ lines.push(` await workos.${propName}.${asyncErrMethod}(${asyncErrArgs})`);
654
+ lines.push(' finally:');
655
+ lines.push(' await workos.close()');
656
+ lines.push('');
657
+ pushAsyncTestDef(lines, ` async def test_${asyncErrMethod}_rate_limited(self, httpx_mock):`);
658
+ lines.push(' workos = AsyncWorkOSClient(api_key="sk_test_123", client_id="client_test", max_retries=0)');
659
+ lines.push(' try:');
660
+ lines.push(
661
+ ' httpx_mock.add_response(status_code=429, headers={"Retry-After": "0"}, json={"message": "Slow down"})',
662
+ );
663
+ lines.push(' with pytest.raises(RateLimitExceededError):');
664
+ lines.push(` await workos.${propName}.${asyncErrMethod}(${asyncErrArgs})`);
665
+ lines.push(' finally:');
666
+ lines.push(' await workos.close()');
667
+ lines.push('');
668
+ pushAsyncTestDef(lines, ` async def test_${asyncErrMethod}_server_error(self, httpx_mock):`);
669
+ lines.push(' workos = AsyncWorkOSClient(api_key="sk_test_123", client_id="client_test", max_retries=0)');
670
+ lines.push(' try:');
671
+ lines.push(' httpx_mock.add_response(status_code=500, json={"message": "Server error"})');
672
+ lines.push(' with pytest.raises(ServerError):');
673
+ lines.push(` await workos.${propName}.${asyncErrMethod}(${asyncErrArgs})`);
674
+ lines.push(' finally:');
675
+ lines.push(' await workos.close()');
676
+ }
677
+
678
+ // Async 400/422 error tests for the first non-delete, non-redirect operation
679
+ const asyncFirstErrorTargetOp = service.operations.find(
680
+ (op) => !planOperation(op).isDelete && !isRedirectEndpoint(op),
681
+ );
682
+ if (asyncFirstErrorTargetOp) {
683
+ const asyncWriteMethod = resolveMethodName(asyncFirstErrorTargetOp, service, ctx);
684
+ const asyncWriteArgs = buildTestArgs(
685
+ asyncFirstErrorTargetOp,
686
+ spec,
687
+ buildHiddenParams(lookupResolved(asyncFirstErrorTargetOp, resolvedLookup)),
688
+ );
689
+
690
+ lines.push('');
691
+ pushAsyncTestDef(lines, ` async def test_${asyncWriteMethod}_bad_request(self, httpx_mock):`);
692
+ lines.push(' workos = AsyncWorkOSClient(api_key="sk_test_123", client_id="client_test", max_retries=0)');
693
+ lines.push(' try:');
694
+ lines.push(' httpx_mock.add_response(status_code=400, json={"message": "Bad request"})');
695
+ lines.push(' with pytest.raises(BadRequestError):');
696
+ lines.push(` await workos.${propName}.${asyncWriteMethod}(${asyncWriteArgs})`);
697
+ lines.push(' finally:');
698
+ lines.push(' await workos.close()');
699
+
700
+ lines.push('');
701
+ pushAsyncTestDef(lines, ` async def test_${asyncWriteMethod}_unprocessable(self, httpx_mock):`);
702
+ lines.push(' workos = AsyncWorkOSClient(api_key="sk_test_123", client_id="client_test", max_retries=0)');
703
+ lines.push(' try:');
704
+ lines.push(' httpx_mock.add_response(status_code=422, json={"message": "Unprocessable"})');
705
+ lines.push(' with pytest.raises(UnprocessableEntityError):');
706
+ lines.push(` await workos.${propName}.${asyncWriteMethod}(${asyncWriteArgs})`);
707
+ lines.push(' finally:');
708
+ lines.push(' await workos.close()');
709
+ }
710
+
711
+ return {
712
+ path: `tests/test_${fileName(resolvedName)}.py`,
713
+ content: lines.join('\n'),
714
+ integrateTarget: true,
715
+ overwriteExisting: true,
716
+ };
717
+ }
718
+
719
+ /**
720
+ * Emit tests for wrapper (union-split) methods.
721
+ *
722
+ * For each resolved operation that has wrappers, emit a test per wrapper
723
+ * that calls the wrapper method, asserts the response type, and verifies
724
+ * that constant defaults appear in the request body.
725
+ */
726
+ function emitWrapperTests(
727
+ lines: string[],
728
+ resolvedOps: ResolvedOperation[] | undefined,
729
+ propName: string,
730
+ spec: ApiSpec,
731
+ ctx: EmitterContext,
732
+ isAsync: boolean,
733
+ ): void {
734
+ if (!resolvedOps) return;
735
+
736
+ for (const r of resolvedOps) {
737
+ if (!r.wrappers || r.wrappers.length === 0) continue;
738
+
739
+ for (const wrapper of r.wrappers) {
740
+ const method = wrapper.name;
741
+ const wrapperParams = resolveWrapperParams(wrapper, ctx);
742
+ const responseType = wrapper.responseModelName ? className(wrapper.responseModelName) : null;
743
+ const fixtureName = wrapper.responseModelName ? `${fileName(wrapper.responseModelName)}.json` : null;
744
+
745
+ // Build test args for required wrapper params
746
+ const argParts: string[] = [];
747
+ for (const { paramName, field, isOptional } of wrapperParams) {
748
+ if (isOptional) continue;
749
+ const pyName = fieldName(paramName);
750
+ const testVal = field ? generateTestValue(field.type, field.name) : '"test_value"';
751
+ argParts.push(`${pyName}=${testVal}`);
752
+ }
753
+ const args = argParts.join(', ');
754
+
755
+ lines.push('');
756
+ if (isAsync) {
757
+ pushAsyncTestDef(lines, ` async def test_${method}(self, async_workos, httpx_mock):`);
758
+ if (fixtureName) {
759
+ lines.push(` httpx_mock.add_response(json=load_fixture("${fixtureName}"))`);
760
+ lines.push(` result = await async_workos.${propName}.${method}(${args})`);
761
+ if (responseType) {
762
+ lines.push(` assert isinstance(result, ${responseType})`);
763
+ }
764
+ } else {
765
+ lines.push(' httpx_mock.add_response(json={})');
766
+ lines.push(` await async_workos.${propName}.${method}(${args})`);
767
+ }
768
+ } else {
769
+ lines.push(` def test_${method}(self, workos, httpx_mock):`);
770
+ if (fixtureName) {
771
+ lines.push(` httpx_mock.add_response(json=load_fixture("${fixtureName}"))`);
772
+ lines.push(` result = workos.${propName}.${method}(${args})`);
773
+ if (responseType) {
774
+ lines.push(` assert isinstance(result, ${responseType})`);
775
+ }
776
+ } else {
777
+ lines.push(' httpx_mock.add_response(json={})');
778
+ lines.push(` workos.${propName}.${method}(${args})`);
779
+ }
780
+ }
781
+
782
+ // Assert the request body contains the correct defaults
783
+ lines.push(' request = httpx_mock.get_request()');
784
+ lines.push(` assert request.method == "${r.operation.httpMethod.toUpperCase()}"`);
785
+
786
+ if (Object.keys(wrapper.defaults).length > 0) {
787
+ lines.push(' body = json.loads(request.content)');
788
+ for (const [key, value] of Object.entries(wrapper.defaults)) {
789
+ lines.push(` assert body["${key}"] == ${pythonLiteral(value)}`);
790
+ }
791
+ }
792
+ }
793
+ }
794
+ }
795
+
796
+ /**
797
+ * Pick up to N scalar fields from a model fixture to use for value assertions.
798
+ * Returns tuples of [snake_case_field_name, python_literal_value].
799
+ */
800
+ function pickAssertableFields(
801
+ modelName: string,
802
+ spec: ApiSpec,
803
+ maxFields: number = 2,
804
+ ): { field: string; value: string; isBool?: boolean }[] {
805
+ const modelMap = new Map(spec.models.map((m) => [m.name, m]));
806
+ const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
807
+ const model = modelMap.get(modelName);
808
+ if (!model) return [];
809
+
810
+ const fixture = generateModelFixture(model, modelMap, enumMap);
811
+ const results: { field: string; value: string; isBool?: boolean }[] = [];
812
+
813
+ for (const f of model.fields) {
814
+ if (results.length >= maxFields) break;
815
+ const val = fixture[f.name];
816
+ if (val === undefined || val === null) continue;
817
+ if (typeof val === 'string') {
818
+ // Skip strings containing characters that are hard to represent as Python literals
819
+ if (val.includes('"') || val.includes("'") || val.includes('{') || val.includes('\\') || val.includes('\n'))
820
+ continue;
821
+ results.push({ field: fieldName(f.name), value: `"${val}"` });
822
+ } else if (typeof val === 'boolean') {
823
+ // Use "is True/False" to satisfy ruff E712
824
+ results.push({ field: fieldName(f.name), value: val ? 'True' : 'False', isBool: true });
825
+ } else if (typeof val === 'number') {
826
+ results.push({ field: fieldName(f.name), value: String(val) });
827
+ }
828
+ }
829
+ return results;
830
+ }
831
+
832
+ /**
833
+ * Build a Python string literal for the expected request URL suffix.
834
+ * Replaces path params with their test values.
835
+ */
836
+ function buildExpectedPath(op: Operation): string {
837
+ let path = op.path.replace(/^\//, '');
838
+ for (const param of op.pathParams) {
839
+ path = path.replace(`{${param.name}}`, `test_${param.name}`);
840
+ }
841
+ return path;
842
+ }
843
+
844
+ /**
845
+ * Build test arguments string for an operation call.
846
+ */
847
+ function buildTestArgs(op: Operation, spec: ApiSpec, hiddenParams?: Set<string>): string {
848
+ const args: string[] = [];
849
+
850
+ // Path params as positional args
851
+ for (const param of op.pathParams) {
852
+ args.push(`"test_${param.name}"`);
853
+ }
854
+
855
+ const pathParamNames = new Set(op.pathParams.map((p) => fieldName(p.name)));
856
+
857
+ // Required body fields as keyword args (matching the expanded-field signature)
858
+ const plan = planOperation(op);
859
+ if (plan.hasBody && op.requestBody?.kind === 'model') {
860
+ const requestBodyName = op.requestBody.name;
861
+ const bodyModel = spec.models.find((m) => m.name === requestBodyName);
862
+ if (bodyModel) {
863
+ const reqFields = bodyModel.fields.filter((f) => f.required && !hiddenParams?.has(f.name));
864
+ for (const f of reqFields) {
865
+ const paramName = bodyParamName(f, pathParamNames);
866
+ args.push(`${paramName}=${generateTestValue(f.type, f.name)}`);
867
+ }
868
+ }
869
+ } else if (plan.hasBody && op.requestBody?.kind === 'union') {
870
+ // Union body — pick the first variant model and use its fixture
871
+ const variants = (op.requestBody as any).variants ?? [];
872
+ const firstModelVariant = variants.find((v: any) => v.kind === 'model');
873
+ if (firstModelVariant) {
874
+ args.push(`body=load_fixture("${fileName(firstModelVariant.name)}.json")`);
875
+ } else {
876
+ args.push('body={}');
877
+ }
878
+ }
879
+
880
+ // Per-operation Bearer token auth (e.g., access_token for SSO)
881
+ const hasBearerOverride = op.security?.some((s) => s.schemeName !== 'bearerAuth') ?? false;
882
+ if (hasBearerOverride) {
883
+ const tokenParamName = fieldName(op.security!.find((s) => s.schemeName !== 'bearerAuth')!.schemeName);
884
+ args.push(`${tokenParamName}="test_${tokenParamName}"`);
885
+ }
886
+
887
+ // Required query params (for all methods, including paginated)
888
+ if (plan.hasQueryParams) {
889
+ for (const param of op.queryParams) {
890
+ // Skip hidden/injected params
891
+ if (hiddenParams?.has(param.name)) continue;
892
+ // Skip pagination params (they're optional)
893
+ if (plan.isPaginated && ['limit', 'before', 'after', 'order'].includes(param.name)) continue;
894
+ // Skip params already covered by body fields
895
+ if (plan.hasBody && op.requestBody?.kind === 'model') {
896
+ const rbName = op.requestBody.name;
897
+ const bodyModel = spec.models.find((m) => m.name === rbName);
898
+ if (bodyModel?.fields.some((f) => fieldName(f.name) === fieldName(param.name))) continue;
899
+ }
900
+ if (param.required && !pathParamNames.has(fieldName(param.name))) {
901
+ args.push(`${fieldName(param.name)}=${generateTestValue(param.type, param.name)}`);
902
+ }
903
+ }
904
+ }
905
+
906
+ return args.join(', ');
907
+ }
908
+
909
+ function buildQueryEncodingTestArgs(op: Operation, spec: ApiSpec): string {
910
+ const args: string[] = [];
911
+
912
+ for (const param of op.pathParams) {
913
+ args.push(`"test_${param.name}"`);
914
+ }
915
+
916
+ const pathParamNames = new Set(op.pathParams.map((p) => fieldName(p.name)));
917
+ const plan = planOperation(op);
918
+
919
+ if (plan.hasBody && op.requestBody?.kind === 'model') {
920
+ const bodyModel = spec.models.find((m) => m.name === (op.requestBody as { kind: string; name: string }).name);
921
+ for (const field of bodyModel?.fields.filter((f) => f.required) ?? []) {
922
+ args.push(`${bodyParamName(field, pathParamNames)}=${generateTestValue(field.type, field.name)}`);
923
+ }
924
+ } else if (plan.hasBody && op.requestBody?.kind === 'union') {
925
+ const variants = (op.requestBody as any).variants ?? [];
926
+ const firstModelVariant = variants.find((v: any) => v.kind === 'model');
927
+ args.push(firstModelVariant ? `body=load_fixture("${fileName(firstModelVariant.name)}.json")` : 'body={}');
928
+ }
929
+
930
+ if (plan.isPaginated) {
931
+ args.push('limit=10');
932
+ args.push('before="cursor before"');
933
+ args.push('after="cursor/after"');
934
+ const orderParam = op.queryParams.find((param) => param.name === 'order');
935
+ if (orderParam) {
936
+ args.push(`order=${generateQueryEncodingValue(orderParam.type, 'order')}`);
937
+ }
938
+ }
939
+
940
+ for (const param of op.queryParams) {
941
+ if (plan.isPaginated && ['limit', 'before', 'after', 'order'].includes(param.name)) continue;
942
+ // Include explode=false array params; skip other array params (complex serialization)
943
+ if (param.type.kind === 'array' && (param as any).explode !== false) continue;
944
+ const paramName = fieldName(param.name);
945
+ if (pathParamNames.has(paramName)) continue;
946
+ if (plan.hasBody && op.requestBody?.kind === 'model') {
947
+ const bodyModel = spec.models.find((m) => m.name === (op.requestBody as { kind: string; name: string }).name);
948
+ if (bodyModel?.fields.some((field) => bodyParamName(field, pathParamNames) === paramName)) continue;
949
+ }
950
+ if ((param as any).explode === false && param.type.kind === 'array') {
951
+ args.push(`${paramName}=["val1", "val2"]`);
952
+ } else {
953
+ args.push(`${paramName}=${generateQueryEncodingValue(param.type, param.name)}`);
954
+ }
955
+ }
956
+
957
+ return args.join(', ');
958
+ }
959
+
960
+ function buildQueryEncodingResponseSetup(op: Operation, plan: ReturnType<typeof planOperation>): string[] {
961
+ if (plan.isPaginated) {
962
+ return ['httpx_mock.add_response(json={"data": [], "list_metadata": {}})'];
963
+ }
964
+ if (plan.isDelete) {
965
+ return ['httpx_mock.add_response(status_code=204)'];
966
+ }
967
+ if (op.response.kind === 'array') {
968
+ if (op.response.items.kind === 'model') {
969
+ return [`httpx_mock.add_response(json=[load_fixture("${fileName(op.response.items.name)}.json")])`];
970
+ }
971
+ return ['httpx_mock.add_response(json=[])'];
972
+ }
973
+ if (plan.responseModelName) {
974
+ return [`httpx_mock.add_response(json=load_fixture("${fileName(plan.responseModelName)}.json"))`];
975
+ }
976
+ return ['httpx_mock.add_response(json={})'];
977
+ }
978
+
979
+ function buildQueryEncodingAssertions(op: Operation, spec: ApiSpec): string[] {
980
+ const assertions: string[] = [];
981
+ const plan = planOperation(op);
982
+ const pathParamNames = new Set(op.pathParams.map((param) => fieldName(param.name)));
983
+
984
+ if (plan.isPaginated) {
985
+ assertions.push('assert request.url.params["limit"] == "10"');
986
+ assertions.push('assert request.url.params["before"] == "cursor before"');
987
+ assertions.push('assert request.url.params["after"] == "cursor/after"');
988
+ const orderParam = op.queryParams.find((param) => param.name === 'order');
989
+ if (orderParam) {
990
+ assertions.push(
991
+ `assert request.url.params["order"] == ${toPythonLiteral(expectedQueryEncodingValue(orderParam.type, 'order'))}`,
992
+ );
993
+ }
994
+ }
995
+
996
+ for (const param of op.queryParams) {
997
+ if (plan.isPaginated && ['limit', 'before', 'after', 'order'].includes(param.name)) continue;
998
+ // Include explode=false array params; skip other array params (complex serialization)
999
+ if (param.type.kind === 'array' && (param as any).explode !== false) continue;
1000
+ const paramName = fieldName(param.name);
1001
+ if (pathParamNames.has(paramName)) continue;
1002
+ if (plan.hasBody && op.requestBody?.kind === 'model') {
1003
+ const bodyModel = spec.models.find(
1004
+ (model) => model.name === (op.requestBody as { kind: string; name: string }).name,
1005
+ );
1006
+ if (bodyModel?.fields.some((field) => bodyParamName(field, pathParamNames) === paramName)) continue;
1007
+ }
1008
+ if ((param as any).explode === false && param.type.kind === 'array') {
1009
+ assertions.push(`assert request.url.params["${param.name}"] == "val1,val2"`);
1010
+ } else {
1011
+ assertions.push(
1012
+ `assert request.url.params["${param.name}"] == ${toPythonLiteral(expectedQueryEncodingValue(param.type, param.name))}`,
1013
+ );
1014
+ }
1015
+ }
1016
+
1017
+ return assertions;
1018
+ }
1019
+
1020
+ /**
1021
+ * Generate a representative Python value literal for a given type, for use in tests.
1022
+ */
1023
+ function generateTestValue(ref: TypeRef, name: string): string {
1024
+ switch (ref.kind) {
1025
+ case 'primitive':
1026
+ switch (ref.type) {
1027
+ case 'string':
1028
+ return `"test_${name}"`;
1029
+ case 'integer':
1030
+ return '1';
1031
+ case 'number':
1032
+ return '1.0';
1033
+ case 'boolean':
1034
+ return 'True';
1035
+ default:
1036
+ return '{}';
1037
+ }
1038
+ case 'array':
1039
+ return '[]';
1040
+ case 'enum': {
1041
+ const enumValues = (ref as any).values as (string | number)[] | undefined;
1042
+ const enumClass = className(ref.name);
1043
+ if (enumValues && enumValues.length > 0) {
1044
+ const first = enumValues[0];
1045
+ const literal = typeof first === 'string' ? `"${first}"` : String(first);
1046
+ return `${enumClass}(${literal})`;
1047
+ }
1048
+ return `${enumClass}("test")`;
1049
+ }
1050
+ case 'model':
1051
+ return `${className(ref.name)}.from_dict(load_fixture("${fileName(ref.name)}.json"))`;
1052
+ case 'nullable':
1053
+ return generateTestValue(ref.inner, name);
1054
+ case 'map':
1055
+ return '{}';
1056
+ case 'literal':
1057
+ return typeof ref.value === 'string' ? `"${ref.value}"` : String(ref.value);
1058
+ case 'union':
1059
+ if (ref.variants.length > 0) return generateTestValue(ref.variants[0], name);
1060
+ return 'None';
1061
+ default:
1062
+ return '{}';
1063
+ }
1064
+ }
1065
+
1066
+ function generateQueryEncodingValue(ref: TypeRef, name: string): string {
1067
+ switch (ref.kind) {
1068
+ case 'primitive':
1069
+ switch (ref.type) {
1070
+ case 'string':
1071
+ return `"${expectedQueryEncodingValue(ref, name)}"`;
1072
+ case 'integer':
1073
+ return '7';
1074
+ case 'number':
1075
+ return '7.5';
1076
+ case 'boolean':
1077
+ return 'True';
1078
+ default:
1079
+ return '{}';
1080
+ }
1081
+ case 'enum': {
1082
+ const value = expectedQueryEncodingValue(ref, name);
1083
+ return `${className(ref.name)}("${value}")`;
1084
+ }
1085
+ case 'nullable':
1086
+ return generateQueryEncodingValue(ref.inner, name);
1087
+ case 'literal':
1088
+ return toPythonLiteral(ref.value);
1089
+ default:
1090
+ return generateTestValue(ref, name);
1091
+ }
1092
+ }
1093
+
1094
+ function expectedQueryEncodingValue(ref: TypeRef, name: string): string | number {
1095
+ switch (ref.kind) {
1096
+ case 'primitive':
1097
+ switch (ref.type) {
1098
+ case 'string':
1099
+ return `value ${name}/test`;
1100
+ case 'integer':
1101
+ return 7;
1102
+ case 'number':
1103
+ return 7.5;
1104
+ case 'boolean':
1105
+ return 'true';
1106
+ default:
1107
+ return `value ${name}`;
1108
+ }
1109
+ case 'enum': {
1110
+ const enumValues = (ref as any).values as (string | number)[] | undefined;
1111
+ if (enumValues && enumValues.length > 0) return enumValues[0];
1112
+ return `value_${name}`;
1113
+ }
1114
+ case 'nullable':
1115
+ return expectedQueryEncodingValue(ref.inner, name);
1116
+ case 'literal': {
1117
+ const v = ref.value;
1118
+ if (typeof v === 'boolean') return v ? 'true' : 'false';
1119
+ return v ?? `value_${name}`;
1120
+ }
1121
+ default:
1122
+ return `value_${name}`;
1123
+ }
1124
+ }
1125
+
1126
+ function buildMinimalModelPayload(model: Model, fixture: Record<string, unknown>): Record<string, unknown> {
1127
+ const payload: Record<string, unknown> = {};
1128
+ for (const field of model.fields) {
1129
+ if (!field.required) continue;
1130
+ if (field.type.kind === 'nullable') {
1131
+ payload[field.name] = null;
1132
+ continue;
1133
+ }
1134
+ payload[field.name] = fixture[field.name];
1135
+ }
1136
+ return payload;
1137
+ }
1138
+
1139
+ function buildPayloadWithoutOptionalNonNullableFields(
1140
+ model: Model,
1141
+ fixture: Record<string, unknown>,
1142
+ ): Record<string, unknown> {
1143
+ const payload: Record<string, unknown> = { ...fixture };
1144
+ for (const field of model.fields) {
1145
+ if (!field.required && field.type.kind !== 'nullable') {
1146
+ delete payload[field.name];
1147
+ }
1148
+ }
1149
+ return payload;
1150
+ }
1151
+
1152
+ function buildPayloadWithNullableFieldsSetToNull(
1153
+ model: Model,
1154
+ fixture: Record<string, unknown>,
1155
+ ): Record<string, unknown> | null {
1156
+ const nullableFields = model.fields.filter((field) => field.type.kind === 'nullable');
1157
+ if (nullableFields.length === 0) return null;
1158
+ const payload: Record<string, unknown> = { ...fixture };
1159
+ for (const field of nullableFields) {
1160
+ payload[field.name] = null;
1161
+ }
1162
+ return payload;
1163
+ }
1164
+
1165
+ function buildPayloadWithUnknownEnumValue(
1166
+ model: Model,
1167
+ fixture: Record<string, unknown>,
1168
+ ): Record<string, unknown> | null {
1169
+ const payload: Record<string, unknown> = { ...fixture };
1170
+ const enumField = model.fields.find((field) => field.type.kind === 'enum');
1171
+ if (!enumField) return null;
1172
+ payload[enumField.name] = `unexpected_${fileName(model.name)}_${fieldName(enumField.name)}`;
1173
+ return payload;
1174
+ }
1175
+
1176
+ function toPythonLiteral(value: unknown): string {
1177
+ if (value === null) return 'None';
1178
+ if (typeof value === 'string') return JSON.stringify(value);
1179
+ if (typeof value === 'number' || typeof value === 'boolean')
1180
+ return JSON.stringify(value).replace('true', 'True').replace('false', 'False');
1181
+ if (Array.isArray(value)) return `[${value.map((item) => toPythonLiteral(item)).join(', ')}]`;
1182
+ if (typeof value === 'object') {
1183
+ const entries = Object.entries(value as Record<string, unknown>).map(
1184
+ ([key, inner]) => `${JSON.stringify(key)}: ${toPythonLiteral(inner)}`,
1185
+ );
1186
+ return `{${entries.join(', ')}}`;
1187
+ }
1188
+ return 'None';
1189
+ }
1190
+
1191
+ /**
1192
+ * Generate model round-trip tests: Model.from_dict(instance.to_dict()) == instance
1193
+ */
1194
+ function generateModelRoundTripTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile | null {
1195
+ // Collect models used as request bodies only (not returned in responses)
1196
+ const responseModelNames = new Set<string>();
1197
+ const requestOnlyModelNames = new Set<string>();
1198
+ for (const svc of spec.services) {
1199
+ for (const op of svc.operations) {
1200
+ const plan = planOperation(op);
1201
+ if (plan.responseModelName) responseModelNames.add(plan.responseModelName);
1202
+ if (op.requestBody?.kind === 'model') requestOnlyModelNames.add(op.requestBody.name);
1203
+ // Also collect union body variant models as request-only
1204
+ if (op.requestBody?.kind === 'union') {
1205
+ for (const v of (op.requestBody as any).variants ?? []) {
1206
+ if (v.kind === 'model') requestOnlyModelNames.add(v.name);
1207
+ }
1208
+ }
1209
+ }
1210
+ }
1211
+ // A model is request-only if it's used as a request body but never as a response
1212
+ for (const name of responseModelNames) requestOnlyModelNames.delete(name);
1213
+
1214
+ const models = spec.models.filter(
1215
+ (m) => !isListWrapperModel(m) && !isListMetadataModel(m) && !requestOnlyModelNames.has(m.name),
1216
+ );
1217
+ if (models.length === 0) return null;
1218
+
1219
+ const modelToService = assignModelsToServices(spec.models, spec.services);
1220
+ const roundTripDirMap = buildMountDirMap(ctx);
1221
+ const resolveDir = (irService: string | undefined) =>
1222
+ irService ? (roundTripDirMap.get(irService) ?? 'common') : 'common';
1223
+
1224
+ const lines: string[] = [];
1225
+ lines.push('"""Model round-trip tests: from_dict(to_dict()) preserves data."""');
1226
+ lines.push('');
1227
+ lines.push('import pytest');
1228
+ lines.push('');
1229
+ lines.push('from tests.generated_helpers import load_fixture');
1230
+ lines.push('');
1231
+
1232
+ // Collect imports by directory
1233
+ const importsByDir = new Map<string, string[]>();
1234
+ for (const model of models) {
1235
+ const service = modelToService.get(model.name);
1236
+ const dirName = resolveDir(service);
1237
+ if (!importsByDir.has(dirName)) importsByDir.set(dirName, []);
1238
+ importsByDir.get(dirName)!.push(className(model.name));
1239
+ }
1240
+
1241
+ for (const [dirName, names] of [...importsByDir].sort()) {
1242
+ lines.push(`from ${ctx.namespace}.${dirToModule(dirName)}.models import ${names.sort().join(', ')}`);
1243
+ }
1244
+
1245
+ lines.push('');
1246
+ lines.push('');
1247
+ lines.push('class TestModelRoundTrip:');
1248
+
1249
+ for (const model of models) {
1250
+ // Skip models with no fields — these are typically discriminated unions
1251
+ // with hand-maintained @oagen-ignore overrides whose fixtures would not match.
1252
+ if (model.fields.length === 0) continue;
1253
+ // Deduplicate fields that map to the same snake_case name (mirrors models.ts)
1254
+ const seenFieldNames = new Set<string>();
1255
+ const dedupFields = model.fields.filter((f) => {
1256
+ const pyName = fieldName(f.name);
1257
+ if (seenFieldNames.has(pyName)) return false;
1258
+ seenFieldNames.add(pyName);
1259
+ return true;
1260
+ });
1261
+ const dedupModel = { ...model, fields: dedupFields };
1262
+
1263
+ const modelClass = className(model.name);
1264
+ const fixtureName = `${fileName(model.name)}.json`;
1265
+ const fullFixture = generateModelFixture(
1266
+ dedupModel,
1267
+ new Map(spec.models.map((m) => [m.name, m])),
1268
+ new Map(spec.enums.map((e) => [e.name, e])),
1269
+ );
1270
+ const minimalPayload = buildMinimalModelPayload(dedupModel, fullFixture);
1271
+ const absentOptionalPayload = buildPayloadWithoutOptionalNonNullableFields(dedupModel, fullFixture);
1272
+ const nullablePayload = buildPayloadWithNullableFieldsSetToNull(dedupModel, fullFixture);
1273
+ const unknownEnumPayload = buildPayloadWithUnknownEnumValue(dedupModel, fullFixture);
1274
+
1275
+ lines.push('');
1276
+ lines.push(` def test_${fileName(model.name)}_round_trip(self):`);
1277
+ lines.push(` data = load_fixture("${fixtureName}")`);
1278
+ lines.push(` instance = ${modelClass}.from_dict(data)`);
1279
+ lines.push(' serialized = instance.to_dict()');
1280
+ lines.push(' assert serialized == data');
1281
+ lines.push(` restored = ${modelClass}.from_dict(serialized)`);
1282
+ lines.push(' assert restored.to_dict() == serialized');
1283
+
1284
+ const requiredFields = dedupFields.filter((field) => field.required);
1285
+ lines.push('');
1286
+ lines.push(` def test_${fileName(model.name)}_minimal_payload(self):`);
1287
+ lines.push(` data = ${toPythonLiteral(minimalPayload)}`);
1288
+ lines.push(` instance = ${modelClass}.from_dict(data)`);
1289
+ if (requiredFields.length > 0) {
1290
+ lines.push(' serialized = instance.to_dict()');
1291
+ for (const field of requiredFields) {
1292
+ lines.push(` assert serialized[${toPythonLiteral(field.name)}] == data[${toPythonLiteral(field.name)}]`);
1293
+ }
1294
+ } else {
1295
+ lines.push(' assert instance.to_dict() is not None');
1296
+ }
1297
+
1298
+ if (Object.keys(absentOptionalPayload).length !== Object.keys(fullFixture).length) {
1299
+ lines.push('');
1300
+ lines.push(` def test_${fileName(model.name)}_omits_absent_optional_non_nullable_fields(self):`);
1301
+ lines.push(` data = ${toPythonLiteral(absentOptionalPayload)}`);
1302
+ lines.push(` instance = ${modelClass}.from_dict(data)`);
1303
+ lines.push(' serialized = instance.to_dict()');
1304
+ for (const field of dedupFields.filter((field) => !field.required && field.type.kind !== 'nullable')) {
1305
+ lines.push(` assert ${toPythonLiteral(field.name)} not in serialized`);
1306
+ }
1307
+ }
1308
+
1309
+ if (nullablePayload) {
1310
+ lines.push('');
1311
+ lines.push(` def test_${fileName(model.name)}_preserves_nullable_fields(self):`);
1312
+ lines.push(` data = ${toPythonLiteral(nullablePayload)}`);
1313
+ lines.push(` instance = ${modelClass}.from_dict(data)`);
1314
+ lines.push(' serialized = instance.to_dict()');
1315
+ for (const field of dedupFields.filter((field) => field.type.kind === 'nullable')) {
1316
+ lines.push(` assert serialized[${toPythonLiteral(field.name)}] is None`);
1317
+ }
1318
+ }
1319
+
1320
+ if (unknownEnumPayload) {
1321
+ lines.push('');
1322
+ lines.push(` def test_${fileName(model.name)}_round_trips_unknown_enum_values(self):`);
1323
+ lines.push(` data = ${toPythonLiteral(unknownEnumPayload)}`);
1324
+ lines.push(` instance = ${modelClass}.from_dict(data)`);
1325
+ lines.push(' assert instance.to_dict() == data');
1326
+ }
1327
+ }
1328
+
1329
+ return {
1330
+ path: 'tests/test_models_round_trip.py',
1331
+ content: lines.join('\n'),
1332
+ integrateTarget: true,
1333
+ overwriteExisting: true,
1334
+ };
1335
+ }