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