Skip to content

Commit a8bba55

Browse files
gjtorikianclaude
andcommitted
test(ruby): assert request body for parameter-group dispatchers
Tests previously stubbed the URL but never verified the wire body, so the silent-drop bug fixed in b87038b — `update_organization_membership` shipping a PUT with no body when only the `role` group managed it — slipped past the suite. Two changes catch this class of regression: - `buildCallArgsStub` now passes optional parameter groups too, not just required ones. The old behavior left the dispatcher code path unexercised whenever a group was optional, hiding the bug entirely. - New `buildBodyMatcher` emits a webmock `.with(body: hash_including(...))` matcher whenever an operation has any parameter group dispatched into the body. The matcher includes required non-group body fields plus the first variant's wire-name leaves, so any mismatch (missing body, dropped variant leaf, wrong wire name) fails the test. Verified by temporarily reverting b87038b: the regenerated `test_update_organization_membership` errors with `WebMock::NetConnectNotAllowedError` because the request body is empty and doesn't match the stub. Restoring the fix makes it pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b87038b commit a8bba55

1 file changed

Lines changed: 59 additions & 5 deletions

File tree

src/ruby/tests.ts

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,24 +77,24 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
7777
const resolved = lookupResolved(op, lookup);
7878
const hiddenParams = buildHiddenParams(resolved);
7979
const callArgs = buildCallArgsStub(op, modelByName, hiddenParams);
80+
const bodyMatcher = buildBodyMatcher(op, modelByName, hiddenParams);
8081

8182
// Collect method info for the parameterized 401 test (T20).
8283
authMethodManifest.push({ method, httpMethodSym, stubUrl, callArgs });
8384

8485
const stubRegex = stubUrlRegex(stubUrl);
8586
lines.push(` def test_${method}_returns_expected_result`);
87+
lines.push(` stub_request(${httpMethodSym}, ${stubRegex})`);
88+
if (bodyMatcher) lines.push(` .with(body: ${bodyMatcher})`);
8689
if (isList) {
87-
lines.push(` stub_request(${httpMethodSym}, ${stubRegex})`);
8890
lines.push(` .to_return(body: '{"data": [], "list_metadata": {}}', status: 200)`);
8991
lines.push(` result = @client.${prop}.${method}(${callArgs})`);
9092
lines.push(' assert_kind_of WorkOS::Types::ListStruct, result');
9193
} else if (op.response.kind === 'primitive') {
92-
lines.push(` stub_request(${httpMethodSym}, ${stubRegex})`);
9394
lines.push(` .to_return(body: "{}", status: 200)`);
9495
lines.push(` result = @client.${prop}.${method}(${callArgs})`);
9596
lines.push(' assert_nil result');
9697
} else {
97-
lines.push(` stub_request(${httpMethodSym}, ${stubRegex})`);
9898
lines.push(` .to_return(body: "{}", status: 200)`);
9999
lines.push(` result = @client.${prop}.${method}(${callArgs})`);
100100
lines.push(' refute_nil result');
@@ -331,9 +331,11 @@ function buildCallArgsStub(op: Operation, modelByName: Map<string, Model>, hidde
331331
parts.push(`${name}: ${stubValueFor(q.type)}`);
332332
}
333333

334-
// Required parameter group kwargs: instantiate the first variant's class.
334+
// Parameter group kwargs (required and optional): instantiate the first
335+
// variant's class. Optional groups are exercised too so the dispatcher
336+
// code path is covered — passing nothing would skip the body block and
337+
// hide silent-drop bugs (see workos/oagen-emitters#66).
335338
for (const group of op.parameterGroups ?? []) {
336-
if (group.optional) continue;
337339
const name = fieldName(group.name);
338340
if (seen.has(name)) continue;
339341
seen.add(name);
@@ -348,6 +350,58 @@ function buildCallArgsStub(op: Operation, modelByName: Map<string, Model>, hidde
348350
return parts.join(', ');
349351
}
350352

353+
/**
354+
* Build a Ruby `hash_including(...)` matcher describing the wire body the
355+
* SDK should send for an operation whose body is constructed (in part) by a
356+
* parameter-group dispatcher. Returns `null` for operations without body
357+
* groups — those are still stubbed without a body matcher.
358+
*
359+
* The matcher includes every required non-group body field plus the first
360+
* variant's wire-name leaves for each group dispatched into the body. This
361+
* catches regressions where the dispatcher silently drops a passed group
362+
* (the original `update_organization_membership` regression).
363+
*/
364+
function buildBodyMatcher(op: Operation, modelByName: Map<string, Model>, hiddenParams: Set<string>): string | null {
365+
const httpMethod = op.httpMethod.toLowerCase();
366+
const hasBodyMethod = !['get', 'head', 'delete'].includes(httpMethod);
367+
const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
368+
if (!hasBodyMethod || !hasGroups) return null;
369+
370+
const groupedParamNames = new Set<string>();
371+
for (const group of op.parameterGroups ?? []) {
372+
for (const variant of group.variants) {
373+
for (const p of variant.parameters) groupedParamNames.add(p.name);
374+
}
375+
}
376+
377+
const entries: string[] = [];
378+
379+
// Required non-group body fields, keyed by wire name.
380+
if (op.requestBody) {
381+
const bodyModel = resolveBodyModel(op.requestBody, modelByName);
382+
if (bodyModel) {
383+
for (const f of bodyModel.fields) {
384+
if (!f.required) continue;
385+
if (hiddenParams.has(f.name)) continue;
386+
if (groupedParamNames.has(f.name)) continue;
387+
entries.push(`"${f.name}" => ${stubValueFor(f.type)}`);
388+
}
389+
}
390+
}
391+
392+
// First variant of each group: its leaves get pumped into the body.
393+
for (const group of op.parameterGroups ?? []) {
394+
const firstVariant = group.variants[0];
395+
if (!firstVariant) continue;
396+
for (const p of firstVariant.parameters) {
397+
entries.push(`"${p.name}" => ${stubValueFor(p.type)}`);
398+
}
399+
}
400+
401+
if (entries.length === 0) return null;
402+
return `hash_including(${entries.join(', ')})`;
403+
}
404+
351405
function resolveBodyModel(ref: TypeRef, modelByName: Map<string, Model>): Model | null {
352406
if (ref.kind === 'model') return modelByName.get(ref.name) ?? null;
353407
if (ref.kind === 'nullable') return resolveBodyModel(ref.inner, modelByName);

0 commit comments

Comments
 (0)