@workos/oagen-emitters 0.0.1 → 0.2.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/.github/workflows/release-please.yml +9 -1
- package/.husky/commit-msg +0 -0
- package/.husky/pre-commit +1 -0
- package/.husky/pre-push +1 -0
- package/.prettierignore +1 -0
- package/.release-please-manifest.json +3 -0
- package/.vscode/settings.json +3 -0
- package/CHANGELOG.md +54 -0
- package/README.md +2 -2
- package/dist/index.d.mts +7 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3522 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +14 -18
- package/release-please-config.json +11 -0
- package/src/node/client.ts +437 -204
- package/src/node/common.ts +74 -4
- package/src/node/config.ts +1 -0
- package/src/node/enums.ts +50 -6
- package/src/node/errors.ts +78 -3
- package/src/node/fixtures.ts +84 -15
- package/src/node/index.ts +2 -2
- package/src/node/manifest.ts +4 -2
- package/src/node/models.ts +195 -79
- package/src/node/naming.ts +16 -1
- package/src/node/resources.ts +721 -106
- package/src/node/serializers.ts +510 -52
- package/src/node/tests.ts +621 -105
- package/src/node/type-map.ts +89 -11
- package/src/node/utils.ts +377 -114
- package/test/node/client.test.ts +979 -15
- package/test/node/enums.test.ts +0 -1
- package/test/node/errors.test.ts +4 -21
- package/test/node/models.test.ts +409 -2
- package/test/node/naming.test.ts +0 -3
- package/test/node/resources.test.ts +964 -7
- package/test/node/serializers.test.ts +212 -3
- package/tsconfig.json +2 -3
- package/{tsup.config.ts → tsdown.config.ts} +1 -1
- package/dist/index.d.ts +0 -5
- package/dist/index.js +0 -2158
package/src/node/tests.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ApiSpec, Service, Operation, Model, TypeRef, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
2
|
import { planOperation, toCamelCase } from '@workos/oagen';
|
|
3
|
+
import { unwrapListModel, ID_PREFIXES } from './fixtures.js';
|
|
3
4
|
import {
|
|
4
5
|
fieldName,
|
|
5
6
|
wireFieldName,
|
|
@@ -7,9 +8,19 @@ import {
|
|
|
7
8
|
serviceDirName,
|
|
8
9
|
servicePropertyName,
|
|
9
10
|
resolveMethodName,
|
|
10
|
-
|
|
11
|
+
resolveInterfaceName,
|
|
11
12
|
} from './naming.js';
|
|
12
13
|
import { generateFixtures } from './fixtures.js';
|
|
14
|
+
import { resolveResourceClassName } from './resources.js';
|
|
15
|
+
import {
|
|
16
|
+
assignModelsToServices,
|
|
17
|
+
createServiceDirResolver,
|
|
18
|
+
isServiceCoveredByExisting,
|
|
19
|
+
uncoveredOperations,
|
|
20
|
+
relativeImport,
|
|
21
|
+
isListMetadataModel,
|
|
22
|
+
isListWrapperModel,
|
|
23
|
+
} from './utils.js';
|
|
13
24
|
|
|
14
25
|
export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
15
26
|
const files: GeneratedFile[] = [];
|
|
@@ -23,9 +34,21 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
23
34
|
// Build model lookup for response field assertions
|
|
24
35
|
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
25
36
|
|
|
26
|
-
// Generate test files per service
|
|
37
|
+
// Generate test files per service — skip services whose endpoints are fully
|
|
38
|
+
// covered by existing hand-written service classes. For partially covered
|
|
39
|
+
// services, generate tests only for uncovered operations.
|
|
27
40
|
for (const service of spec.services) {
|
|
28
|
-
|
|
41
|
+
if (isServiceCoveredByExisting(service, ctx)) continue;
|
|
42
|
+
const ops = uncoveredOperations(service, ctx);
|
|
43
|
+
if (ops.length === 0) continue;
|
|
44
|
+
const testService = ops.length < service.operations.length ? { ...service, operations: ops } : service;
|
|
45
|
+
files.push(generateServiceTest(testService, spec, ctx, modelMap));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Generate serializer round-trip tests
|
|
49
|
+
const serializerTests = generateSerializerTests(spec, ctx);
|
|
50
|
+
for (const f of serializerTests) {
|
|
51
|
+
files.push(f);
|
|
29
52
|
}
|
|
30
53
|
|
|
31
54
|
return files;
|
|
@@ -37,40 +60,97 @@ function generateServiceTest(
|
|
|
37
60
|
ctx: EmitterContext,
|
|
38
61
|
modelMap: Map<string, Model>,
|
|
39
62
|
): GeneratedFile {
|
|
40
|
-
const resolvedName =
|
|
63
|
+
const resolvedName = resolveResourceClassName(service, ctx);
|
|
41
64
|
const serviceDir = serviceDirName(resolvedName);
|
|
42
65
|
const serviceClass = resolvedName;
|
|
43
66
|
const serviceProp = servicePropertyName(resolvedName);
|
|
44
67
|
const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
|
|
45
68
|
|
|
69
|
+
const plans = service.operations.map((op) => ({
|
|
70
|
+
op,
|
|
71
|
+
plan: planOperation(op),
|
|
72
|
+
method: resolveMethodName(op, service, ctx),
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
// Sort plans to match the existing file's method order (same as resources.ts).
|
|
76
|
+
if (ctx.overlayLookup?.methodByOperation) {
|
|
77
|
+
const methodOrder = new Map<string, number>();
|
|
78
|
+
let pos = 0;
|
|
79
|
+
for (const [, info] of ctx.overlayLookup.methodByOperation) {
|
|
80
|
+
if (!methodOrder.has(info.methodName)) {
|
|
81
|
+
methodOrder.set(info.methodName, pos++);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (methodOrder.size > 0) {
|
|
85
|
+
plans.sort((a, b) => {
|
|
86
|
+
const aPos = methodOrder.get(a.method) ?? Number.MAX_SAFE_INTEGER;
|
|
87
|
+
const bPos = methodOrder.get(b.method) ?? Number.MAX_SAFE_INTEGER;
|
|
88
|
+
return aPos - bPos;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Compute model-to-service mapping so fixture imports use the correct cross-service path.
|
|
94
|
+
// A test for service A may reference a response model owned by service B — the fixture
|
|
95
|
+
// lives in service B's fixtures directory, not service A's.
|
|
96
|
+
const { modelToService, resolveDir } = createServiceDirResolver(spec.models, spec.services, ctx);
|
|
97
|
+
|
|
46
98
|
const lines: string[] = [];
|
|
47
99
|
|
|
48
100
|
lines.push("import fetch from 'jest-fetch-mock';");
|
|
101
|
+
|
|
102
|
+
// Conditionally import test utilities based on what test types exist
|
|
103
|
+
const hasPaginated = plans.some((p) => p.plan.isPaginated);
|
|
104
|
+
const hasBody = plans.some((p) => p.plan.hasBody);
|
|
105
|
+
const testUtils = ['fetchOnce', 'fetchURL', 'fetchMethod'];
|
|
106
|
+
if (hasPaginated) testUtils.push('fetchSearchParams');
|
|
107
|
+
if (hasBody) testUtils.push('fetchBody');
|
|
108
|
+
// Import shared test helpers for error and pagination tests
|
|
109
|
+
if (hasPaginated) testUtils.push('testEmptyResults', 'testPaginationParams');
|
|
110
|
+
// Only import testUnauthorized when at least one operation has a response model or is paginated
|
|
111
|
+
const hasErrorTests = plans.some((p) => p.plan.responseModelName || p.plan.isPaginated);
|
|
112
|
+
if (hasErrorTests) testUtils.push('testUnauthorized');
|
|
49
113
|
lines.push('import {');
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
lines.push(' fetchBody,');
|
|
114
|
+
for (const util of testUtils) {
|
|
115
|
+
lines.push(` ${util},`);
|
|
116
|
+
}
|
|
54
117
|
lines.push("} from '../common/utils/test-utils';");
|
|
55
118
|
lines.push("import { WorkOS } from '../workos';");
|
|
56
119
|
lines.push('');
|
|
57
120
|
|
|
58
|
-
// Import fixtures
|
|
121
|
+
// Import fixtures — use correct cross-service paths when the response model
|
|
122
|
+
// is owned by a different service than the current test file.
|
|
59
123
|
const fixtureImports = new Set<string>();
|
|
60
|
-
for (const op of
|
|
61
|
-
const plan = planOperation(op);
|
|
124
|
+
for (const { op, plan } of plans) {
|
|
62
125
|
if (plan.isPaginated && op.pagination) {
|
|
63
|
-
|
|
126
|
+
let itemModelName = op.pagination.itemType.kind === 'model' ? op.pagination.itemType.name : null;
|
|
64
127
|
if (itemModelName) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
)
|
|
128
|
+
// Unwrap list wrapper models to match the fixture file naming in fixtures.ts
|
|
129
|
+
const rawModel = modelMap.get(itemModelName);
|
|
130
|
+
if (rawModel) {
|
|
131
|
+
const unwrapped = unwrapListModel(rawModel, modelMap);
|
|
132
|
+
if (unwrapped) {
|
|
133
|
+
itemModelName = unwrapped.name;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// List fixtures are always generated in the current service's directory
|
|
137
|
+
// (the service owning the list operation), not in the model's home service.
|
|
138
|
+
// Always use a local import path.
|
|
139
|
+
const fixturePath = `./fixtures/list-${fileName(itemModelName)}.fixture.json`;
|
|
140
|
+
fixtureImports.add(`import list${itemModelName}Fixture from '${fixturePath}';`);
|
|
68
141
|
}
|
|
69
142
|
} else if (plan.responseModelName) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
143
|
+
const respService = modelToService.get(plan.responseModelName);
|
|
144
|
+
const respDir = resolveDir(respService);
|
|
145
|
+
const fixturePath =
|
|
146
|
+
respDir === serviceDir
|
|
147
|
+
? `./fixtures/${fileName(plan.responseModelName)}.fixture.json`
|
|
148
|
+
: `../${respDir}/fixtures/${fileName(plan.responseModelName)}.fixture.json`;
|
|
149
|
+
fixtureImports.add(`import ${toCamelCase(plan.responseModelName)}Fixture from '${fixturePath}';`);
|
|
73
150
|
}
|
|
151
|
+
// NOTE: Request body fixtures are not imported for body tests because
|
|
152
|
+
// fixtures use wire format (snake_case) but methods expect domain types
|
|
153
|
+
// (camelCase). Body tests use `{} as any` instead.
|
|
74
154
|
}
|
|
75
155
|
for (const imp of fixtureImports) {
|
|
76
156
|
lines.push(imp);
|
|
@@ -79,31 +159,37 @@ function generateServiceTest(
|
|
|
79
159
|
lines.push('');
|
|
80
160
|
lines.push("const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU');");
|
|
81
161
|
lines.push('');
|
|
162
|
+
|
|
163
|
+
// Generate per-entity assertion helpers for models used in 2+ tests.
|
|
164
|
+
// This deduplicates the field assertion blocks that would otherwise be
|
|
165
|
+
// copy-pasted across list/find/create/update test cases.
|
|
166
|
+
const { lines: helperLines, helpers: entityHelperNames } = generateEntityHelpers(plans, modelMap, ctx);
|
|
167
|
+
for (const line of helperLines) {
|
|
168
|
+
lines.push(line);
|
|
169
|
+
}
|
|
170
|
+
|
|
82
171
|
lines.push(`describe('${serviceClass}', () => {`);
|
|
83
172
|
lines.push(' beforeEach(() => fetch.resetMocks());');
|
|
84
173
|
|
|
85
|
-
for (const op of
|
|
86
|
-
const plan = planOperation(op);
|
|
87
|
-
const method = resolveMethodName(op, service, ctx);
|
|
88
|
-
|
|
174
|
+
for (const { op, plan, method } of plans) {
|
|
89
175
|
lines.push('');
|
|
90
176
|
lines.push(` describe('${method}', () => {`);
|
|
91
177
|
|
|
92
178
|
if (plan.isPaginated) {
|
|
93
|
-
renderPaginatedTest(lines, op, plan, method, serviceProp, modelMap);
|
|
179
|
+
renderPaginatedTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames);
|
|
94
180
|
} else if (plan.isDelete) {
|
|
95
|
-
renderDeleteTest(lines, op, method, serviceProp);
|
|
181
|
+
renderDeleteTest(lines, op, plan, method, serviceProp, modelMap);
|
|
96
182
|
} else if (plan.hasBody && plan.responseModelName) {
|
|
97
|
-
renderBodyTest(lines, op, plan, method, serviceProp, modelMap);
|
|
183
|
+
renderBodyTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames);
|
|
98
184
|
} else if (plan.responseModelName) {
|
|
99
|
-
renderGetTest(lines, op, plan, method, serviceProp, modelMap);
|
|
185
|
+
renderGetTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames);
|
|
100
186
|
} else {
|
|
101
|
-
renderVoidTest(lines, op, method, serviceProp);
|
|
187
|
+
renderVoidTest(lines, op, plan, method, serviceProp, modelMap);
|
|
102
188
|
}
|
|
103
189
|
|
|
104
190
|
// Error case test for all non-void operations
|
|
105
191
|
if (plan.responseModelName || plan.isPaginated) {
|
|
106
|
-
renderErrorTest(lines, op, plan, method, serviceProp);
|
|
192
|
+
renderErrorTest(lines, op, plan, method, serviceProp, modelMap);
|
|
107
193
|
}
|
|
108
194
|
|
|
109
195
|
lines.push(' });');
|
|
@@ -111,7 +197,36 @@ function generateServiceTest(
|
|
|
111
197
|
|
|
112
198
|
lines.push('});');
|
|
113
199
|
|
|
114
|
-
return { path: testPath, content: lines.join('\n'), skipIfExists: true
|
|
200
|
+
return { path: testPath, content: lines.join('\n'), skipIfExists: true };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Compute the test value for a single path parameter.
|
|
204
|
+
* Uses distinct values per param name so multi-param paths don't all get 'test_id'.
|
|
205
|
+
*/
|
|
206
|
+
function pathParamTestValue(param: { type: TypeRef; name?: string } | undefined, paramName?: string): string {
|
|
207
|
+
if (param?.type.kind === 'enum' && param.type.values?.length) {
|
|
208
|
+
const first = param.type.values[0];
|
|
209
|
+
return typeof first === 'string' ? first : String(first);
|
|
210
|
+
}
|
|
211
|
+
// Use distinct values for different path params to detect ordering bugs
|
|
212
|
+
const name = paramName ?? (param as any)?.name;
|
|
213
|
+
if (name) return `test_${fieldName(name)}`;
|
|
214
|
+
return 'test_id';
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Build test arguments for all path params (handles multiple path params). */
|
|
218
|
+
function buildTestPathArgs(op: Operation): string {
|
|
219
|
+
// Detect path template variables (may be more than op.pathParams if spec is incomplete)
|
|
220
|
+
const templateVars = [...op.path.matchAll(/\{(\w+)\}/g)].map(([, name]) => fieldName(name));
|
|
221
|
+
const declaredNames = new Set(op.pathParams.map((p) => fieldName(p.name)));
|
|
222
|
+
const paramByName = new Map(op.pathParams.map((p) => [fieldName(p.name), p]));
|
|
223
|
+
// Merge declared + template vars, deduplicate, preserve order
|
|
224
|
+
const allVars: string[] = [];
|
|
225
|
+
for (const p of op.pathParams) allVars.push(fieldName(p.name));
|
|
226
|
+
for (const v of templateVars) {
|
|
227
|
+
if (!declaredNames.has(v)) allVars.push(v);
|
|
228
|
+
}
|
|
229
|
+
return allVars.map((varName) => `'${pathParamTestValue(paramByName.get(varName), varName)}'`).join(', ');
|
|
115
230
|
}
|
|
116
231
|
|
|
117
232
|
function renderPaginatedTest(
|
|
@@ -121,46 +236,94 @@ function renderPaginatedTest(
|
|
|
121
236
|
method: string,
|
|
122
237
|
serviceProp: string,
|
|
123
238
|
modelMap: Map<string, Model>,
|
|
239
|
+
ctx?: EmitterContext,
|
|
240
|
+
entityHelpers?: Set<string>,
|
|
124
241
|
): void {
|
|
125
|
-
|
|
242
|
+
let itemModelName = op.pagination?.itemType.kind === 'model' ? op.pagination.itemType.name : 'Item';
|
|
243
|
+
// Unwrap list wrapper models to match the fixture file naming in fixtures.ts
|
|
244
|
+
const rawModel = itemModelName !== 'Item' ? modelMap.get(itemModelName) : null;
|
|
245
|
+
if (rawModel) {
|
|
246
|
+
const unwrapped = unwrapListModel(rawModel, modelMap);
|
|
247
|
+
if (unwrapped) {
|
|
248
|
+
itemModelName = unwrapped.name;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const pathArgs = buildTestPathArgs(op);
|
|
126
252
|
|
|
127
253
|
lines.push(" it('returns paginated results', async () => {");
|
|
128
254
|
lines.push(` fetchOnce(list${itemModelName}Fixture);`);
|
|
129
255
|
lines.push('');
|
|
130
|
-
lines.push(` const { data, listMetadata } = await workos.${serviceProp}.${method}();`);
|
|
256
|
+
lines.push(` const { data, listMetadata } = await workos.${serviceProp}.${method}(${pathArgs});`);
|
|
131
257
|
lines.push('');
|
|
132
|
-
lines.push(
|
|
258
|
+
lines.push(" expect(fetchMethod()).toBe('GET');");
|
|
259
|
+
// Fix #12: Full URL path assertion instead of toContain()
|
|
260
|
+
const expectedPath = buildExpectedPath(op);
|
|
261
|
+
lines.push(` expect(new URL(String(fetchURL())).pathname).toBe('${expectedPath}');`);
|
|
133
262
|
lines.push(" expect(fetchSearchParams()).toHaveProperty('order');");
|
|
134
263
|
lines.push(' expect(Array.isArray(data)).toBe(true);');
|
|
135
264
|
lines.push(' expect(listMetadata).toBeDefined();');
|
|
136
265
|
|
|
137
|
-
// Assert on first item fields
|
|
138
|
-
const
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
266
|
+
// Assert on first item fields — use entity helper if available
|
|
267
|
+
const paginatedHelperName = ctx ? `expect${resolveInterfaceName(itemModelName, ctx)}` : null;
|
|
268
|
+
if (paginatedHelperName && entityHelpers?.has(paginatedHelperName)) {
|
|
269
|
+
lines.push(' expect(data.length).toBeGreaterThan(0);');
|
|
270
|
+
lines.push(` ${paginatedHelperName}(data[0]);`);
|
|
271
|
+
} else {
|
|
272
|
+
const itemModel = modelMap.get(itemModelName);
|
|
273
|
+
if (itemModel) {
|
|
274
|
+
const assertions = buildFieldAssertions(itemModel, 'data[0]', modelMap);
|
|
275
|
+
if (assertions.length > 0) {
|
|
276
|
+
lines.push(' expect(data.length).toBeGreaterThan(0);');
|
|
277
|
+
for (const assertion of assertions) {
|
|
278
|
+
lines.push(` ${assertion}`);
|
|
279
|
+
}
|
|
145
280
|
}
|
|
146
281
|
}
|
|
147
282
|
}
|
|
148
283
|
|
|
149
284
|
lines.push(' });');
|
|
285
|
+
|
|
286
|
+
// Edge case: handles empty results — use shared helper
|
|
287
|
+
lines.push('');
|
|
288
|
+
lines.push(` testEmptyResults(() => workos.${serviceProp}.${method}(${pathArgs}));`);
|
|
289
|
+
|
|
290
|
+
// Edge case: forwards pagination params — use shared helper
|
|
291
|
+
lines.push('');
|
|
292
|
+
lines.push(` testPaginationParams(`);
|
|
293
|
+
lines.push(` (opts) => workos.${serviceProp}.${method}(${pathArgs ? pathArgs + ', ' : ''}opts),`);
|
|
294
|
+
lines.push(` list${itemModelName}Fixture,`);
|
|
295
|
+
lines.push(' );');
|
|
150
296
|
}
|
|
151
297
|
|
|
152
|
-
function renderDeleteTest(
|
|
153
|
-
|
|
154
|
-
|
|
298
|
+
function renderDeleteTest(
|
|
299
|
+
lines: string[],
|
|
300
|
+
op: Operation,
|
|
301
|
+
plan: any,
|
|
302
|
+
method: string,
|
|
303
|
+
serviceProp: string,
|
|
304
|
+
modelMap: Map<string, Model>,
|
|
305
|
+
): void {
|
|
306
|
+
const pathArgs = buildTestPathArgs(op);
|
|
307
|
+
// Build realistic payload for body-bearing delete operations
|
|
308
|
+
const payload = plan.hasBody ? buildTestPayload(op, modelMap) : null;
|
|
309
|
+
const bodyArg = plan.hasBody ? (payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap)) : '';
|
|
310
|
+
const args = plan.hasBody ? (pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg) : pathArgs;
|
|
155
311
|
|
|
156
312
|
lines.push(" it('sends a DELETE request', async () => {");
|
|
157
313
|
lines.push(' fetchOnce({}, { status: 204 });');
|
|
158
314
|
lines.push('');
|
|
159
315
|
lines.push(` await workos.${serviceProp}.${method}(${args});`);
|
|
160
316
|
lines.push('');
|
|
161
|
-
lines.push(
|
|
162
|
-
|
|
163
|
-
|
|
317
|
+
lines.push(" expect(fetchMethod()).toBe('DELETE');");
|
|
318
|
+
// Fix #12: Full URL path assertion instead of toContain()
|
|
319
|
+
const expectedPathDel = buildExpectedPath(op);
|
|
320
|
+
lines.push(` expect(new URL(String(fetchURL())).pathname).toBe('${expectedPathDel}');`);
|
|
321
|
+
if (plan.hasBody) {
|
|
322
|
+
if (payload) {
|
|
323
|
+
lines.push(` expect(fetchBody()).toEqual(expect.objectContaining(${payload.snakeCaseObj}));`);
|
|
324
|
+
} else {
|
|
325
|
+
lines.push(' expect(fetchBody()).toBeDefined();');
|
|
326
|
+
}
|
|
164
327
|
}
|
|
165
328
|
lines.push(' });');
|
|
166
329
|
}
|
|
@@ -172,30 +335,53 @@ function renderBodyTest(
|
|
|
172
335
|
method: string,
|
|
173
336
|
serviceProp: string,
|
|
174
337
|
modelMap: Map<string, Model>,
|
|
338
|
+
ctx?: EmitterContext,
|
|
339
|
+
entityHelpers?: Set<string>,
|
|
175
340
|
): void {
|
|
176
341
|
const responseModelName = plan.responseModelName!;
|
|
177
342
|
const fixture = `${toCamelCase(responseModelName)}Fixture`;
|
|
178
|
-
const
|
|
179
|
-
|
|
343
|
+
const pathArgs = buildTestPathArgs(op);
|
|
344
|
+
|
|
345
|
+
// Build realistic payload from request body model fields
|
|
346
|
+
const payload = buildTestPayload(op, modelMap);
|
|
347
|
+
const payloadArg = payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap);
|
|
348
|
+
const allArgs = pathArgs ? `${pathArgs}, ${payloadArg}` : payloadArg;
|
|
180
349
|
|
|
181
350
|
lines.push(" it('sends the correct request and returns result', async () => {");
|
|
182
351
|
lines.push(` fetchOnce(${fixture});`);
|
|
183
352
|
lines.push('');
|
|
184
|
-
lines.push(` const result = await workos.${serviceProp}.${method}(${
|
|
353
|
+
lines.push(` const result = await workos.${serviceProp}.${method}(${allArgs});`);
|
|
185
354
|
lines.push('');
|
|
186
|
-
lines.push(` expect(
|
|
187
|
-
|
|
188
|
-
|
|
355
|
+
lines.push(` expect(fetchMethod()).toBe('${op.httpMethod.toUpperCase()}');`);
|
|
356
|
+
|
|
357
|
+
// Fix #12: Full URL path assertion instead of toContain()
|
|
358
|
+
const expectedPath = buildExpectedPath(op);
|
|
359
|
+
lines.push(` expect(new URL(String(fetchURL())).pathname).toBe('${expectedPath}');`);
|
|
360
|
+
|
|
361
|
+
// Fix #10: Assert serialized wire format of request body
|
|
362
|
+
if (payload) {
|
|
363
|
+
lines.push(` expect(fetchBody()).toEqual(expect.objectContaining(${payload.snakeCaseObj}));`);
|
|
364
|
+
} else {
|
|
365
|
+
lines.push(' expect(fetchBody()).toBeDefined();');
|
|
189
366
|
}
|
|
190
|
-
lines.push(' expect(fetchBody()).toBeDefined();');
|
|
191
|
-
lines.push(' expect(result).toBeDefined();');
|
|
192
367
|
|
|
193
|
-
//
|
|
194
|
-
const
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
368
|
+
// Use entity helper if available, otherwise inline assertions
|
|
369
|
+
const bodyHelperName = ctx ? `expect${resolveInterfaceName(responseModelName, ctx)}` : null;
|
|
370
|
+
if (bodyHelperName && entityHelpers?.has(bodyHelperName)) {
|
|
371
|
+
lines.push(` ${bodyHelperName}(result);`);
|
|
372
|
+
} else {
|
|
373
|
+
const responseModel = modelMap.get(responseModelName);
|
|
374
|
+
if (responseModel) {
|
|
375
|
+
const assertions = buildFieldAssertions(responseModel, 'result', modelMap);
|
|
376
|
+
if (assertions.length > 0) {
|
|
377
|
+
for (const assertion of assertions) {
|
|
378
|
+
lines.push(` ${assertion}`);
|
|
379
|
+
}
|
|
380
|
+
} else {
|
|
381
|
+
lines.push(' expect(result).toBeDefined();');
|
|
382
|
+
}
|
|
383
|
+
} else {
|
|
384
|
+
lines.push(' expect(result).toBeDefined();');
|
|
199
385
|
}
|
|
200
386
|
}
|
|
201
387
|
|
|
@@ -209,92 +395,233 @@ function renderGetTest(
|
|
|
209
395
|
method: string,
|
|
210
396
|
serviceProp: string,
|
|
211
397
|
modelMap: Map<string, Model>,
|
|
398
|
+
ctx?: EmitterContext,
|
|
399
|
+
entityHelpers?: Set<string>,
|
|
212
400
|
): void {
|
|
213
401
|
const responseModelName = plan.responseModelName!;
|
|
214
402
|
const fixture = `${toCamelCase(responseModelName)}Fixture`;
|
|
215
|
-
const
|
|
216
|
-
const args = hasPathParam ? "'test_id'" : '';
|
|
403
|
+
const pathArgs = buildTestPathArgs(op);
|
|
217
404
|
|
|
218
405
|
lines.push(" it('returns the expected result', async () => {");
|
|
219
406
|
lines.push(` fetchOnce(${fixture});`);
|
|
220
407
|
lines.push('');
|
|
221
|
-
lines.push(` const result = await workos.${serviceProp}.${method}(${
|
|
408
|
+
lines.push(` const result = await workos.${serviceProp}.${method}(${pathArgs});`);
|
|
222
409
|
lines.push('');
|
|
223
|
-
lines.push(` expect(
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
410
|
+
lines.push(` expect(fetchMethod()).toBe('${op.httpMethod.toUpperCase()}');`);
|
|
411
|
+
// Fix #12: Full URL path assertion instead of toContain()
|
|
412
|
+
const expectedPathGet = buildExpectedPath(op);
|
|
413
|
+
lines.push(` expect(new URL(String(fetchURL())).pathname).toBe('${expectedPathGet}');`);
|
|
414
|
+
|
|
415
|
+
// Use entity helper if available, otherwise inline assertions
|
|
416
|
+
const helperName = ctx ? `expect${resolveInterfaceName(responseModelName, ctx)}` : null;
|
|
417
|
+
if (helperName && entityHelpers?.has(helperName)) {
|
|
418
|
+
lines.push(` ${helperName}(result);`);
|
|
419
|
+
} else {
|
|
420
|
+
const responseModel = modelMap.get(responseModelName);
|
|
421
|
+
if (responseModel) {
|
|
422
|
+
const assertions = buildFieldAssertions(responseModel, 'result', modelMap);
|
|
423
|
+
if (assertions.length > 0) {
|
|
424
|
+
for (const assertion of assertions) {
|
|
425
|
+
lines.push(` ${assertion}`);
|
|
426
|
+
}
|
|
427
|
+
} else {
|
|
428
|
+
lines.push(' expect(result).toBeDefined();');
|
|
429
|
+
}
|
|
430
|
+
} else {
|
|
431
|
+
lines.push(' expect(result).toBeDefined();');
|
|
235
432
|
}
|
|
236
433
|
}
|
|
237
434
|
|
|
238
435
|
lines.push(' });');
|
|
239
436
|
}
|
|
240
437
|
|
|
241
|
-
function renderVoidTest(
|
|
242
|
-
|
|
243
|
-
|
|
438
|
+
function renderVoidTest(
|
|
439
|
+
lines: string[],
|
|
440
|
+
op: Operation,
|
|
441
|
+
plan: any,
|
|
442
|
+
method: string,
|
|
443
|
+
serviceProp: string,
|
|
444
|
+
modelMap: Map<string, Model>,
|
|
445
|
+
): void {
|
|
446
|
+
const pathArgs = buildTestPathArgs(op);
|
|
447
|
+
// Build realistic payload for body-bearing void operations
|
|
448
|
+
const payload = plan.hasBody ? buildTestPayload(op, modelMap) : null;
|
|
449
|
+
const bodyArg = plan.hasBody ? (payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap)) : '';
|
|
450
|
+
const args = plan.hasBody ? (pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg) : pathArgs;
|
|
244
451
|
|
|
245
452
|
lines.push(" it('sends the request', async () => {");
|
|
246
453
|
lines.push(' fetchOnce({});');
|
|
247
454
|
lines.push('');
|
|
248
455
|
lines.push(` await workos.${serviceProp}.${method}(${args});`);
|
|
249
456
|
lines.push('');
|
|
250
|
-
lines.push(` expect(
|
|
251
|
-
|
|
252
|
-
|
|
457
|
+
lines.push(` expect(fetchMethod()).toBe('${op.httpMethod.toUpperCase()}');`);
|
|
458
|
+
// Fix #12: Full URL path assertion instead of toContain()
|
|
459
|
+
const expectedPathVoid = buildExpectedPath(op);
|
|
460
|
+
lines.push(` expect(new URL(String(fetchURL())).pathname).toBe('${expectedPathVoid}');`);
|
|
461
|
+
if (plan.hasBody && payload) {
|
|
462
|
+
lines.push(` expect(fetchBody()).toEqual(expect.objectContaining(${payload.snakeCaseObj}));`);
|
|
253
463
|
}
|
|
254
464
|
lines.push(' });');
|
|
255
465
|
}
|
|
256
466
|
|
|
257
|
-
function renderErrorTest(
|
|
258
|
-
|
|
467
|
+
function renderErrorTest(
|
|
468
|
+
lines: string[],
|
|
469
|
+
op: Operation,
|
|
470
|
+
plan: any,
|
|
471
|
+
method: string,
|
|
472
|
+
serviceProp: string,
|
|
473
|
+
modelMap: Map<string, Model>,
|
|
474
|
+
): void {
|
|
475
|
+
const args = buildCallArgs(op, plan, modelMap);
|
|
476
|
+
|
|
477
|
+
lines.push('');
|
|
478
|
+
lines.push(` testUnauthorized(() => workos.${serviceProp}.${method}(${args}));`);
|
|
479
|
+
|
|
480
|
+
// Add error-status tests based on the operation's error responses
|
|
481
|
+
const errorStatuses = new Set(op.errors.map((e) => e.statusCode));
|
|
482
|
+
|
|
483
|
+
// 404 test for find/get methods
|
|
484
|
+
if (errorStatuses.has(404) && (method.startsWith('get') || method.startsWith('find'))) {
|
|
485
|
+
lines.push('');
|
|
486
|
+
lines.push(" it('throws NotFoundException on 404', async () => {");
|
|
487
|
+
lines.push(" fetchOnce('', { status: 404 });");
|
|
488
|
+
lines.push(` await expect(workos.${serviceProp}.${method}(${args})).rejects.toThrow();`);
|
|
489
|
+
lines.push(' });');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// 422 test for create/update methods
|
|
493
|
+
if (errorStatuses.has(422) && (method.startsWith('create') || method.startsWith('update'))) {
|
|
494
|
+
lines.push('');
|
|
495
|
+
lines.push(" it('throws UnprocessableEntityException on 422', async () => {");
|
|
496
|
+
lines.push(" fetchOnce('', { status: 422 });");
|
|
497
|
+
lines.push(` await expect(workos.${serviceProp}.${method}(${args})).rejects.toThrow();`);
|
|
498
|
+
lines.push(' });');
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Build the argument string for a method call in tests.
|
|
504
|
+
* Shared by renderErrorTest and other test renderers.
|
|
505
|
+
*/
|
|
506
|
+
function buildCallArgs(op: Operation, plan: any, modelMap: Map<string, Model>): string {
|
|
507
|
+
const pathArgs = buildTestPathArgs(op);
|
|
259
508
|
const isPaginated = plan.isPaginated;
|
|
260
509
|
const hasBody = plan.hasBody;
|
|
261
510
|
|
|
262
|
-
|
|
263
|
-
if (
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
511
|
+
if (isPaginated) return pathArgs || '';
|
|
512
|
+
if (hasBody) {
|
|
513
|
+
const fb = fallbackBodyArg(op, modelMap);
|
|
514
|
+
return pathArgs ? `${pathArgs}, ${fb}` : fb;
|
|
515
|
+
}
|
|
516
|
+
return pathArgs || '';
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Generate per-entity assertion helper functions for models used in 2+ tests.
|
|
521
|
+
* Returns lines like: function expectConnection(result: any) { expect(...) }
|
|
522
|
+
*/
|
|
523
|
+
/**
|
|
524
|
+
* Generate per-entity assertion helper functions for models used in 2+ tests.
|
|
525
|
+
* Returns { lines, helpers } where helpers is a Set of helper function names.
|
|
526
|
+
*/
|
|
527
|
+
function generateEntityHelpers(
|
|
528
|
+
plans: { op: Operation; plan: any; method: string }[],
|
|
529
|
+
modelMap: Map<string, Model>,
|
|
530
|
+
ctx: EmitterContext,
|
|
531
|
+
): { lines: string[]; helpers: Set<string> } {
|
|
532
|
+
// Count how many tests reference each response model
|
|
533
|
+
const modelUsage = new Map<string, number>();
|
|
534
|
+
for (const { op, plan } of plans) {
|
|
535
|
+
let modelName: string | null = null;
|
|
536
|
+
if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
|
|
537
|
+
modelName = op.pagination.itemType.name;
|
|
538
|
+
const rawModel = modelMap.get(modelName);
|
|
539
|
+
if (rawModel) {
|
|
540
|
+
const unwrapped = unwrapListModel(rawModel, modelMap);
|
|
541
|
+
if (unwrapped) modelName = unwrapped.name;
|
|
542
|
+
}
|
|
543
|
+
} else if (plan.responseModelName) {
|
|
544
|
+
modelName = plan.responseModelName;
|
|
545
|
+
}
|
|
546
|
+
if (modelName) {
|
|
547
|
+
modelUsage.set(modelName, (modelUsage.get(modelName) ?? 0) + 1);
|
|
548
|
+
}
|
|
273
549
|
}
|
|
274
550
|
|
|
275
|
-
lines
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
551
|
+
const lines: string[] = [];
|
|
552
|
+
const helpers = new Set<string>();
|
|
553
|
+
for (const [modelName, count] of modelUsage) {
|
|
554
|
+
if (count < 2) continue;
|
|
555
|
+
const model = modelMap.get(modelName);
|
|
556
|
+
if (!model) continue;
|
|
557
|
+
const assertions = buildFieldAssertions(model, 'result', modelMap);
|
|
558
|
+
if (assertions.length === 0) continue;
|
|
559
|
+
|
|
560
|
+
const domainName = resolveInterfaceName(modelName, ctx);
|
|
561
|
+
const helperName = `expect${domainName}`;
|
|
562
|
+
if (helpers.has(helperName)) continue;
|
|
563
|
+
helpers.add(helperName);
|
|
564
|
+
|
|
565
|
+
lines.push(`function ${helperName}(result: any) {`);
|
|
566
|
+
for (const assertion of assertions) {
|
|
567
|
+
lines.push(` ${assertion}`);
|
|
568
|
+
}
|
|
569
|
+
lines.push('}');
|
|
570
|
+
lines.push('');
|
|
571
|
+
}
|
|
572
|
+
return { lines, helpers };
|
|
281
573
|
}
|
|
282
574
|
|
|
283
575
|
/**
|
|
284
576
|
* Build field-level assertions for top-level primitive fields of a response model.
|
|
285
577
|
* Returns lines like: expect(result.fieldName).toBe(fixtureValue);
|
|
578
|
+
*
|
|
579
|
+
* When the top level has no assertable primitive fields (e.g. wrapper types
|
|
580
|
+
* whose only required fields are nested models), recurse one level into those
|
|
581
|
+
* nested models so we still get meaningful assertions instead of a bare
|
|
582
|
+
* `toBeDefined()`.
|
|
286
583
|
*/
|
|
287
|
-
function buildFieldAssertions(model: Model, accessor: string): string[] {
|
|
584
|
+
function buildFieldAssertions(model: Model, accessor: string, modelMap?: Map<string, Model>): string[] {
|
|
288
585
|
const assertions: string[] = [];
|
|
289
586
|
|
|
290
587
|
for (const field of model.fields) {
|
|
291
588
|
if (!field.required) continue;
|
|
292
|
-
|
|
589
|
+
// When a field has an example value, use it as the expected assertion value
|
|
590
|
+
if (field.example !== undefined) {
|
|
591
|
+
const domainField = fieldName(field.name);
|
|
592
|
+
if (typeof field.example === 'object' && field.example !== null) {
|
|
593
|
+
// Objects and arrays need toEqual with JSON serialization
|
|
594
|
+
assertions.push(`expect(${accessor}.${domainField}).toEqual(${JSON.stringify(field.example)});`);
|
|
595
|
+
} else {
|
|
596
|
+
const exampleLiteral = typeof field.example === 'string' ? `'${field.example}'` : String(field.example);
|
|
597
|
+
assertions.push(`expect(${accessor}.${domainField}).toBe(${exampleLiteral});`);
|
|
598
|
+
}
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
const value = fixtureValueForType(field.type, field.name, model.name);
|
|
293
602
|
if (value === null) continue;
|
|
294
603
|
const domainField = fieldName(field.name);
|
|
295
604
|
assertions.push(`expect(${accessor}.${domainField}).toBe(${value});`);
|
|
296
605
|
}
|
|
297
606
|
|
|
607
|
+
// When no primitive assertions were found (e.g. wrapper types like
|
|
608
|
+
// ResetPasswordResponse { user: User }), recurse one level into nested
|
|
609
|
+
// model-type fields to generate assertions on their primitive fields.
|
|
610
|
+
if (assertions.length === 0 && modelMap) {
|
|
611
|
+
for (const field of model.fields) {
|
|
612
|
+
if (!field.required) continue;
|
|
613
|
+
if (field.type.kind === 'model') {
|
|
614
|
+
const nestedModel = modelMap.get(field.type.name);
|
|
615
|
+
if (nestedModel) {
|
|
616
|
+
const nestedAccessor = `${accessor}.${fieldName(field.name)}`;
|
|
617
|
+
// Recurse without modelMap to limit depth to one level
|
|
618
|
+
const nested = buildFieldAssertions(nestedModel, nestedAccessor);
|
|
619
|
+
assertions.push(...nested);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
298
625
|
return assertions;
|
|
299
626
|
}
|
|
300
627
|
|
|
@@ -302,23 +629,47 @@ function buildFieldAssertions(model: Model, accessor: string): string[] {
|
|
|
302
629
|
* Return a JS literal string for the expected fixture value of a primitive field.
|
|
303
630
|
* Returns null for non-primitive or complex types (arrays, models, etc.).
|
|
304
631
|
*/
|
|
305
|
-
function fixtureValueForType(ref: TypeRef, name: string): string | null {
|
|
632
|
+
function fixtureValueForType(ref: TypeRef, name: string, modelName: string): string | null {
|
|
306
633
|
switch (ref.kind) {
|
|
307
634
|
case 'primitive':
|
|
308
|
-
return fixtureValueForPrimitive(ref.type, ref.format, name);
|
|
635
|
+
return fixtureValueForPrimitive(ref.type, ref.format, name, modelName);
|
|
309
636
|
case 'literal':
|
|
310
637
|
return typeof ref.value === 'string' ? `'${ref.value}'` : String(ref.value);
|
|
638
|
+
case 'enum':
|
|
639
|
+
// Use the first enum value as a realistic fixture value
|
|
640
|
+
if (ref.values?.length) {
|
|
641
|
+
const first = ref.values[0];
|
|
642
|
+
return typeof first === 'string' ? `'${first}'` : String(first);
|
|
643
|
+
}
|
|
644
|
+
return null;
|
|
645
|
+
case 'array': {
|
|
646
|
+
// For arrays of primitives/enums, generate a single-element array assertion.
|
|
647
|
+
// For arrays of models/complex types, return null to skip the assertion —
|
|
648
|
+
// the fixture will have populated items that we can't predict here.
|
|
649
|
+
const itemValue = fixtureValueForType(ref.items, name, modelName);
|
|
650
|
+
if (itemValue !== null) return `[${itemValue}]`;
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
311
653
|
default:
|
|
312
654
|
return null;
|
|
313
655
|
}
|
|
314
656
|
}
|
|
315
657
|
|
|
316
|
-
function fixtureValueForPrimitive(
|
|
658
|
+
function fixtureValueForPrimitive(
|
|
659
|
+
type: string,
|
|
660
|
+
format: string | undefined,
|
|
661
|
+
name: string,
|
|
662
|
+
modelName: string,
|
|
663
|
+
): string | null {
|
|
317
664
|
switch (type) {
|
|
318
665
|
case 'string':
|
|
319
666
|
if (format === 'date-time') return "'2023-01-01T00:00:00.000Z'";
|
|
320
667
|
if (format === 'date') return "'2023-01-01'";
|
|
321
668
|
if (format === 'uuid') return "'00000000-0000-0000-0000-000000000000'";
|
|
669
|
+
if (name === 'id') {
|
|
670
|
+
const prefix = ID_PREFIXES[modelName] ?? '';
|
|
671
|
+
return `'${prefix}01234'`;
|
|
672
|
+
}
|
|
322
673
|
if (name.includes('id')) return `'${wireFieldName(name)}_01234'`;
|
|
323
674
|
if (name.includes('email')) return "'test@example.com'";
|
|
324
675
|
if (name.includes('url') || name.includes('uri')) return "'https://example.com'";
|
|
@@ -334,3 +685,168 @@ function fixtureValueForPrimitive(type: string, format: string | undefined, name
|
|
|
334
685
|
return null;
|
|
335
686
|
}
|
|
336
687
|
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Build the expected full URL path for an operation, substituting path params
|
|
691
|
+
* with their test values. Returns a string like '/organizations/test_id'.
|
|
692
|
+
*/
|
|
693
|
+
function buildExpectedPath(op: Operation): string {
|
|
694
|
+
let path = op.path;
|
|
695
|
+
for (const param of op.pathParams) {
|
|
696
|
+
path = path.replace(`{${param.name}}`, pathParamTestValue(param, fieldName(param.name)));
|
|
697
|
+
}
|
|
698
|
+
return path;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Build a realistic test payload for a request body model.
|
|
703
|
+
* Returns { camelCaseObj, snakeCaseObj } as inline JS object literal strings,
|
|
704
|
+
* or null if the request body is not a named model.
|
|
705
|
+
*
|
|
706
|
+
* camelCaseObj is what the SDK consumer passes (e.g. { organizationName: 'Test' })
|
|
707
|
+
* snakeCaseObj is the expected wire format (e.g. { organization_name: 'Test' })
|
|
708
|
+
*/
|
|
709
|
+
function buildTestPayload(
|
|
710
|
+
op: Operation,
|
|
711
|
+
modelMap: Map<string, Model>,
|
|
712
|
+
): { camelCaseObj: string; snakeCaseObj: string } | null {
|
|
713
|
+
if (!op.requestBody || op.requestBody.kind !== 'model') return null;
|
|
714
|
+
|
|
715
|
+
const model = modelMap.get(op.requestBody.name);
|
|
716
|
+
if (!model) return null;
|
|
717
|
+
|
|
718
|
+
const fields = model.fields.filter((f) => f.required);
|
|
719
|
+
// Only use primitive/literal/enum/array fields that we can generate deterministic values for
|
|
720
|
+
const usableFields = fields.filter((f) => fixtureValueForType(f.type, f.name, model.name) !== null);
|
|
721
|
+
|
|
722
|
+
// Only generate a typed payload when ALL required fields have fixture values.
|
|
723
|
+
// A partial payload missing required fields would fail TypeScript type checking.
|
|
724
|
+
if (usableFields.length === 0 || usableFields.length < fields.length) return null;
|
|
725
|
+
|
|
726
|
+
const camelEntries: string[] = [];
|
|
727
|
+
const snakeEntries: string[] = [];
|
|
728
|
+
|
|
729
|
+
for (const field of usableFields) {
|
|
730
|
+
const value = fixtureValueForType(field.type, field.name, model.name)!;
|
|
731
|
+
const camelKey = fieldName(field.name);
|
|
732
|
+
const snakeKey = wireFieldName(field.name);
|
|
733
|
+
camelEntries.push(`${camelKey}: ${value}`);
|
|
734
|
+
snakeEntries.push(`${snakeKey}: ${value}`);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
return {
|
|
738
|
+
camelCaseObj: `{ ${camelEntries.join(', ')} }`,
|
|
739
|
+
snakeCaseObj: `{ ${snakeEntries.join(', ')} }`,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Compute a fallback body argument when buildTestPayload returns null.
|
|
745
|
+
* If the request body model has no required fields (all optional), an empty
|
|
746
|
+
* object `{}` is a valid value and doesn't need a type assertion. Otherwise,
|
|
747
|
+
* fall back to `{} as any` to bypass type checking for complex required fields.
|
|
748
|
+
*/
|
|
749
|
+
function fallbackBodyArg(op: Operation, modelMap: Map<string, Model>): string {
|
|
750
|
+
if (!op.requestBody || op.requestBody.kind !== 'model') return '{} as any';
|
|
751
|
+
const model = modelMap.get(op.requestBody.name);
|
|
752
|
+
if (!model) return '{} as any';
|
|
753
|
+
const hasRequiredFields = model.fields.some((f) => f.required);
|
|
754
|
+
return hasRequiredFields ? '{} as any' : '{}';
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Determine whether a model should get a round-trip serializer test.
|
|
759
|
+
* Includes all models with at least one field — every model gets both
|
|
760
|
+
* serialize and deserialize functions, so all benefit from round-trip testing.
|
|
761
|
+
*/
|
|
762
|
+
function modelNeedsRoundTripTest(model: Model): boolean {
|
|
763
|
+
return model.fields.length > 0;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Generate serializer round-trip tests for models that have both serialize and
|
|
768
|
+
* deserialize functions and have nested types requiring non-trivial serialization.
|
|
769
|
+
*/
|
|
770
|
+
function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
771
|
+
const files: GeneratedFile[] = [];
|
|
772
|
+
const modelToService = assignModelsToServices(spec.models, spec.services);
|
|
773
|
+
const serviceNameMap = new Map<string, string>();
|
|
774
|
+
for (const service of spec.services) {
|
|
775
|
+
serviceNameMap.set(service.name, resolveResourceClassName(service, ctx));
|
|
776
|
+
}
|
|
777
|
+
const resolveDir = (irService: string | undefined) =>
|
|
778
|
+
irService ? serviceDirName(serviceNameMap.get(irService) ?? irService) : 'common';
|
|
779
|
+
|
|
780
|
+
// Only generate round-trip tests for models with fields that have serializers generated.
|
|
781
|
+
// Skip list metadata and list wrapper models since their serializers are not emitted.
|
|
782
|
+
const eligibleModels = spec.models.filter(
|
|
783
|
+
(m) => modelNeedsRoundTripTest(m) && !isListMetadataModel(m) && !isListWrapperModel(m),
|
|
784
|
+
);
|
|
785
|
+
|
|
786
|
+
if (eligibleModels.length === 0) return files;
|
|
787
|
+
|
|
788
|
+
// Group eligible models by service directory for one test file per service
|
|
789
|
+
const modelsByDir = new Map<string, Model[]>();
|
|
790
|
+
for (const model of eligibleModels) {
|
|
791
|
+
const service = modelToService.get(model.name);
|
|
792
|
+
const dirName = resolveDir(service);
|
|
793
|
+
if (!modelsByDir.has(dirName)) {
|
|
794
|
+
modelsByDir.set(dirName, []);
|
|
795
|
+
}
|
|
796
|
+
modelsByDir.get(dirName)!.push(model);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
for (const [dirName, models] of modelsByDir) {
|
|
800
|
+
const testPath = `src/${dirName}/serializers.spec.ts`;
|
|
801
|
+
const lines: string[] = [];
|
|
802
|
+
|
|
803
|
+
// Collect imports
|
|
804
|
+
const serializerImports: string[] = [];
|
|
805
|
+
const fixtureImports: string[] = [];
|
|
806
|
+
|
|
807
|
+
for (const model of models) {
|
|
808
|
+
const domainName = resolveInterfaceName(model.name, ctx);
|
|
809
|
+
const service = modelToService.get(model.name);
|
|
810
|
+
const modelDir = resolveDir(service);
|
|
811
|
+
const serializerPath = `src/${modelDir}/serializers/${fileName(model.name)}.serializer.ts`;
|
|
812
|
+
const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.fixture.json`;
|
|
813
|
+
|
|
814
|
+
serializerImports.push(
|
|
815
|
+
`import { deserialize${domainName}, serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
|
|
816
|
+
);
|
|
817
|
+
fixtureImports.push(`import ${toCamelCase(model.name)}Fixture from '${relativeImport(testPath, fixturePath)}';`);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
for (const imp of serializerImports) {
|
|
821
|
+
lines.push(imp);
|
|
822
|
+
}
|
|
823
|
+
for (const imp of fixtureImports) {
|
|
824
|
+
lines.push(imp);
|
|
825
|
+
}
|
|
826
|
+
lines.push('');
|
|
827
|
+
|
|
828
|
+
for (const model of models) {
|
|
829
|
+
const domainName = resolveInterfaceName(model.name, ctx);
|
|
830
|
+
const fixtureName = `${toCamelCase(model.name)}Fixture`;
|
|
831
|
+
|
|
832
|
+
lines.push(`describe('${domainName}Serializer', () => {`);
|
|
833
|
+
lines.push(" it('round-trips through serialize/deserialize', () => {");
|
|
834
|
+
lines.push(` const fixture = ${fixtureName};`);
|
|
835
|
+
lines.push(` const deserialized = deserialize${domainName}(fixture);`);
|
|
836
|
+
lines.push(` const reserialized = serialize${domainName}(deserialized);`);
|
|
837
|
+
lines.push(' expect(reserialized).toEqual(expect.objectContaining(fixture));');
|
|
838
|
+
lines.push(' });');
|
|
839
|
+
lines.push('});');
|
|
840
|
+
lines.push('');
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
files.push({
|
|
844
|
+
path: testPath,
|
|
845
|
+
content: lines.join('\n'),
|
|
846
|
+
skipIfExists: true,
|
|
847
|
+
integrateTarget: false,
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
return files;
|
|
852
|
+
}
|