@workos/oagen-emitters 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +13 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +14549 -3385
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/dotnet.md +336 -0
  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 +328 -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 +45 -12
  18. package/smoke/sdk-go.ts +116 -42
  19. package/smoke/sdk-php.ts +28 -26
  20. package/smoke/sdk-python.ts +5 -2
  21. package/src/dotnet/client.ts +89 -0
  22. package/src/dotnet/enums.ts +323 -0
  23. package/src/dotnet/fixtures.ts +236 -0
  24. package/src/dotnet/index.ts +246 -0
  25. package/src/dotnet/manifest.ts +36 -0
  26. package/src/dotnet/models.ts +344 -0
  27. package/src/dotnet/naming.ts +330 -0
  28. package/src/dotnet/resources.ts +622 -0
  29. package/src/dotnet/tests.ts +693 -0
  30. package/src/dotnet/type-map.ts +201 -0
  31. package/src/dotnet/wrappers.ts +186 -0
  32. package/src/go/client.ts +141 -0
  33. package/src/go/enums.ts +196 -0
  34. package/src/go/fixtures.ts +212 -0
  35. package/src/go/index.ts +84 -0
  36. package/src/go/manifest.ts +36 -0
  37. package/src/go/models.ts +254 -0
  38. package/src/go/naming.ts +179 -0
  39. package/src/go/resources.ts +827 -0
  40. package/src/go/tests.ts +751 -0
  41. package/src/go/type-map.ts +82 -0
  42. package/src/go/wrappers.ts +261 -0
  43. package/src/index.ts +4 -0
  44. package/src/kotlin/client.ts +53 -0
  45. package/src/kotlin/enums.ts +162 -0
  46. package/src/kotlin/index.ts +92 -0
  47. package/src/kotlin/manifest.ts +55 -0
  48. package/src/kotlin/models.ts +395 -0
  49. package/src/kotlin/naming.ts +223 -0
  50. package/src/kotlin/overrides.ts +25 -0
  51. package/src/kotlin/resources.ts +667 -0
  52. package/src/kotlin/tests.ts +1019 -0
  53. package/src/kotlin/type-map.ts +123 -0
  54. package/src/kotlin/wrappers.ts +168 -0
  55. package/src/node/client.ts +128 -115
  56. package/src/node/enums.ts +9 -0
  57. package/src/node/errors.ts +37 -232
  58. package/src/node/field-plan.ts +726 -0
  59. package/src/node/fixtures.ts +9 -1
  60. package/src/node/index.ts +3 -9
  61. package/src/node/models.ts +178 -21
  62. package/src/node/naming.ts +49 -111
  63. package/src/node/resources.ts +527 -397
  64. package/src/node/sdk-errors.ts +41 -0
  65. package/src/node/tests.ts +69 -19
  66. package/src/node/type-map.ts +4 -2
  67. package/src/node/utils.ts +13 -71
  68. package/src/node/wrappers.ts +151 -0
  69. package/src/php/client.ts +179 -0
  70. package/src/php/enums.ts +67 -0
  71. package/src/php/errors.ts +9 -0
  72. package/src/php/fixtures.ts +181 -0
  73. package/src/php/index.ts +96 -0
  74. package/src/php/manifest.ts +36 -0
  75. package/src/php/models.ts +310 -0
  76. package/src/php/naming.ts +279 -0
  77. package/src/php/resources.ts +636 -0
  78. package/src/php/tests.ts +609 -0
  79. package/src/php/type-map.ts +90 -0
  80. package/src/php/utils.ts +18 -0
  81. package/src/php/wrappers.ts +152 -0
  82. package/src/python/client.ts +345 -0
  83. package/src/python/enums.ts +313 -0
  84. package/src/python/fixtures.ts +196 -0
  85. package/src/python/index.ts +95 -0
  86. package/src/python/manifest.ts +38 -0
  87. package/src/python/models.ts +688 -0
  88. package/src/python/naming.ts +189 -0
  89. package/src/python/resources.ts +1322 -0
  90. package/src/python/tests.ts +1335 -0
  91. package/src/python/type-map.ts +93 -0
  92. package/src/python/wrappers.ts +191 -0
  93. package/src/shared/model-utils.ts +472 -0
  94. package/src/shared/naming-utils.ts +154 -0
  95. package/src/shared/non-spec-services.ts +54 -0
  96. package/src/shared/resolved-ops.ts +109 -0
  97. package/src/shared/wrapper-utils.ts +70 -0
  98. package/test/dotnet/client.test.ts +121 -0
  99. package/test/dotnet/enums.test.ts +193 -0
  100. package/test/dotnet/errors.test.ts +9 -0
  101. package/test/dotnet/manifest.test.ts +82 -0
  102. package/test/dotnet/models.test.ts +260 -0
  103. package/test/dotnet/resources.test.ts +255 -0
  104. package/test/dotnet/tests.test.ts +202 -0
  105. package/test/go/client.test.ts +92 -0
  106. package/test/go/enums.test.ts +132 -0
  107. package/test/go/errors.test.ts +9 -0
  108. package/test/go/models.test.ts +265 -0
  109. package/test/go/resources.test.ts +408 -0
  110. package/test/go/tests.test.ts +143 -0
  111. package/test/kotlin/models.test.ts +135 -0
  112. package/test/kotlin/tests.test.ts +176 -0
  113. package/test/node/client.test.ts +92 -12
  114. package/test/node/enums.test.ts +2 -0
  115. package/test/node/errors.test.ts +2 -41
  116. package/test/node/models.test.ts +2 -0
  117. package/test/node/naming.test.ts +23 -0
  118. package/test/node/resources.test.ts +315 -84
  119. package/test/node/serializers.test.ts +3 -1
  120. package/test/node/type-map.test.ts +11 -0
  121. package/test/php/client.test.ts +95 -0
  122. package/test/php/enums.test.ts +173 -0
  123. package/test/php/errors.test.ts +9 -0
  124. package/test/php/models.test.ts +497 -0
  125. package/test/php/resources.test.ts +682 -0
  126. package/test/php/tests.test.ts +185 -0
  127. package/test/python/client.test.ts +200 -0
  128. package/test/python/enums.test.ts +228 -0
  129. package/test/python/errors.test.ts +16 -0
  130. package/test/python/manifest.test.ts +74 -0
  131. package/test/python/models.test.ts +716 -0
  132. package/test/python/resources.test.ts +617 -0
  133. package/test/python/tests.test.ts +202 -0
  134. package/src/node/common.ts +0 -273
  135. package/src/node/config.ts +0 -71
  136. package/src/node/serializers.ts +0 -746
@@ -0,0 +1,609 @@
1
+ import type {
2
+ ApiSpec,
3
+ Service,
4
+ Operation,
5
+ EmitterContext,
6
+ GeneratedFile,
7
+ Model,
8
+ ResolvedOperation,
9
+ } from '@workos/oagen';
10
+ import { planOperation, toCamelCase, toPascalCase } from '@workos/oagen';
11
+ import { className, enumClassName, resolveMethodName, snakeName, servicePropertyName } from './naming.js';
12
+ import { isListWrapperModel } from './models.js';
13
+ import { generateFixtures } from './fixtures.js';
14
+ import {
15
+ getMountTarget,
16
+ groupByMount,
17
+ buildHiddenParams,
18
+ getOpDefaults,
19
+ getOpInferFromClient,
20
+ } from '../shared/resolved-ops.js';
21
+ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
22
+ import { isRedirectEndpoint } from './resources.js';
23
+
24
+ /**
25
+ * Generate PHPUnit test files and fixture JSON files.
26
+ */
27
+ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
28
+ const files: GeneratedFile[] = [];
29
+
30
+ // TestHelper is now hand-maintained in the target SDK (@oagen-ignore-file).
31
+
32
+ // Collect all operations per mount target using resolved per-operation mounts.
33
+ // This correctly handles operationHint mountOn overrides (e.g., audit_logs_retention → AuditLogs).
34
+ const mountGroupsFromResolved = groupByMount(ctx);
35
+ const mountGroups = new Map<string, { op: Operation; service: Service; resolvedOp?: ResolvedOperation }[]>();
36
+ if (mountGroupsFromResolved.size > 0) {
37
+ for (const [target, group] of mountGroupsFromResolved) {
38
+ mountGroups.set(
39
+ target,
40
+ group.resolvedOps.map((r) => ({ op: r.operation, service: r.service, resolvedOp: r })),
41
+ );
42
+ }
43
+ } else {
44
+ // Fallback: group by service
45
+ for (const service of spec.services) {
46
+ const target = getMountTarget(service, ctx);
47
+ if (!mountGroups.has(target)) mountGroups.set(target, []);
48
+ for (const op of service.operations) {
49
+ mountGroups.get(target)!.push({ op, service });
50
+ }
51
+ }
52
+ }
53
+
54
+ // Generate resource tests (one per mount target, all operations included)
55
+ // Use overwriteExisting so the integration step always writes the latest
56
+ // test content rather than attempting additive AST merge.
57
+ for (const [target, ops] of mountGroups) {
58
+ files.push({
59
+ path: `tests/Service/${className(target)}Test.php`,
60
+ content: generateMountGroupTest(target, ops, ctx),
61
+ overwriteExisting: true,
62
+ });
63
+ }
64
+
65
+ // Generate client test
66
+ files.push({
67
+ path: 'tests/ClientTest.php',
68
+ content: generateClientTest(ctx),
69
+ overwriteExisting: true,
70
+ });
71
+
72
+ // Generate fixture JSON files
73
+ const fixtures = generateFixtures(spec);
74
+ for (const fixture of fixtures) {
75
+ files.push({
76
+ path: fixture.path,
77
+ content: fixture.content,
78
+ headerPlacement: 'skip',
79
+ });
80
+ }
81
+
82
+ return files;
83
+ }
84
+
85
+ function generateMountGroupTest(
86
+ target: string,
87
+ ops: { op: Operation; service: Service; resolvedOp?: ResolvedOperation }[],
88
+ ctx: EmitterContext,
89
+ ): string {
90
+ const ns = ctx.namespacePascal;
91
+ const name = className(target);
92
+ const accessor = servicePropertyName(target);
93
+ const lines: string[] = [];
94
+
95
+ // No <?php here — the file header from fileHeader() provides it
96
+ lines.push('namespace Tests\\Service;');
97
+ lines.push('');
98
+ lines.push('use PHPUnit\\Framework\\TestCase;');
99
+ lines.push('use WorkOS\\TestHelper;');
100
+ lines.push('');
101
+ lines.push(`class ${name}Test extends TestCase`);
102
+ lines.push('{');
103
+ lines.push(' use TestHelper;');
104
+
105
+ // Track emitted test names to avoid duplicates
106
+ const emitted = new Set<string>();
107
+
108
+ // Generate tests for all operations across all services in the mount group.
109
+ // Uses the hand-maintained TestHelper API:
110
+ // - loadFixture(name) appends .json automatically
111
+ // - createMockClient([['status' => N, 'body' => [...]]]) wraps into Response
112
+ for (const { op, service, resolvedOp } of ops) {
113
+ // Skip base method when wrappers exist (matches resources.ts)
114
+ if (resolvedOp?.wrappers && resolvedOp.wrappers.length > 0) continue;
115
+
116
+ const plan = planOperation(op);
117
+ const method = resolveMethodName(op, service, ctx);
118
+ const testName = `test${method.charAt(0).toUpperCase()}${method.slice(1)}`;
119
+ const hidden = buildHiddenParams(resolvedOp);
120
+
121
+ if (emitted.has(testName)) continue;
122
+ emitted.add(testName);
123
+
124
+ lines.push('');
125
+ lines.push(` public function ${testName}(): void`);
126
+ lines.push(' {');
127
+
128
+ const expectedPath = buildExpectedPath(op, ctx);
129
+
130
+ if (plan.isDelete) {
131
+ lines.push(" $client = $this->createMockClient([['status' => 204]]);");
132
+ lines.push(` $client->${accessor}()->${method}(${buildTestArgs(op, ctx, { hidden })});`);
133
+ // Request assertions
134
+ lines.push(' $request = $this->getLastRequest();');
135
+ lines.push(" $this->assertSame('DELETE', $request->getMethod());");
136
+ lines.push(` $this->assertStringEndsWith('${expectedPath}', $request->getUri()->getPath());`);
137
+ // Body assertions for DELETE-with-body
138
+ if (plan.hasBody && op.requestBody?.kind === 'model') {
139
+ emitBodyAssertions(lines, op, ctx, hidden);
140
+ }
141
+ } else if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
142
+ const fixtureName = `list_${resolveFixtureModelName(op.pagination.itemType.name, ctx)}`;
143
+ // Pass all params (including optional enums) to verify serialization
144
+ lines.push(` $fixture = $this->loadFixture('${fixtureName}');`);
145
+ lines.push(" $client = $this->createMockClient([['status' => 200, 'body' => $fixture]]);");
146
+ lines.push(
147
+ ` $result = $client->${accessor}()->${method}(${buildTestArgs(op, ctx, { includeOptional: true, hidden })});`,
148
+ );
149
+ lines.push(` $this->assertInstanceOf(\\${ns}\\PaginatedResponse::class, $result);`);
150
+ // Request assertions
151
+ lines.push(' $request = $this->getLastRequest();');
152
+ lines.push(` $this->assertSame('${op.httpMethod.toUpperCase()}', $request->getMethod());`);
153
+ lines.push(` $this->assertStringEndsWith('${expectedPath}', $request->getUri()->getPath());`);
154
+ // Query string serialization assertions
155
+ emitQueryAssertions(lines, op, ctx, hidden);
156
+ } else if (isRedirectEndpoint(op, resolvedOp)) {
157
+ // Redirect endpoint: URL is built locally, no HTTP request made.
158
+ // Pass all params (including optional) to verify they appear in the URL.
159
+ lines.push(' $client = $this->createMockClient([]);');
160
+ lines.push(
161
+ ` $result = $client->${accessor}()->${method}(${buildTestArgs(op, ctx, { includeOptional: true, hidden })});`,
162
+ );
163
+ lines.push(' $this->assertIsString($result);');
164
+ lines.push(` $this->assertStringContainsString('${expectedPath}', $result);`);
165
+ // Query param assertions for the generated URL
166
+ emitRedirectQueryAssertions(lines, op, ctx, hidden, resolvedOp);
167
+ } else if (plan.responseModelName) {
168
+ const modelName = className(plan.responseModelName);
169
+ const fixtureName = `${snakeName(plan.responseModelName)}`;
170
+ lines.push(` $fixture = $this->loadFixture('${fixtureName}');`);
171
+ if (op.response.kind === 'array') {
172
+ lines.push(" $client = $this->createMockClient([['status' => 200, 'body' => [$fixture]]]);");
173
+ } else {
174
+ lines.push(" $client = $this->createMockClient([['status' => 200, 'body' => $fixture]]);");
175
+ }
176
+ lines.push(` $result = $client->${accessor}()->${method}(${buildTestArgs(op, ctx, { hidden })});`);
177
+ if (op.response.kind === 'array') {
178
+ lines.push(' $this->assertIsArray($result);');
179
+ lines.push(` $this->assertInstanceOf(\\${ns}\\Resource\\${modelName}::class, $result[0]);`);
180
+ emitFieldHydrationAssertions(lines, plan.responseModelName, '$result[0]', '$fixture', ctx);
181
+ // Round-trip: fromArray -> toArray must not throw
182
+ lines.push(' $this->assertIsArray($result[0]->toArray());');
183
+ } else {
184
+ lines.push(` $this->assertInstanceOf(\\${ns}\\Resource\\${modelName}::class, $result);`);
185
+ emitFieldHydrationAssertions(lines, plan.responseModelName, '$result', '$fixture', ctx);
186
+ // Round-trip: fromArray -> toArray must not throw
187
+ lines.push(' $this->assertIsArray($result->toArray());');
188
+ }
189
+ // Request assertions
190
+ lines.push(' $request = $this->getLastRequest();');
191
+ lines.push(` $this->assertSame('${op.httpMethod.toUpperCase()}', $request->getMethod());`);
192
+ lines.push(` $this->assertStringEndsWith('${expectedPath}', $request->getUri()->getPath());`);
193
+ // Body assertions for POST/PUT/PATCH
194
+ if (plan.hasBody && ['post', 'put', 'patch'].includes(op.httpMethod.toLowerCase())) {
195
+ emitBodyAssertions(lines, op, ctx, hidden);
196
+ }
197
+ } else {
198
+ lines.push(" $client = $this->createMockClient([['status' => 200, 'body' => []]]);");
199
+ lines.push(` $client->${accessor}()->${method}(${buildTestArgs(op, ctx, { hidden })});`);
200
+ // Request assertions
201
+ lines.push(' $request = $this->getLastRequest();');
202
+ lines.push(` $this->assertSame('${op.httpMethod.toUpperCase()}', $request->getMethod());`);
203
+ lines.push(` $this->assertStringEndsWith('${expectedPath}', $request->getUri()->getPath());`);
204
+ }
205
+
206
+ lines.push(' }');
207
+ }
208
+
209
+ // Generate tests for wrapper methods (union split operations)
210
+ for (const resolved of ctx.resolvedOperations ?? []) {
211
+ if (resolved.mountOn !== target) continue;
212
+ for (const wrapper of resolved.wrappers ?? []) {
213
+ const method = toCamelCase(wrapper.name);
214
+ const testName = `test${method.charAt(0).toUpperCase()}${method.slice(1)}`;
215
+
216
+ if (emitted.has(testName)) continue;
217
+ emitted.add(testName);
218
+
219
+ const op = resolved.operation;
220
+ const responseModel = op.response.kind === 'model' ? op.response.name : null;
221
+
222
+ lines.push('');
223
+ lines.push(` public function ${testName}(): void`);
224
+ lines.push(' {');
225
+
226
+ // Build required args for wrapper methods
227
+ const wrapperArgs = buildWrapperTestArgs(wrapper, ctx);
228
+
229
+ if (responseModel) {
230
+ const modelName = className(responseModel);
231
+ const fixtureName = `${snakeName(responseModel)}`;
232
+ lines.push(` $fixture = $this->loadFixture('${fixtureName}');`);
233
+ lines.push(" $client = $this->createMockClient([['status' => 200, 'body' => $fixture]]);");
234
+ lines.push(` $result = $client->${accessor}()->${method}(${wrapperArgs});`);
235
+ lines.push(` $this->assertInstanceOf(\\${ns}\\Resource\\${modelName}::class, $result);`);
236
+ } else {
237
+ lines.push(" $client = $this->createMockClient([['status' => 200, 'body' => []]]);");
238
+ lines.push(` $client->${accessor}()->${method}(${wrapperArgs});`);
239
+ lines.push(' $this->assertTrue(true);');
240
+ }
241
+
242
+ lines.push(' }');
243
+ }
244
+ }
245
+
246
+ // Pagination boundary test: verify iteration works when before/after cursors are null
247
+ const firstPaginatedOp = ops.find(({ op }) => {
248
+ const p = planOperation(op);
249
+ return p.isPaginated && op.pagination?.itemType.kind === 'model';
250
+ });
251
+ if (firstPaginatedOp) {
252
+ const testName = 'testPaginationBoundary';
253
+ if (!emitted.has(testName)) {
254
+ emitted.add(testName);
255
+ const op = firstPaginatedOp.op;
256
+ const paginatedHidden = buildHiddenParams(firstPaginatedOp.resolvedOp);
257
+ const itemType = op.pagination!.itemType as { name: string };
258
+ const fixtureName = `list_${resolveFixtureModelName(itemType.name, ctx)}`;
259
+ const method = resolveMethodName(op, firstPaginatedOp.service, ctx);
260
+
261
+ lines.push('');
262
+ lines.push(` public function ${testName}(): void`);
263
+ lines.push(' {');
264
+ lines.push(` $fixture = $this->loadFixture('${fixtureName}');`);
265
+ lines.push(' // Ensure cursors are null (first/last page boundary)');
266
+ lines.push(" $fixture['list_metadata']['before'] = null;");
267
+ lines.push(" $fixture['list_metadata']['after'] = null;");
268
+ lines.push(" $client = $this->createMockClient([['status' => 200, 'body' => $fixture]]);");
269
+ lines.push(
270
+ ` $result = $client->${accessor}()->${method}(${buildTestArgs(op, ctx, { hidden: paginatedHidden })});`,
271
+ );
272
+ lines.push(` $this->assertInstanceOf(\\${ns}\\PaginatedResponse::class, $result);`);
273
+ lines.push(' // Verify cursors are null on boundary page');
274
+ lines.push(" $this->assertNull($result->listMetadata['before']);");
275
+ lines.push(" $this->assertNull($result->listMetadata['after']);");
276
+ lines.push(' // Iterating should not throw on null cursors');
277
+ lines.push(' foreach ($result as $item) {');
278
+ lines.push(' $this->assertNotNull($item);');
279
+ lines.push(' break;');
280
+ lines.push(' }');
281
+ lines.push(' }');
282
+ }
283
+ }
284
+
285
+ lines.push('}');
286
+ return lines.join('\n');
287
+ }
288
+
289
+ function generateClientTest(ctx: EmitterContext): string {
290
+ const ns = ctx.namespacePascal;
291
+ const lines: string[] = [];
292
+
293
+ // No <?php here — the file header from fileHeader() provides it
294
+ lines.push('namespace Tests;');
295
+ lines.push('');
296
+ lines.push('use PHPUnit\\Framework\\TestCase;');
297
+ lines.push(`use ${ns}\\${ns};`);
298
+ lines.push('');
299
+ lines.push('class ClientTest extends TestCase');
300
+ lines.push('{');
301
+ lines.push(' public function testConstructor(): void');
302
+ lines.push(' {');
303
+ lines.push(` $client = new ${ns}(apiKey: 'test-key');`);
304
+ lines.push(' $this->assertNotNull($client);');
305
+ lines.push(' }');
306
+ lines.push('}');
307
+
308
+ return lines.join('\n');
309
+ }
310
+
311
+ function buildTestArgs(
312
+ op: Operation,
313
+ ctx: EmitterContext,
314
+ opts?: { includeOptional?: boolean; hidden?: Set<string> },
315
+ ): string {
316
+ const includeOptional = opts?.includeOptional ?? false;
317
+ const hidden = opts?.hidden ?? new Set<string>();
318
+ const args: string[] = [];
319
+ const usedNames = new Set<string>();
320
+
321
+ // Path params (use enum values for enum-typed path params)
322
+ for (const p of op.pathParams) {
323
+ if (p.type.kind === 'enum' || p.type.kind === 'model') {
324
+ args.push(generateTestValue(p.type, ctx));
325
+ } else {
326
+ args.push(`'test_${p.name}'`);
327
+ }
328
+ usedNames.add(toCamelCase(p.name));
329
+ }
330
+
331
+ // Required body fields
332
+ if (op.requestBody?.kind === 'model') {
333
+ const bodyModel = ctx.spec.models.find((m) => m.name === (op.requestBody as { name: string }).name);
334
+ if (bodyModel) {
335
+ const pathParamNames = new Set(op.pathParams.map((p) => toCamelCase(p.name)));
336
+ for (const f of bodyModel.fields) {
337
+ if (hidden.has(f.name)) continue;
338
+ if (!f.required && !includeOptional) continue;
339
+ let phpName = toCamelCase(f.name);
340
+ if (pathParamNames.has(phpName)) {
341
+ phpName = `body${phpName.charAt(0).toUpperCase()}${phpName.slice(1)}`;
342
+ }
343
+ if (usedNames.has(phpName)) continue;
344
+ usedNames.add(phpName);
345
+ args.push(`${phpName}: ${generateTestValue(f.type, ctx)}`);
346
+ }
347
+ }
348
+ }
349
+
350
+ // Query params
351
+ for (const q of op.queryParams) {
352
+ if (hidden.has(q.name)) continue;
353
+ if (!q.required && !includeOptional) continue;
354
+ const phpName = toCamelCase(q.name);
355
+ if (usedNames.has(phpName)) continue;
356
+ usedNames.add(phpName);
357
+ args.push(`${phpName}: ${generateTestValue(q.type, ctx)}`);
358
+ }
359
+
360
+ return args.join(', ');
361
+ }
362
+
363
+ function generateTestValue(ref: { kind: string; type?: string; name?: string }, ctx?: EmitterContext): string {
364
+ switch (ref.kind) {
365
+ case 'primitive':
366
+ switch (ref.type) {
367
+ case 'string':
368
+ return "'test_value'";
369
+ case 'integer':
370
+ return '1';
371
+ case 'number':
372
+ return '1.0';
373
+ case 'boolean':
374
+ return 'true';
375
+ default:
376
+ return "'test_value'";
377
+ }
378
+ case 'enum': {
379
+ // Use the first enum value so PHP type-checking passes
380
+ if (ctx && ref.name) {
381
+ const e = ctx.spec.enums.find((en) => en.name === ref.name);
382
+ if (e && e.values.length > 0) {
383
+ const enumClass = enumClassName(ref.name);
384
+ // Must match the case-name logic in enums.ts
385
+ const caseName = toPascalCase(String(e.values[0].name).toLowerCase());
386
+ return `\\WorkOS\\Resource\\${enumClass}::${caseName}`;
387
+ }
388
+ }
389
+ return "'test_value'";
390
+ }
391
+ case 'array':
392
+ return '[]';
393
+ case 'map':
394
+ return '[]';
395
+ case 'nullable':
396
+ return generateTestValue(
397
+ (ref as unknown as { inner: { kind: string; type?: string; name?: string } }).inner,
398
+ ctx,
399
+ );
400
+ case 'model': {
401
+ if (ref.name) {
402
+ const modelClass = className(ref.name);
403
+ const fixtureName = snakeName(ref.name);
404
+ return `\\WorkOS\\Resource\\${modelClass}::fromArray($this->loadFixture('${fixtureName}'))`;
405
+ }
406
+ return '[]';
407
+ }
408
+ default:
409
+ return "'test_value'";
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Build test arguments for wrapper method calls, providing values for required exposed params.
415
+ */
416
+ function buildWrapperTestArgs(wrapper: import('@workos/oagen').ResolvedWrapper, ctx: EmitterContext): string {
417
+ const params = resolveWrapperParams(wrapper, ctx);
418
+ const args: string[] = [];
419
+ for (const { paramName, field, isOptional } of params) {
420
+ if (isOptional) continue;
421
+ const phpName = toCamelCase(paramName);
422
+ const value = field ? generateTestValue(field.type, ctx) : "'test_value'";
423
+ args.push(`${phpName}: ${value}`);
424
+ }
425
+ return args.join(', ');
426
+ }
427
+
428
+ /**
429
+ * Resolve the fixture model name, unwrapping list wrapper models to match
430
+ * the fixture generator's naming (which unwraps before naming).
431
+ */
432
+ function resolveFixtureModelName(modelName: string, ctx: EmitterContext): string {
433
+ const model = ctx.spec.models.find((m: Model) => m.name === modelName);
434
+ if (model && isListWrapperModel(model)) {
435
+ const dataField = model.fields.find((f) => f.name === 'data');
436
+ if (dataField?.type.kind === 'array' && dataField.type.items.kind === 'model') {
437
+ return snakeName(dataField.type.items.name);
438
+ }
439
+ }
440
+ return snakeName(modelName);
441
+ }
442
+
443
+ /**
444
+ * Build the expected URL path for an operation, substituting test values for path params.
445
+ */
446
+ function buildExpectedPath(op: Operation, ctx: EmitterContext): string {
447
+ let path = op.path.replace(/^\//, '');
448
+ for (const p of op.pathParams) {
449
+ if (p.type.kind === 'enum' && (p.type as { name: string }).name) {
450
+ // Use the actual first enum backing value for the path
451
+ const e = ctx.spec.enums.find((en) => en.name === (p.type as { name: string }).name);
452
+ const firstValue = e?.values[0]?.value;
453
+ path = path.replace(`{${p.name}}`, firstValue != null ? String(firstValue) : `test_${p.name}`);
454
+ } else {
455
+ path = path.replace(`{${p.name}}`, `test_${p.name}`);
456
+ }
457
+ }
458
+ return path;
459
+ }
460
+
461
+ /**
462
+ * Emit field hydration assertions: verify that deserialized model fields
463
+ * match the fixture data. Checks up to 2 primitive string fields (id + one more).
464
+ */
465
+ function emitFieldHydrationAssertions(
466
+ lines: string[],
467
+ modelName: string,
468
+ resultVar: string,
469
+ fixtureVar: string,
470
+ ctx: EmitterContext,
471
+ ): void {
472
+ const model = ctx.spec.models.find((m) => m.name === modelName);
473
+ if (!model) return;
474
+
475
+ // Pick required primitive string fields for assertion (id first, then others)
476
+ const candidates = model.fields.filter(
477
+ (f) => f.required && f.type.kind === 'primitive' && f.type.type === 'string' && !f.type.format,
478
+ );
479
+ const idField = candidates.find((f) => f.name === 'id');
480
+ const others = candidates.filter((f) => f.name !== 'id');
481
+ const assertFields = [idField, others[0]].filter(Boolean);
482
+
483
+ for (const f of assertFields) {
484
+ if (!f) continue;
485
+ const phpProp = toCamelCase(f.name);
486
+ lines.push(` $this->assertSame(${fixtureVar}['${f.name}'], ${resultVar}->${phpProp});`);
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Emit query string assertions for list operations.
492
+ * Asserts that all query params (including optional enums) are serialized correctly.
493
+ */
494
+ function emitQueryAssertions(lines: string[], op: Operation, ctx: EmitterContext, hidden?: Set<string>): void {
495
+ if (op.queryParams.length === 0) return;
496
+ lines.push(' parse_str($request->getUri()->getQuery(), $query);');
497
+ for (const q of op.queryParams) {
498
+ if (hidden?.has(q.name)) continue;
499
+ const innerType =
500
+ q.type.kind === 'nullable' ? (q.type as { inner: { kind: string; type?: string; name?: string } }).inner : q.type;
501
+ if (innerType.kind === 'enum' && innerType.name) {
502
+ // Assert enum is serialized as its backing value, not the enum instance
503
+ const e = ctx.spec.enums.find((en) => en.name === innerType.name);
504
+ if (e && e.values.length > 0) {
505
+ lines.push(` $this->assertSame('${e.values[0].value}', $query['${q.name}']);`);
506
+ }
507
+ } else if (innerType.kind === 'primitive') {
508
+ switch (innerType.type) {
509
+ case 'string':
510
+ lines.push(` $this->assertSame('test_value', $query['${q.name}']);`);
511
+ break;
512
+ case 'integer':
513
+ case 'number':
514
+ lines.push(` $this->assertArrayHasKey('${q.name}', $query);`);
515
+ break;
516
+ case 'boolean':
517
+ lines.push(` $this->assertArrayHasKey('${q.name}', $query);`);
518
+ break;
519
+ }
520
+ }
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Emit query param assertions for redirect endpoint URLs.
526
+ * Parses the query string from the built URL and asserts visible params,
527
+ * hidden defaults (e.g., response_type), and inferred client fields (e.g., client_id).
528
+ */
529
+ function emitRedirectQueryAssertions(
530
+ lines: string[],
531
+ op: Operation,
532
+ ctx: EmitterContext,
533
+ hidden: Set<string>,
534
+ resolvedOp?: ResolvedOperation,
535
+ ): void {
536
+ const hasVisibleQueryParams = op.queryParams.some((q) => !hidden.has(q.name));
537
+ const defaults = getOpDefaults(resolvedOp);
538
+ const inferred = getOpInferFromClient(resolvedOp);
539
+ if (!hasVisibleQueryParams && Object.keys(defaults).length === 0 && inferred.length === 0) return;
540
+
541
+ lines.push(" parse_str(parse_url($result, PHP_URL_QUERY) ?? '', $query);");
542
+
543
+ // Assert visible query params (same logic as emitQueryAssertions but reading from $query parsed from URL)
544
+ for (const q of op.queryParams) {
545
+ if (hidden.has(q.name)) continue;
546
+ const innerType =
547
+ q.type.kind === 'nullable' ? (q.type as { inner: { kind: string; type?: string; name?: string } }).inner : q.type;
548
+ if (innerType.kind === 'enum' && innerType.name) {
549
+ const e = ctx.spec.enums.find((en) => en.name === innerType.name);
550
+ if (e && e.values.length > 0) {
551
+ lines.push(` $this->assertSame('${e.values[0].value}', $query['${q.name}']);`);
552
+ }
553
+ } else if (innerType.kind === 'primitive') {
554
+ switch (innerType.type) {
555
+ case 'string':
556
+ lines.push(` $this->assertSame('test_value', $query['${q.name}']);`);
557
+ break;
558
+ case 'integer':
559
+ case 'number':
560
+ case 'boolean':
561
+ lines.push(` $this->assertArrayHasKey('${q.name}', $query);`);
562
+ break;
563
+ }
564
+ }
565
+ }
566
+
567
+ // Assert hidden defaults (e.g., response_type => 'code')
568
+ for (const [key, value] of Object.entries(defaults)) {
569
+ lines.push(` $this->assertSame('${value}', $query['${key}']);`);
570
+ }
571
+
572
+ // Assert inferred client fields are present (e.g., client_id)
573
+ for (const key of inferred) {
574
+ lines.push(` $this->assertArrayHasKey('${key}', $query);`);
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Emit body field assertions for POST/PUT/PATCH operations.
580
+ * Only asserts primitive required fields (strings, numbers, booleans).
581
+ */
582
+ function emitBodyAssertions(lines: string[], op: Operation, ctx: EmitterContext, hidden?: Set<string>): void {
583
+ if (op.requestBody?.kind !== 'model') return;
584
+ const bodyModel = ctx.spec.models.find((m) => m.name === (op.requestBody as { name: string }).name);
585
+ if (!bodyModel) return;
586
+ // Skip fields that collide with path param names (they get deduped in the resource)
587
+ const pathParamNames = new Set(op.pathParams.map((p) => p.name));
588
+ const primitiveRequired = bodyModel.fields.filter(
589
+ (f) =>
590
+ f.required &&
591
+ (f.type.kind === 'primitive' || f.type.kind === 'literal') &&
592
+ !pathParamNames.has(f.name) &&
593
+ !hidden?.has(f.name),
594
+ );
595
+ if (primitiveRequired.length === 0) return;
596
+
597
+ lines.push(' $body = json_decode((string) $request->getBody(), true);');
598
+ for (const f of primitiveRequired) {
599
+ if (f.type.kind === 'primitive' && f.type.type === 'string') {
600
+ lines.push(` $this->assertSame('test_value', $body['${f.name}']);`);
601
+ } else if (f.type.kind === 'primitive' && f.type.type === 'integer') {
602
+ lines.push(` $this->assertSame(1, $body['${f.name}']);`);
603
+ } else if (f.type.kind === 'primitive' && f.type.type === 'boolean') {
604
+ lines.push(` $this->assertTrue($body['${f.name}']);`);
605
+ } else {
606
+ lines.push(` $this->assertArrayHasKey('${f.name}', $body);`);
607
+ }
608
+ }
609
+ }