@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.
- package/.husky/pre-commit +1 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +129 -0
- package/dist/index.d.mts +13 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +14549 -3385
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/dotnet.md +336 -0
- package/docs/sdk-architecture/go.md +338 -0
- package/docs/sdk-architecture/php.md +315 -0
- package/docs/sdk-architecture/python.md +511 -0
- package/oagen.config.ts +328 -2
- package/package.json +9 -5
- package/scripts/generate-php.js +13 -0
- package/scripts/git-push-with-published-oagen.sh +21 -0
- package/smoke/sdk-dotnet.ts +45 -12
- package/smoke/sdk-go.ts +116 -42
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- package/src/dotnet/client.ts +89 -0
- package/src/dotnet/enums.ts +323 -0
- package/src/dotnet/fixtures.ts +236 -0
- package/src/dotnet/index.ts +246 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +344 -0
- package/src/dotnet/naming.ts +330 -0
- package/src/dotnet/resources.ts +622 -0
- package/src/dotnet/tests.ts +693 -0
- package/src/dotnet/type-map.ts +201 -0
- package/src/dotnet/wrappers.ts +186 -0
- package/src/go/client.ts +141 -0
- package/src/go/enums.ts +196 -0
- package/src/go/fixtures.ts +212 -0
- package/src/go/index.ts +84 -0
- package/src/go/manifest.ts +36 -0
- package/src/go/models.ts +254 -0
- package/src/go/naming.ts +179 -0
- package/src/go/resources.ts +827 -0
- package/src/go/tests.ts +751 -0
- package/src/go/type-map.ts +82 -0
- package/src/go/wrappers.ts +261 -0
- package/src/index.ts +4 -0
- package/src/kotlin/client.ts +53 -0
- package/src/kotlin/enums.ts +162 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +395 -0
- package/src/kotlin/naming.ts +223 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +667 -0
- package/src/kotlin/tests.ts +1019 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +128 -115
- package/src/node/enums.ts +9 -0
- package/src/node/errors.ts +37 -232
- package/src/node/field-plan.ts +726 -0
- package/src/node/fixtures.ts +9 -1
- package/src/node/index.ts +3 -9
- package/src/node/models.ts +178 -21
- package/src/node/naming.ts +49 -111
- package/src/node/resources.ts +527 -397
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +69 -19
- package/src/node/type-map.ts +4 -2
- package/src/node/utils.ts +13 -71
- package/src/node/wrappers.ts +151 -0
- package/src/php/client.ts +179 -0
- package/src/php/enums.ts +67 -0
- package/src/php/errors.ts +9 -0
- package/src/php/fixtures.ts +181 -0
- package/src/php/index.ts +96 -0
- package/src/php/manifest.ts +36 -0
- package/src/php/models.ts +310 -0
- package/src/php/naming.ts +279 -0
- package/src/php/resources.ts +636 -0
- package/src/php/tests.ts +609 -0
- package/src/php/type-map.ts +90 -0
- package/src/php/utils.ts +18 -0
- package/src/php/wrappers.ts +152 -0
- package/src/python/client.ts +345 -0
- package/src/python/enums.ts +313 -0
- package/src/python/fixtures.ts +196 -0
- package/src/python/index.ts +95 -0
- package/src/python/manifest.ts +38 -0
- package/src/python/models.ts +688 -0
- package/src/python/naming.ts +189 -0
- package/src/python/resources.ts +1322 -0
- package/src/python/tests.ts +1335 -0
- package/src/python/type-map.ts +93 -0
- package/src/python/wrappers.ts +191 -0
- package/src/shared/model-utils.ts +472 -0
- package/src/shared/naming-utils.ts +154 -0
- package/src/shared/non-spec-services.ts +54 -0
- package/src/shared/resolved-ops.ts +109 -0
- package/src/shared/wrapper-utils.ts +70 -0
- package/test/dotnet/client.test.ts +121 -0
- package/test/dotnet/enums.test.ts +193 -0
- package/test/dotnet/errors.test.ts +9 -0
- package/test/dotnet/manifest.test.ts +82 -0
- package/test/dotnet/models.test.ts +260 -0
- package/test/dotnet/resources.test.ts +255 -0
- package/test/dotnet/tests.test.ts +202 -0
- package/test/go/client.test.ts +92 -0
- package/test/go/enums.test.ts +132 -0
- package/test/go/errors.test.ts +9 -0
- package/test/go/models.test.ts +265 -0
- package/test/go/resources.test.ts +408 -0
- package/test/go/tests.test.ts +143 -0
- package/test/kotlin/models.test.ts +135 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +92 -12
- package/test/node/enums.test.ts +2 -0
- package/test/node/errors.test.ts +2 -41
- package/test/node/models.test.ts +2 -0
- package/test/node/naming.test.ts +23 -0
- package/test/node/resources.test.ts +315 -84
- package/test/node/serializers.test.ts +3 -1
- package/test/node/type-map.test.ts +11 -0
- package/test/php/client.test.ts +95 -0
- package/test/php/enums.test.ts +173 -0
- package/test/php/errors.test.ts +9 -0
- package/test/php/models.test.ts +497 -0
- package/test/php/resources.test.ts +682 -0
- package/test/php/tests.test.ts +185 -0
- package/test/python/client.test.ts +200 -0
- package/test/python/enums.test.ts +228 -0
- package/test/python/errors.test.ts +16 -0
- package/test/python/manifest.test.ts +74 -0
- package/test/python/models.test.ts +716 -0
- package/test/python/resources.test.ts +617 -0
- package/test/python/tests.test.ts +202 -0
- package/src/node/common.ts +0 -273
- package/src/node/config.ts +0 -71
- package/src/node/serializers.ts +0 -746
package/src/php/tests.ts
ADDED
|
@@ -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
|
+
}
|