@workos/oagen-emitters 0.12.0 → 0.12.1

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/src/rust/tests.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  GeneratedFile,
6
6
  Model,
7
7
  Operation,
8
+ Parameter,
8
9
  ResolvedOperation,
9
10
  ResolvedWrapper,
10
11
  TypeRef,
@@ -16,9 +17,19 @@ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
16
17
 
17
18
  /**
18
19
  * Generate integration tests under `tests/`. Each mount group gets one
19
- * `tests/{mount}_test.rs` file. Generated tests construct params, mock the
20
- * expected request, then call the SDK method and assert the request was sent
21
- * (`Mock::expect(1)`). JSON fixtures are emitted alongside.
20
+ * `tests/{mount}_test.rs` file. Per operation the generator emits:
21
+ *
22
+ * - `_round_trip`: happy-path 200 mock + call.
23
+ * - `_unauthorized`, `_not_found`, `_rate_limited`, `_server_error`: error
24
+ * paths for every HTTP-calling op.
25
+ * - `_bad_request`, `_unprocessable`: additional 4xx error paths for write
26
+ * ops (POST/PUT/PATCH/DELETE).
27
+ * - `_empty_page`: empty `data: []` response for paginated ops.
28
+ * - `_encodes_query_params`: outbound query-string assertion for ops with
29
+ * array query params (`explode: true` repeated keys, `explode: false`
30
+ * comma-joined).
31
+ *
32
+ * URL-builder ops (no HTTP call) only get the round-trip test.
22
33
  */
23
34
  export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
24
35
  const files: GeneratedFile[] = [];
@@ -84,6 +95,7 @@ function renderMountTest(
84
95
  lines.push('');
85
96
  lines.push('use wiremock::matchers::{method, path as path_matcher};');
86
97
  lines.push('use wiremock::{Mock, MockServer, ResponseTemplate};');
98
+ lines.push(`use ${crate}::Error;`);
87
99
  lines.push('');
88
100
 
89
101
  const seen = new Set<string>();
@@ -113,6 +125,32 @@ function renderMountTest(
113
125
  return lines.join('\n') + '\n';
114
126
  }
115
127
 
128
+ /**
129
+ * Bundle of inputs computed once per operation and reused across the various
130
+ * test categories (round-trip, error tests, encoding tests). All test
131
+ * categories share the same params construction, path, method, and accessor
132
+ * call shape — the only thing that varies is the response template and the
133
+ * assertion.
134
+ */
135
+ interface CallShape {
136
+ /** Operation method name, e.g. `list_events`. */
137
+ methodIdent: string;
138
+ /** Literal path with `{id}` placeholders substituted to `test_id`. */
139
+ literalPath: string;
140
+ /** Upper-case HTTP verb, e.g. `GET`. */
141
+ httpMethod: string;
142
+ /** The args passed to the SDK method call, joined with `, `. */
143
+ callArgs: string;
144
+ /** True when the op is a URL-builder (no HTTP call). */
145
+ isUrlBuilder: boolean;
146
+ /** True for HTTP methods that mutate state. */
147
+ isWrite: boolean;
148
+ /** True when the op declares cursor pagination. */
149
+ isPaginated: boolean;
150
+ /** When non-null, the op has a synthetic body group enum. */
151
+ hasBodyGroup: boolean;
152
+ }
153
+
116
154
  /** Test for a non-wrapper operation. */
117
155
  function renderRegularTest(
118
156
  op: Operation,
@@ -126,30 +164,143 @@ function renderRegularTest(
126
164
  const literalPath = op.path.replace(/\{[^}]+\}/g, 'test_id');
127
165
  const httpMethod = op.httpMethod.toUpperCase();
128
166
  const responseExpr = responseBodyExpr(op.response, modelMap, enumMap);
167
+ const isUrlBuilder = resolved.urlBuilder === true;
168
+
169
+ const callArgs = buildCallArgs(op, resolved, crate, accessor, modelMap, enumMap).join(', ');
170
+ const shape: CallShape = {
171
+ methodIdent: m,
172
+ literalPath,
173
+ httpMethod,
174
+ callArgs,
175
+ isUrlBuilder,
176
+ isWrite: isWriteMethod(op.httpMethod),
177
+ isPaginated: !!op.pagination,
178
+ hasBodyGroup: !!op.requestBody && hasBodyParameterGroup(op),
179
+ };
180
+
181
+ const lines: string[] = [];
182
+
183
+ // Round-trip (happy path).
184
+ lines.push('#[tokio::test]');
185
+ lines.push(`async fn ${accessor}_${m}_round_trip() {`);
186
+ if (isUrlBuilder) {
187
+ // URL-builder ops don't issue HTTP requests; there's nothing to mock.
188
+ lines.push(' let server = MockServer::start().await;');
189
+ lines.push(' let client = common::test_client(&server).await;');
190
+ lines.push(` let _ = client.${accessor}().${m}(${callArgs});`);
191
+ } else {
192
+ lines.push(' let server = MockServer::start().await;');
193
+ lines.push(` Mock::given(method(${JSON.stringify(httpMethod)}))`);
194
+ lines.push(` .and(path_matcher(${JSON.stringify(literalPath)}))`);
195
+ lines.push(` .respond_with(ResponseTemplate::new(200).set_body_string(${responseExpr}))`);
196
+ lines.push(' .expect(1)');
197
+ lines.push(' .mount(&server)');
198
+ lines.push(' .await;');
199
+ lines.push(' let client = common::test_client(&server).await;');
200
+ lines.push(` let _ = client.${accessor}().${m}(${callArgs}).await;`);
201
+ }
202
+ // wiremock asserts on drop that the `.expect(1)` mock was matched once;
203
+ // mismatched method/path produces a panic at end-of-test. We deliberately
204
+ // ignore the deserialised response: stub fixtures cover the common path
205
+ // (typed model deserialisation), but discriminated-union responses can't
206
+ // always be reproduced from a generated fixture without bespoke schema
207
+ // awareness, so a strict `is_ok()` would over-trigger.
208
+ lines.push('}');
129
209
 
210
+ if (isUrlBuilder) {
211
+ // No HTTP call to mock — the round-trip is all we can sensibly emit.
212
+ return lines;
213
+ }
214
+
215
+ // Error-path tests — same path/method, different response status.
216
+ for (const errTest of standardErrorTests(shape, accessor)) {
217
+ lines.push('');
218
+ lines.push(...errTest);
219
+ }
220
+
221
+ if (shape.isWrite) {
222
+ for (const errTest of writeErrorTests(shape, accessor)) {
223
+ lines.push('');
224
+ lines.push(...errTest);
225
+ }
226
+ }
227
+
228
+ // Empty-page test for paginated list ops.
229
+ if (shape.isPaginated) {
230
+ const emptyTest = emptyPageTest(op, shape, accessor);
231
+ if (emptyTest) {
232
+ lines.push('');
233
+ lines.push(...emptyTest);
234
+ }
235
+ }
236
+
237
+ // Query-string encoding assertion when an array query param is present.
238
+ const encodingTest = encodesQueryParamsTest(op, resolved, accessor, crate, modelMap, enumMap);
239
+ if (encodingTest) {
240
+ lines.push('');
241
+ lines.push(...encodingTest);
242
+ }
243
+
244
+ return lines;
245
+ }
246
+
247
+ /**
248
+ * Build the positional args for the resource method call: path-param literals,
249
+ * then a single params struct (when the op has any), then a token-string for
250
+ * non-bearer security overrides. This factors the constructor logic out of
251
+ * the round-trip renderer so the error/encoding tests can reuse it verbatim
252
+ * — every test category sends the same request, only the mocked response and
253
+ * assertion differ.
254
+ */
255
+ function buildCallArgs(
256
+ op: Operation,
257
+ resolved: ResolvedOperation,
258
+ crate: string,
259
+ accessor: string,
260
+ modelMap: Map<string, Model>,
261
+ enumMap: Map<string, Enum>,
262
+ ): string[] {
130
263
  const hidden = new Set<string>([...Object.keys(resolved.defaults ?? {}), ...(resolved.inferFromClient ?? [])]);
131
- const visibleQuery = op.queryParams.filter((p) => !hidden.has(p.name));
264
+ // Names of query/header params that fold into a parameter-group enum and
265
+ // therefore must not be passed individually to the params constructor.
266
+ const groupedNames = new Set<string>();
267
+ for (const g of op.parameterGroups ?? []) {
268
+ for (const v of g.variants) for (const p of v.parameters) groupedNames.add(p.name);
269
+ }
270
+ const visibleQuery = op.queryParams.filter((p) => !hidden.has(p.name) && !groupedNames.has(p.name));
132
271
  const visibleHeader = op.headerParams.filter((p) => !hidden.has(p.name));
133
272
  const visibleParams = [...visibleQuery, ...visibleHeader];
134
273
  const requiredParams = visibleParams.filter((p) => p.required);
135
274
  const hasBody = op.requestBody !== undefined;
136
275
  const bodyRequired = hasBody && op.requestBody!.kind !== 'nullable';
137
- const emptyParams = !hasBody && visibleParams.length === 0;
276
+ const queryNames = new Set(op.queryParams.map((p) => p.name));
277
+ const requiredGroups = (op.parameterGroups ?? []).filter((g) => !g.optional);
278
+ const requiredQueryGroups = requiredGroups.filter((g) =>
279
+ g.variants.every((v) => v.parameters.every((p) => queryNames.has(p.name))),
280
+ );
281
+ const emptyParams = !hasBody && visibleParams.length === 0 && (op.parameterGroups?.length ?? 0) === 0;
138
282
 
139
283
  const callArgs: string[] = [];
140
284
  for (const _ of op.pathParams) callArgs.push('"test_id"');
141
285
 
142
286
  if (!emptyParams) {
143
287
  const paramsType = `${crate}::${accessor}::${typeName(resolved.methodName)}Params`;
144
- if (requiredParams.length === 0 && !bodyRequired) {
288
+ const noCtorRequired = requiredParams.length === 0 && !bodyRequired && requiredQueryGroups.length === 0;
289
+ if (noCtorRequired) {
145
290
  callArgs.push(`${paramsType}::default()`);
146
291
  } else {
147
292
  const ctorArgs: string[] = [];
148
293
  for (const p of requiredParams) {
149
294
  ctorArgs.push(stubExpr(p.type, p.name, modelMap, enumMap));
150
295
  }
151
- if (hasBody) {
152
- if (bodyRequired) {
296
+ for (const g of requiredQueryGroups) {
297
+ ctorArgs.push(parameterGroupStubExpr(g, crate, accessor));
298
+ }
299
+ if (hasBody && bodyRequired) {
300
+ const hasBodyGroup = hasBodyParameterGroup(op);
301
+ if (hasBodyGroup) {
302
+ ctorArgs.push(syntheticBodyStubExpr(op, resolved, crate, accessor, modelMap));
303
+ } else {
153
304
  ctorArgs.push(stubExpr(op.requestBody!, 'body', modelMap, enumMap));
154
305
  }
155
306
  }
@@ -157,28 +308,372 @@ function renderRegularTest(
157
308
  }
158
309
  }
159
310
 
311
+ // Per-operation bearer override (e.g. `GET /sso/profile`) — append the
312
+ // access-token positional arg before the awaiter.
313
+ const tokenName = bearerOverrideTokenName(op);
314
+ if (tokenName) {
315
+ callArgs.push(`"stub_${tokenName}".to_string()`);
316
+ }
317
+
318
+ return callArgs;
319
+ }
320
+
321
+ /**
322
+ * Build the four common-error test bodies (401/404/429/500). Each one mocks a
323
+ * single response with the given status, calls the SDK method (which should
324
+ * fail), and asserts on the unwrapped `Error::Api` payload.
325
+ */
326
+ function standardErrorTests(shape: CallShape, accessor: string): string[][] {
327
+ const tests: string[][] = [];
328
+ tests.push(errorTestBody(shape, accessor, 'unauthorized', 401, '{"message":"Unauthorized"}', undefined));
329
+ tests.push(errorTestBody(shape, accessor, 'not_found', 404, '{"message":"Not found"}', undefined));
330
+ tests.push(
331
+ errorTestBody(shape, accessor, 'rate_limited', 429, '{"message":"Slow down"}', {
332
+ retryAfterSeconds: 1,
333
+ assertRetryAfter: true,
334
+ }),
335
+ );
336
+ tests.push(errorTestBody(shape, accessor, 'server_error', 500, '{"message":"Internal error"}', undefined));
337
+ return tests;
338
+ }
339
+
340
+ /** Build the two write-op-only error tests (400/422). */
341
+ function writeErrorTests(shape: CallShape, accessor: string): string[][] {
342
+ return [
343
+ errorTestBody(shape, accessor, 'bad_request', 400, '{"code":"validation_error","message":"Bad request"}', {
344
+ assertCode: 'validation_error',
345
+ }),
346
+ errorTestBody(shape, accessor, 'unprocessable', 422, '{"message":"Unprocessable"}', undefined),
347
+ ];
348
+ }
349
+
350
+ interface ErrorOptions {
351
+ retryAfterSeconds?: number;
352
+ assertRetryAfter?: boolean;
353
+ assertCode?: string;
354
+ }
355
+
356
+ function errorTestBody(
357
+ shape: CallShape,
358
+ accessor: string,
359
+ category: string,
360
+ status: number,
361
+ body: string,
362
+ opts: ErrorOptions | undefined,
363
+ ): string[] {
160
364
  const lines: string[] = [];
161
365
  lines.push('#[tokio::test]');
162
- lines.push(`async fn ${accessor}_${m}_round_trip() {`);
366
+ lines.push(`async fn ${accessor}_${shape.methodIdent}_${category}() {`);
163
367
  lines.push(' let server = MockServer::start().await;');
164
- lines.push(` Mock::given(method(${JSON.stringify(httpMethod)}))`);
165
- lines.push(` .and(path_matcher(${JSON.stringify(literalPath)}))`);
166
- lines.push(` .respond_with(ResponseTemplate::new(200).set_body_string(${responseExpr}))`);
368
+ lines.push(` let template = ResponseTemplate::new(${status})`);
369
+ if (opts?.retryAfterSeconds !== undefined) {
370
+ lines.push(` .insert_header("retry-after", ${JSON.stringify(String(opts.retryAfterSeconds))})`);
371
+ }
372
+ lines.push(` .set_body_string(${JSON.stringify(body)});`);
373
+ lines.push(` Mock::given(method(${JSON.stringify(shape.httpMethod)}))`);
374
+ lines.push(` .and(path_matcher(${JSON.stringify(shape.literalPath)}))`);
375
+ lines.push(' .respond_with(template)');
167
376
  lines.push(' .expect(1)');
168
377
  lines.push(' .mount(&server)');
169
378
  lines.push(' .await;');
170
379
  lines.push(' let client = common::test_client(&server).await;');
171
- lines.push(` let _ = client.${accessor}().${m}(${callArgs.join(', ')}).await;`);
172
- // wiremock asserts on drop that the `.expect(1)` mock was matched once;
173
- // mismatched method/path produces a panic at end-of-test. We deliberately
174
- // ignore the deserialised response: stub fixtures cover the common path
175
- // (typed model deserialisation), but discriminated-union responses can't
176
- // always be reproduced from a generated fixture without bespoke schema
177
- // awareness, so a strict `is_ok()` would over-trigger.
380
+ lines.push(
381
+ ` let err = client.${accessor}().${shape.methodIdent}(${shape.callArgs}).await.expect_err("expected error");`,
382
+ );
383
+ lines.push(' let api = match &err {');
384
+ lines.push(' Error::Api(api) => api.as_ref(),');
385
+ lines.push(' other => panic!("expected Error::Api, got {other:?}"),');
386
+ lines.push(' };');
387
+ lines.push(` assert_eq!(api.status, ${status});`);
388
+ if (opts?.assertRetryAfter) {
389
+ lines.push(
390
+ ` assert_eq!(api.retry_after, Some(std::time::Duration::from_secs(${opts.retryAfterSeconds ?? 0})));`,
391
+ );
392
+ }
393
+ if (opts?.assertCode) {
394
+ lines.push(` assert_eq!(api.code.as_deref(), Some(${JSON.stringify(opts.assertCode)}));`);
395
+ }
396
+ lines.push('}');
397
+ return lines;
398
+ }
399
+
400
+ /**
401
+ * Return an `_empty_page` test for a paginated list op. Two shapes are
402
+ * supported:
403
+ *
404
+ * - Wrapper model: `{"data": [], "list_metadata": {...}}`, accessed via
405
+ * `resp.data` on the returned struct.
406
+ * - Bare array: `[]`, accessed via `resp.is_empty()` directly (the SDK
407
+ * returns `Vec<T>` for paginated ops without a wrapper model).
408
+ *
409
+ * Returns null when the response shape isn't recognised (e.g. a primitive
410
+ * or unknown shape we can't safely assert against).
411
+ */
412
+ function emptyPageTest(op: Operation, shape: CallShape, accessor: string): string[] | null {
413
+ const responseKind = op.response.kind;
414
+ let body: string;
415
+ let dataAccessor: string;
416
+ if (responseKind === 'array') {
417
+ // Bare-array paginated response: SDK returns Vec<T>.
418
+ body = '[]';
419
+ dataAccessor = 'resp';
420
+ } else if (responseKind === 'model') {
421
+ // Wrapper-model paginated response: SDK returns the wrapper struct.
422
+ // `list_metadata` is always an object — its required field set depends
423
+ // on the response model, but for the empty case both `before` and `after`
424
+ // are either present-as-null or absent. We emit both keys as null to
425
+ // satisfy any shape with optional cursor fields without hard-coding
426
+ // per-op knowledge.
427
+ // The wrapper model has a required `object` discriminator field, e.g.
428
+ // `"list"`. Include it so the response deserialises against any
429
+ // generated wrapper struct.
430
+ body = '{"object":"list","data":[],"list_metadata":{"before":null,"after":null}}';
431
+ dataAccessor = 'resp.data';
432
+ } else {
433
+ return null;
434
+ }
435
+ const lines: string[] = [];
436
+ lines.push('#[tokio::test]');
437
+ lines.push(`async fn ${accessor}_${shape.methodIdent}_empty_page() {`);
438
+ lines.push(' let server = MockServer::start().await;');
439
+ lines.push(` Mock::given(method(${JSON.stringify(shape.httpMethod)}))`);
440
+ lines.push(` .and(path_matcher(${JSON.stringify(shape.literalPath)}))`);
441
+ lines.push(` .respond_with(ResponseTemplate::new(200).set_body_string(${JSON.stringify(body)}))`);
442
+ lines.push(' .expect(1)');
443
+ lines.push(' .mount(&server)');
444
+ lines.push(' .await;');
445
+ lines.push(' let client = common::test_client(&server).await;');
446
+ lines.push(
447
+ ` let resp = client.${accessor}().${shape.methodIdent}(${shape.callArgs}).await.expect("expected success");`,
448
+ );
449
+ lines.push(` assert!(${dataAccessor}.is_empty(), "expected empty data array");`);
178
450
  lines.push('}');
179
451
  return lines;
180
452
  }
181
453
 
454
+ /**
455
+ * Build an `_encodes_query_params` test when the op declares at least one
456
+ * array query param. Constructs a params struct with a known Vec value on
457
+ * each array field and asserts the actual outbound query string contains
458
+ * either repeated keys (`events=foo&events=bar`, default for `explode: true`)
459
+ * or a comma-joined value (`events=foo%2Cbar`, when `explode: false`).
460
+ *
461
+ * Returns null when no array query params apply — those ops have nothing
462
+ * interesting to encode.
463
+ */
464
+ function encodesQueryParamsTest(
465
+ op: Operation,
466
+ resolved: ResolvedOperation,
467
+ accessor: string,
468
+ crate: string,
469
+ modelMap: Map<string, Model>,
470
+ enumMap: Map<string, Enum>,
471
+ ): string[] | null {
472
+ const hidden = new Set<string>([...Object.keys(resolved.defaults ?? {}), ...(resolved.inferFromClient ?? [])]);
473
+ const groupedNames = new Set<string>();
474
+ for (const g of op.parameterGroups ?? []) {
475
+ for (const v of g.variants) for (const p of v.parameters) groupedNames.add(p.name);
476
+ }
477
+ // Find at most one Vec<String> query param to drive the assertion. The
478
+ // order is stable (it follows `op.queryParams`), and one is enough — the
479
+ // encoder applies the same rule to every array field. We restrict the
480
+ // assertion to string-element arrays because non-string arrays (e.g.
481
+ // enums) require per-type constructors we can't reliably synthesise from
482
+ // the IR alone, and serializing a Vec<EnumX> via vec!["foo".into(), ..]
483
+ // wouldn't type-check.
484
+ const arrayParams = op.queryParams.filter(
485
+ (p) => !hidden.has(p.name) && !groupedNames.has(p.name) && isStringArrayParam(p),
486
+ );
487
+ if (arrayParams.length === 0) return null;
488
+ const target = arrayParams[0];
489
+
490
+ // The encoded form for both variants. The test inspects the literal query
491
+ // string returned by wiremock; `assert!(query.contains(...))` matches the
492
+ // serialized output regardless of where in the string the param sits, so
493
+ // we don't need to predict the rest of the params' order.
494
+ const exploded = (target as { explode?: boolean }).explode !== false;
495
+ const expectedFragment = exploded ? `${target.name}=foo&${target.name}=bar` : `${target.name}=foo%2Cbar`;
496
+
497
+ // For the call, we want to set `target` to ["foo", "bar"] on the params
498
+ // struct. Build the standard call args, then mutate the params struct to
499
+ // override the field.
500
+ const callArgs = buildCallArgs(op, resolved, crate, accessor, modelMap, enumMap);
501
+ // Locate which entry in callArgs is the params struct so we can mutate it
502
+ // post-construction. The buildCallArgs result is:
503
+ // path_arg_1, path_arg_2, ..., paramsExpr, [token]
504
+ // Path args are always `"test_id"` literals; the params expr is the first
505
+ // entry past the path arg count.
506
+ const pathArgCount = op.pathParams.length;
507
+ const tokenName = bearerOverrideTokenName(op);
508
+ const expectsParams = callArgs.length > pathArgCount && (tokenName ? callArgs.length > pathArgCount + 1 : true);
509
+ if (!expectsParams) return null;
510
+
511
+ // Build the test body. We materialise the params as a mutable local so we
512
+ // can set the target array field, then pass it (by value) to the SDK call.
513
+ const tokenArg = tokenName ? callArgs[callArgs.length - 1] : null;
514
+ const paramsExpr = callArgs[pathArgCount];
515
+
516
+ const lines: string[] = [];
517
+ const respExpr = encodingResponseExpr(op, modelMap, enumMap);
518
+ lines.push('#[tokio::test]');
519
+ lines.push(`async fn ${accessor}_${methodName(resolved.methodName)}_encodes_query_params() {`);
520
+ lines.push(' let server = MockServer::start().await;');
521
+ lines.push(` Mock::given(method(${JSON.stringify('GET'.replace('GET', op.httpMethod.toUpperCase()))}))`);
522
+ lines.push(` .and(path_matcher(${JSON.stringify(op.path.replace(/\{[^}]+\}/g, 'test_id'))}))`);
523
+ lines.push(` .respond_with(ResponseTemplate::new(200).set_body_string(${respExpr}))`);
524
+ lines.push(' .mount(&server)');
525
+ lines.push(' .await;');
526
+ lines.push(' let client = common::test_client(&server).await;');
527
+ // Clippy's `field_reassign_with_default` fires when fields are mutated on a
528
+ // value created via `T::default()`. Use struct-update syntax in that case;
529
+ // otherwise (`Type::new(...)` etc.), fall back to the mutable-binding form
530
+ // since the lint doesn't apply.
531
+ if (paramsExpr.endsWith('::default()')) {
532
+ const ty = paramsExpr.slice(0, -'::default()'.length);
533
+ lines.push(` let params = ${ty} {`);
534
+ lines.push(` ${fieldIdent(target.name)}: Some(vec!["foo".to_string(), "bar".to_string()]),`);
535
+ lines.push(' ..Default::default()');
536
+ lines.push(' };');
537
+ } else {
538
+ lines.push(` let mut params = ${paramsExpr};`);
539
+ lines.push(` params.${fieldIdent(target.name)} = Some(vec!["foo".to_string(), "bar".to_string()]);`);
540
+ }
541
+ // Drop the array param onto the params; ignore any required-cursor fields
542
+ // — they're already populated by buildCallArgs.
543
+ const passArgs: string[] = [];
544
+ for (let i = 0; i < pathArgCount; i++) passArgs.push(callArgs[i]);
545
+ passArgs.push('params');
546
+ if (tokenArg) passArgs.push(tokenArg);
547
+ lines.push(` let _ = client.${accessor}().${methodName(resolved.methodName)}(${passArgs.join(', ')}).await;`);
548
+ lines.push(' let received = server.received_requests().await.expect("recorded requests");');
549
+ lines.push(' let request = received.first().expect("at least one request");');
550
+ lines.push(' let query = request.url.query().unwrap_or("");');
551
+ lines.push(
552
+ ` assert!(query.contains(${JSON.stringify(expectedFragment)}), "expected query to contain {:?}, got {:?}", ${JSON.stringify(expectedFragment)}, query);`,
553
+ );
554
+ lines.push('}');
555
+ return lines;
556
+ }
557
+
558
+ /** Body expression for the encoding-test response (success, ignored). */
559
+ function encodingResponseExpr(op: Operation, modelMap: Map<string, Model>, enumMap: Map<string, Enum>): string {
560
+ // For paginated ops we serve an empty page so the call succeeds. Use the
561
+ // bare-array shape for `Vec<T>` responses, the wrapper shape otherwise.
562
+ if (op.pagination) {
563
+ if (op.response.kind === 'array') return JSON.stringify('[]');
564
+ return JSON.stringify('{"object":"list","data":[],"list_metadata":{"before":null,"after":null}}');
565
+ }
566
+ return responseBodyExpr(op.response, modelMap, enumMap);
567
+ }
568
+
569
+ /**
570
+ * True if `param.type` is `Vec<String>` (or `Option<Vec<String>>`). Restricts
571
+ * the encoding test to string arrays because non-string arrays would need
572
+ * per-type constructors we can't synthesise reliably.
573
+ */
574
+ function isStringArrayParam(p: Parameter): boolean {
575
+ let t: TypeRef = p.type;
576
+ while (t.kind === 'nullable') t = t.inner;
577
+ if (t.kind !== 'array') return false;
578
+ let inner: TypeRef = t.items;
579
+ while (inner.kind === 'nullable') inner = inner.inner;
580
+ return inner.kind === 'primitive' && inner.type === 'string';
581
+ }
582
+
583
+ /** Snake-case field accessor matching the resources emitter's naming. */
584
+ function fieldIdent(name: string): string {
585
+ // The Rust emitter snake-cases field names via `methodName`. Reuse it so
586
+ // the generated field name matches the params struct.
587
+ return methodName(name);
588
+ }
589
+
590
+ /** True for HTTP methods that mutate state and should retry-defensively. */
591
+ function isWriteMethod(method: string): boolean {
592
+ return method !== 'get' && method !== 'head';
593
+ }
594
+
595
+ /** True when the op has at least one body-side parameter group. */
596
+ function hasBodyParameterGroup(op: Operation): boolean {
597
+ const queryNames = new Set(op.queryParams.map((p) => p.name));
598
+ return (op.parameterGroups ?? []).some((g) =>
599
+ g.variants.every((v) => v.parameters.every((p) => !queryNames.has(p.name))),
600
+ );
601
+ }
602
+
603
+ /** Pick the snake_case token-arg name for an op with a non-bearer security override. */
604
+ function bearerOverrideTokenName(op: Operation): string | null {
605
+ const override = op.security?.find((s) => s.schemeName !== 'bearerAuth');
606
+ if (!override) return null;
607
+ return override.schemeName.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`).replace(/^_/, '');
608
+ }
609
+
610
+ /**
611
+ * Construct a synthetic body type via its `new(...)` constructor. The
612
+ * resources emitter passes required flat fields and required flatten enums
613
+ * positionally; mirror that ordering here so the stub compiles against the
614
+ * generated `impl <Type> { fn new(...) }`.
615
+ */
616
+ function syntheticBodyStubExpr(
617
+ op: Operation,
618
+ resolved: ResolvedOperation,
619
+ crate: string,
620
+ accessor: string,
621
+ modelMap: Map<string, Model>,
622
+ ): string {
623
+ const bodyRef = op.requestBody!;
624
+ const bodyName = `${typeName(resolved.methodName)}ParamsBody`;
625
+ const fqn = `${crate}::${accessor}::${bodyName}`;
626
+ const model = bodyRef.kind === 'model' ? (modelMap.get(bodyRef.name) ?? null) : null;
627
+ const queryNames = new Set(op.queryParams.map((p) => p.name));
628
+ const bodyGroupNames = new Set<string>();
629
+ for (const g of op.parameterGroups ?? []) {
630
+ if (g.variants.every((v) => v.parameters.every((p) => !queryNames.has(p.name)))) {
631
+ for (const v of g.variants) for (const p of v.parameters) bodyGroupNames.add(p.name);
632
+ }
633
+ }
634
+ // Iteration order must match resources.ts: required flat model fields, then
635
+ // each parameter-group field (in the order returned by `op.parameterGroups`).
636
+ const args: string[] = [];
637
+ if (model) {
638
+ for (const f of model.fields) {
639
+ if (bodyGroupNames.has(f.name)) continue;
640
+ const isRequired = !!f.required && f.type.kind !== 'nullable';
641
+ if (!isRequired) continue;
642
+ args.push(`${JSON.stringify(`stub_${f.name}`)}.to_string()`);
643
+ }
644
+ }
645
+ for (const g of op.parameterGroups ?? []) {
646
+ const isBodyGroup = g.variants.every((v) => v.parameters.every((p) => !queryNames.has(p.name)));
647
+ if (!isBodyGroup) continue;
648
+ if (g.optional) continue;
649
+ args.push(parameterGroupStubExpr(g, crate, accessor));
650
+ }
651
+ return `${fqn}::new(${args.join(', ')})`;
652
+ }
653
+
654
+ /** First-variant stub for a parameter-group enum, fully crate-qualified. */
655
+ function parameterGroupStubExpr(
656
+ group: import('@workos/oagen').ParameterGroup,
657
+ crate: string,
658
+ accessor: string,
659
+ ): string {
660
+ const enumName = group.name
661
+ .split('_')
662
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
663
+ .join('');
664
+ const firstVariant = group.variants[0];
665
+ const variantName = firstVariant.name
666
+ .split('_')
667
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
668
+ .join('');
669
+ const fqn = `${crate}::${accessor}::${enumName}`;
670
+ if (firstVariant.parameters.length === 0) return `${fqn}::${variantName}`;
671
+ const fields = firstVariant.parameters
672
+ .map((p) => `${p.name}: ${JSON.stringify(`stub_${p.name}`)}.to_string()`)
673
+ .join(', ');
674
+ return `${fqn}::${variantName} { ${fields} }`;
675
+ }
676
+
182
677
  /** Test for a wrapper-method operation. */
183
678
  function renderWrapperTest(
184
679
  op: Operation,
@@ -215,6 +710,8 @@ function renderWrapperTest(
215
710
  callArgs.push(`${paramsType}::new(${ctorArgs.join(', ')})`);
216
711
  }
217
712
 
713
+ const callArgsStr = callArgs.join(', ');
714
+
218
715
  const lines: string[] = [];
219
716
  lines.push('#[tokio::test]');
220
717
  lines.push(`async fn ${accessor}_${m}_round_trip() {`);
@@ -226,9 +723,32 @@ function renderWrapperTest(
226
723
  lines.push(' .mount(&server)');
227
724
  lines.push(' .await;');
228
725
  lines.push(' let client = common::test_client(&server).await;');
229
- lines.push(` let _ = client.${accessor}().${m}(${callArgs.join(', ')}).await;`);
726
+ lines.push(` let _ = client.${accessor}().${m}(${callArgsStr}).await;`);
230
727
  // Drop assertion on `Mock::expect(1)` validates path/method.
231
728
  lines.push('}');
729
+
730
+ // Error tests for wrapper variants — same path/method, different response.
731
+ const shape: CallShape = {
732
+ methodIdent: m,
733
+ literalPath,
734
+ httpMethod,
735
+ callArgs: callArgsStr,
736
+ isUrlBuilder: false,
737
+ isWrite: isWriteMethod(op.httpMethod),
738
+ isPaginated: !!op.pagination,
739
+ hasBodyGroup: false,
740
+ };
741
+ for (const errTest of standardErrorTests(shape, accessor)) {
742
+ lines.push('');
743
+ lines.push(...errTest);
744
+ }
745
+ if (shape.isWrite) {
746
+ for (const errTest of writeErrorTests(shape, accessor)) {
747
+ lines.push('');
748
+ lines.push(...errTest);
749
+ }
750
+ }
751
+
232
752
  return lines;
233
753
  }
234
754