@workos/oagen-emitters 0.12.3 → 0.12.5

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/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-D2N2ZT5W.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-Ca9LUkWW.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.12.3",
3
+ "version": "0.12.5",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -666,6 +666,10 @@ interface SerializerContext {
666
666
  resolveDir: (irService: string | undefined) => string;
667
667
  dedup: Map<string, string>;
668
668
  skippedSerializeModels: Set<string>;
669
+ /** Models reachable from any response — anything outside this set is
670
+ * request-only and won't have a `deserialize<X>` emitted. `undefined`
671
+ * means "no usage info available, assume deserialize exists". */
672
+ responseReachableModels: Set<string> | undefined;
669
673
  ctx: EmitterContext;
670
674
  }
671
675
 
@@ -723,6 +727,10 @@ export function buildSerializerImports(
723
727
  const canon = sctx.dedup.get(dep);
724
728
  const depSkipSerialize =
725
729
  sctx.skippedSerializeModels.has(dep) || (canon != null && sctx.skippedSerializeModels.has(canon));
730
+ const depSkipDeserialize =
731
+ sctx.responseReachableModels !== undefined &&
732
+ !sctx.responseReachableModels.has(dep) &&
733
+ (canon == null || !sctx.responseReachableModels.has(canon));
726
734
 
727
735
  // Decide whether this serializer is reachable at runtime:
728
736
  // - file on disk → honor what it exports (hasDeser/hasSer)
@@ -759,8 +767,11 @@ export function buildSerializerImports(
759
767
  continue;
760
768
  }
761
769
 
770
+ if (depSkipSerialize && depSkipDeserialize) continue;
762
771
  if (depSkipSerialize) {
763
772
  lines.push(`import { deserialize${depName} } from '${rel}';`);
773
+ } else if (depSkipDeserialize) {
774
+ lines.push(`import { serialize${depName} } from '${rel}';`);
764
775
  } else {
765
776
  lines.push(`import { deserialize${depName}, serialize${depName} } from '${rel}';`);
766
777
  }
@@ -821,27 +832,30 @@ export function emitSerializerBody(
821
832
  baselineResponse: BaselineInterface | undefined,
822
833
  skipFormatFields: Set<string>,
823
834
  shouldSkipSerialize: boolean,
835
+ shouldSkipDeserialize: boolean,
824
836
  ctx: EmitterContext,
825
837
  ): string[] {
826
838
  const lines: string[] = [];
827
839
 
828
- const seenDeserFields = new Set<string>();
829
- const deserParamPrefix = model.fields.length === 0 ? '_' : '';
830
- lines.push(`export const deserialize${domainName} = ${typeParams.decl}(`);
831
- lines.push(` ${deserParamPrefix}response: ${responseName}${typeParams.usage},`);
832
- lines.push(`): ${domainName}${typeParams.usage} => ({`);
833
- for (const field of model.fields) {
834
- const domain = fieldName(field.name);
835
- if (seenDeserFields.has(domain)) continue;
836
- seenDeserFields.add(domain);
837
- const plan = planDeserializeField(field, baselineDomain, baselineResponse, skipFormatFields, ctx);
838
- if (!plan.skip) lines.push(plan.line);
840
+ if (!shouldSkipDeserialize) {
841
+ const seenDeserFields = new Set<string>();
842
+ const deserParamPrefix = model.fields.length === 0 ? '_' : '';
843
+ lines.push(`export const deserialize${domainName} = ${typeParams.decl}(`);
844
+ lines.push(` ${deserParamPrefix}response: ${responseName}${typeParams.usage},`);
845
+ lines.push(`): ${domainName}${typeParams.usage} => ({`);
846
+ for (const field of model.fields) {
847
+ const domain = fieldName(field.name);
848
+ if (seenDeserFields.has(domain)) continue;
849
+ seenDeserFields.add(domain);
850
+ const plan = planDeserializeField(field, baselineDomain, baselineResponse, skipFormatFields, ctx);
851
+ if (!plan.skip) lines.push(plan.line);
852
+ }
853
+ lines.push('});');
839
854
  }
840
- lines.push('});');
841
855
 
842
856
  if (!shouldSkipSerialize) {
857
+ if (!shouldSkipDeserialize) lines.push('');
843
858
  const serParamPrefix = model.fields.length === 0 ? '_' : '';
844
- lines.push('');
845
859
  lines.push(`export const serialize${domainName} = ${typeParams.decl}(`);
846
860
  lines.push(` ${serParamPrefix}model: ${domainName}${typeParams.usage},`);
847
861
  lines.push(`): ${responseName}${typeParams.usage} => ({`);
@@ -61,6 +61,10 @@ interface SharedModelContext {
61
61
  interface GeneratedResourceModelUsage {
62
62
  interfaceRoots: Set<string>;
63
63
  serializerRoots: Set<string>;
64
+ /** Models that are directly used as a request body. Drive `serialize<X>`. */
65
+ requestRoots: Set<string>;
66
+ /** Models that are directly used as a response body. Drive `deserialize<X>`. */
67
+ responseRoots: Set<string>;
64
68
  }
65
69
 
66
70
  function buildSharedContext(models: Model[], ctx: EmitterContext): SharedModelContext {
@@ -655,6 +659,14 @@ export function generateSerializers(
655
659
  const serializerEligibleModels = resourceUsage
656
660
  ? expandModelRoots(resourceUsage.serializerRoots, projectedByName)
657
661
  : undefined;
662
+ // Models reachable from any response — only these need a `deserialize<X>`.
663
+ // A model used solely as a request body (e.g. `CreateWebhookEndpoint`)
664
+ // would otherwise emit a deserializer with a partial response shape that
665
+ // silently misbehaves if called. Undefined means "no resource usage info,
666
+ // emit both halves" (standalone generation, smoke tests).
667
+ const responseReachableModels = resourceUsage
668
+ ? expandModelRoots(resourceUsage.responseRoots, projectedByName)
669
+ : undefined;
658
670
 
659
671
  const serializerReachable = computeNonEventReachable(ctx.spec.services, models);
660
672
 
@@ -765,9 +777,19 @@ export function generateSerializers(
765
777
  if (domainName === canonDomainName) continue;
766
778
  const rel = relativeImport(serializerPath, canonSerializerPath);
767
779
  const canonSkipSerialize = skippedSerializeModels.has(canonicalName) || skippedSerializeModels.has(model.name);
768
- const reexportContent = canonSkipSerialize
769
- ? `export { deserialize${canonDomainName} as deserialize${domainName} } from '${rel}';`
770
- : `export { deserialize${canonDomainName} as deserialize${domainName}, serialize${canonDomainName} as serialize${domainName} } from '${rel}';`;
780
+ const canonSkipDeserialize =
781
+ responseReachableModels !== undefined &&
782
+ !responseReachableModels.has(canonicalName) &&
783
+ !responseReachableModels.has(model.name);
784
+ if (canonSkipSerialize && canonSkipDeserialize) continue;
785
+ const parts: string[] = [];
786
+ if (!canonSkipDeserialize) {
787
+ parts.push(`deserialize${canonDomainName} as deserialize${domainName}`);
788
+ }
789
+ if (!canonSkipSerialize) {
790
+ parts.push(`serialize${canonDomainName} as serialize${domainName}`);
791
+ }
792
+ const reexportContent = `export { ${parts.join(', ')} } from '${rel}';`;
771
793
  files.push({
772
794
  path: serializerPath,
773
795
  content: reexportContent,
@@ -787,8 +809,21 @@ export function generateSerializers(
787
809
 
788
810
  const skipFormatFields = buildSkipFormatFields(model, baselineDomain);
789
811
  const shouldSkipSerialize = skippedSerializeModels.has(model.name);
790
-
791
- const sctx = { modelToService, resolveDir, dedup, skippedSerializeModels, ctx };
812
+ // Skip `deserialize<X>` when the model never appears as a response (and
813
+ // we have usage info to verify that `undefined` means "emit both halves
814
+ // conservatively"). Cuts unused, partially-typed deserializers like
815
+ // `deserializeCreateWebhookEndpoint` from request-body-only models.
816
+ const shouldSkipDeserialize = responseReachableModels !== undefined && !responseReachableModels.has(model.name);
817
+ if (shouldSkipSerialize && shouldSkipDeserialize) continue;
818
+
819
+ const sctx = {
820
+ modelToService,
821
+ resolveDir,
822
+ dedup,
823
+ skippedSerializeModels,
824
+ responseReachableModels,
825
+ ctx,
826
+ };
792
827
  const lines = [
793
828
  ...buildSerializerImports(model, serializerPath, dirName, domainName, responseName, sctx),
794
829
  ...emitSerializerBody(
@@ -800,6 +835,7 @@ export function generateSerializers(
800
835
  baselineResponse,
801
836
  skipFormatFields,
802
837
  shouldSkipSerialize,
838
+ shouldSkipDeserialize,
803
839
  ctx,
804
840
  ),
805
841
  ];
@@ -812,6 +848,45 @@ export function generateSerializers(
812
848
  }
813
849
 
814
850
  (ctx as any)._skippedSerializeModels = skippedSerializeModels;
851
+ // Surface the response-reachable set so the serializer-roundtrip test
852
+ // generator can fall back to a deserialize-skipped path for request-only
853
+ // models (where `deserialize<X>` was deliberately not emitted).
854
+ (ctx as any)._responseReachableModels = responseReachableModels;
855
+
856
+ // Emit a `serializers/index.ts` barrel per directory that received serializer
857
+ // files in this pass. Mirrors the per-service `interfaces/index.ts` barrel so
858
+ // consumers can `import { ... } from './serializers'` rather than reaching
859
+ // into individual `.serializer.ts` files. Also includes any pre-existing
860
+ // `*.serializer.ts` files in the same directory (e.g. hand-written option
861
+ // serializers in an owned service) so we don't strand them from the barrel.
862
+ const serializersByDir = new Map<string, Set<string>>();
863
+ for (const f of files) {
864
+ const match = f.path.match(/^src\/([^/]+)\/serializers\/(.+)\.serializer\.ts$/);
865
+ if (!match) continue;
866
+ const [, dir, stem] = match;
867
+ if (!serializersByDir.has(dir)) serializersByDir.set(dir, new Set());
868
+ serializersByDir.get(dir)!.add(stem);
869
+ }
870
+ const liveRootForBarrel = ctx.outputDir ?? ctx.targetDir;
871
+ for (const [dir, stems] of serializersByDir) {
872
+ if (liveRootForBarrel) {
873
+ const serializersDir = path.join(liveRootForBarrel, 'src', dir, 'serializers');
874
+ try {
875
+ for (const entry of fs.readdirSync(serializersDir)) {
876
+ if (!entry.endsWith('.serializer.ts')) continue;
877
+ stems.add(entry.replace(/\.serializer\.ts$/, ''));
878
+ }
879
+ } catch {
880
+ // Directory doesn't exist yet — only this-pass serializers will appear.
881
+ }
882
+ }
883
+ const lines = [...stems].sort().map((stem) => `export * from './${stem}.serializer';`);
884
+ files.push({
885
+ path: `src/${dir}/serializers/index.ts`,
886
+ content: lines.join('\n') + '\n',
887
+ overwriteExisting: true,
888
+ });
889
+ }
815
890
 
816
891
  return files;
817
892
  }
@@ -847,6 +922,8 @@ function buildGeneratedResourceModelUsage(
847
922
  const modelMap = new Map(models.map((model) => [model.name, model]));
848
923
  const interfaceRoots = new Set<string>();
849
924
  const serializerRoots = new Set<string>();
925
+ const requestRoots = new Set<string>();
926
+ const responseRoots = new Set<string>();
850
927
  const resolvedLookup = buildResolvedLookup(ctx);
851
928
  const mountGroups = groupByMount(ctx);
852
929
  const services: Service[] =
@@ -889,16 +966,19 @@ function buildGeneratedResourceModelUsage(
889
966
  if (unwrapped) itemName = unwrapped.name;
890
967
  interfaceRoots.add(itemName);
891
968
  serializerRoots.add(itemName);
969
+ responseRoots.add(itemName);
892
970
  }
893
971
  } else if (plan.responseModelName) {
894
972
  interfaceRoots.add(plan.responseModelName);
895
973
  serializerRoots.add(plan.responseModelName);
974
+ responseRoots.add(plan.responseModelName);
896
975
  }
897
976
 
898
977
  const bodyInfo = extractRequestBodyModels(op, ctx);
899
978
  for (const name of bodyInfo) {
900
979
  interfaceRoots.add(name);
901
980
  serializerRoots.add(name);
981
+ requestRoots.add(name);
902
982
  }
903
983
 
904
984
  for (const param of [...op.pathParams, ...op.queryParams, ...op.headerParams]) {
@@ -910,6 +990,7 @@ function buildGeneratedResourceModelUsage(
910
990
  for (const name of collectWrapperResponseModels(resolved)) {
911
991
  interfaceRoots.add(name);
912
992
  serializerRoots.add(name);
993
+ responseRoots.add(name);
913
994
  }
914
995
  for (const wrapper of resolved.wrappers ?? []) {
915
996
  for (const { field } of resolveWrapperParams(wrapper, ctx)) {
@@ -920,7 +1001,7 @@ function buildGeneratedResourceModelUsage(
920
1001
  }
921
1002
  }
922
1003
 
923
- return { interfaceRoots, serializerRoots };
1004
+ return { interfaceRoots, serializerRoots, requestRoots, responseRoots };
924
1005
  }
925
1006
 
926
1007
  function extractRequestBodyModels(op: Operation, ctx: EmitterContext): string[] {
package/src/node/tests.ts CHANGED
@@ -838,18 +838,33 @@ function buildTestPayload(
838
838
  }
839
839
  if (!model) return null;
840
840
 
841
- const fields = model.fields.filter((f) => f.required);
841
+ const requiredFields = model.fields.filter((f) => f.required);
842
842
  // Only use fields that we can generate deterministic values for (primitives, enums, and nested models)
843
- const usableFields = fields.filter((f) => fixtureValueForType(f.type, f.name, model.name, modelMap) !== null);
844
-
845
- // Only generate a typed payload when ALL required fields have fixture values.
846
- // A partial payload missing required fields would fail TypeScript type checking.
847
- if (usableFields.length === 0 || usableFields.length < fields.length) return null;
843
+ const usableRequired = requiredFields.filter(
844
+ (f) => fixtureValueForType(f.type, f.name, model.name, modelMap) !== null,
845
+ );
846
+
847
+ let chosenFields: typeof model.fields;
848
+ if (requiredFields.length > 0) {
849
+ // Only generate a typed payload when ALL required fields have fixture values.
850
+ // A partial payload missing required fields would fail TypeScript type checking.
851
+ if (usableRequired.length < requiredFields.length) return null;
852
+ chosenFields = usableRequired;
853
+ } else {
854
+ // All-optional model (e.g. PATCH `Update<X>` bodies). Pick the first few
855
+ // optional fields with available fixture values so the test asserts the
856
+ // wire format instead of falling back to `expect(fetchBody()).toBeDefined()`.
857
+ const usableOptional = model.fields.filter(
858
+ (f) => !f.required && fixtureValueForType(f.type, f.name, model.name, modelMap) !== null,
859
+ );
860
+ if (usableOptional.length === 0) return null;
861
+ chosenFields = usableOptional.slice(0, 2);
862
+ }
848
863
 
849
864
  const camelEntries: string[] = [];
850
865
  const snakeEntries: string[] = [];
851
866
 
852
- for (const field of usableFields) {
867
+ for (const field of chosenFields) {
853
868
  const camelValue = fixtureValueForType(field.type, field.name, model.name, modelMap)!;
854
869
  const wireValue = fixtureValueForType(field.type, field.name, model.name, modelMap, true)!;
855
870
  const camelKey = fieldName(field.name);
@@ -935,6 +950,10 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
935
950
  // Use the skipped-serialize set computed by the serializer generator.
936
951
  // It's stashed on the context during generateSerializers.
937
952
  const serializeSkipped: Set<string> = (ctx as any)._skippedSerializeModels ?? new Set<string>();
953
+ // Response-reachable models — anything outside this set is request-only
954
+ // and has no `deserialize<X>` to test. `undefined` means "no usage info,
955
+ // assume deserialize exists" (standalone generation, smoke tests).
956
+ const responseReachableModels: Set<string> | undefined = (ctx as any)._responseReachableModels;
938
957
 
939
958
  // Group eligible models by service directory for one test file per service
940
959
  const modelsByDir = new Map<string, Model[]>();
@@ -958,6 +977,7 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
958
977
  const interfaceImports: string[] = [];
959
978
  const fixtureImports: string[] = [];
960
979
  const deserializeOnlyModels = new Set<string>();
980
+ const serializeOnlyModels = new Set<string>();
961
981
 
962
982
  for (const model of models) {
963
983
  const domainName = resolveInterfaceName(model.name, ctx);
@@ -966,13 +986,24 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
966
986
  const serializerPath = `src/${modelDir}/serializers/${fileName(model.name)}.serializer.ts`;
967
987
  const interfacePath = `src/${modelDir}/interfaces/${fileName(model.name)}.interface.ts`;
968
988
  const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.json`;
969
- const deserializeOnly = serializeSkipped.has(model.name) || fixtureIsHandOwned(fixturePath, ctx);
989
+ // Request-only models (e.g. `CreateWebhookEndpoint`) have no
990
+ // `deserialize<X>` emitted, so they can only be tested via serialize.
991
+ // This check has to come first: a hand-owned fixture would otherwise
992
+ // route through the deserialize-only branch, which then imports a
993
+ // function that doesn't exist.
994
+ const isRequestOnly = responseReachableModels !== undefined && !responseReachableModels.has(model.name);
995
+ const deserializeOnly =
996
+ !isRequestOnly && (serializeSkipped.has(model.name) || fixtureIsHandOwned(fixturePath, ctx));
997
+ const serializeOnly = isRequestOnly;
970
998
  if (deserializeOnly) deserializeOnlyModels.add(model.name);
999
+ if (serializeOnly) serializeOnlyModels.add(model.name);
971
1000
 
972
1001
  if (deserializeOnly) {
973
1002
  serializerImports.push(
974
1003
  `import { deserialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
975
1004
  );
1005
+ } else if (serializeOnly) {
1006
+ serializerImports.push(`import { serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`);
976
1007
  } else {
977
1008
  serializerImports.push(
978
1009
  `import { deserialize${domainName}, serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
@@ -1009,6 +1040,15 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
1009
1040
  lines.push(' expect(deserialized).toBeDefined();');
1010
1041
  lines.push(' });');
1011
1042
  lines.push('});');
1043
+ } else if (serializeOnlyModels.has(model.name)) {
1044
+ // Serialize-only test for request-body-only models without a deserializer.
1045
+ lines.push(`describe('${domainName}Serializer', () => {`);
1046
+ lines.push(" it('serializes correctly', () => {");
1047
+ lines.push(` const fixture = ${fixtureName} as ${wireName};`);
1048
+ lines.push(` const serialized = serialize${domainName}(fixture as any);`);
1049
+ lines.push(' expect(serialized).toBeDefined();');
1050
+ lines.push(' });');
1051
+ lines.push('});');
1012
1052
  } else {
1013
1053
  // Round-trip test
1014
1054
  lines.push(`describe('${domainName}Serializer', () => {`);
@@ -558,4 +558,92 @@ describe('generateSerializers', () => {
558
558
  fs.rmSync(tmpRoot, { recursive: true, force: true });
559
559
  }
560
560
  });
561
+
562
+ it('emits a serializers/index.ts barrel listing every emitted serializer', () => {
563
+ const models: Model[] = [
564
+ {
565
+ name: 'Organization',
566
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
567
+ },
568
+ {
569
+ name: 'OrganizationMember',
570
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
571
+ },
572
+ ];
573
+
574
+ const spec = makeSpec(models);
575
+ const ctxWithModels: EmitterContext = { ...ctx, spec };
576
+ const result = generateSerializers(models, ctxWithModels);
577
+
578
+ const serializerFiles = result.filter((f) => f.path.endsWith('.serializer.ts'));
579
+ expect(serializerFiles.length).toBeGreaterThan(0);
580
+
581
+ // Every emitted serializer file should appear in a barrel at the same
582
+ // directory's `serializers/index.ts`.
583
+ for (const sf of serializerFiles) {
584
+ const match = sf.path.match(/^src\/([^/]+)\/serializers\/(.+)\.serializer\.ts$/);
585
+ expect(match).not.toBeNull();
586
+ const [, dir, stem] = match!;
587
+ const barrel = result.find((f) => f.path === `src/${dir}/serializers/index.ts`);
588
+ expect(barrel, `expected barrel for ${dir}`).toBeDefined();
589
+ expect(barrel!.content).toContain(`export * from './${stem}.serializer';`);
590
+ expect(barrel!.overwriteExisting).toBe(true);
591
+ }
592
+ });
593
+
594
+ it('omits deserialize half for request-body-only models', () => {
595
+ // `CreateOrganization` is sent as a POST body but the operation responds
596
+ // with a separate `Organization` model. The deserializer for the request
597
+ // model would be dead code AND would silently misbehave if called (the
598
+ // response wire shape doesn't match), so it shouldn't be emitted.
599
+ const models: Model[] = [
600
+ {
601
+ name: 'Organization',
602
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
603
+ },
604
+ {
605
+ name: 'CreateOrganization',
606
+ fields: [{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true }],
607
+ },
608
+ ];
609
+
610
+ const spec: ApiSpec = {
611
+ ...emptySpec,
612
+ models,
613
+ services: [
614
+ {
615
+ name: 'Organizations',
616
+ operations: [
617
+ {
618
+ name: 'createOrganization',
619
+ httpMethod: 'post',
620
+ path: '/organizations',
621
+ pathParams: [],
622
+ queryParams: [],
623
+ headerParams: [],
624
+ response: { kind: 'model', name: 'Organization' },
625
+ requestBody: { kind: 'model', name: 'CreateOrganization' },
626
+ errors: [],
627
+ injectIdempotencyKey: false,
628
+ },
629
+ ],
630
+ },
631
+ ],
632
+ };
633
+
634
+ const ctxWithModels: EmitterContext = { ...ctx, spec };
635
+ const result = generateSerializers(models, ctxWithModels);
636
+
637
+ const createSerializer = result.find((f) => f.path.endsWith('create-organization.serializer.ts'));
638
+ expect(createSerializer).toBeDefined();
639
+ expect(createSerializer!.content).toContain('export const serializeCreateOrganization');
640
+ expect(createSerializer!.content).not.toContain('export const deserializeCreateOrganization');
641
+
642
+ // Response-side model still gets both halves.
643
+ const orgSerializer = result.find(
644
+ (f) => f.path.endsWith('organization.serializer.ts') && !f.path.includes('create'),
645
+ );
646
+ expect(orgSerializer).toBeDefined();
647
+ expect(orgSerializer!.content).toContain('export const deserializeOrganization');
648
+ });
561
649
  });
@@ -211,4 +211,61 @@ describe('node test generation ownership', () => {
211
211
  fs.rmSync(tmpRoot, { recursive: true, force: true });
212
212
  }
213
213
  });
214
+
215
+ it('asserts wire format on all-optional request bodies instead of toBeDefined()', () => {
216
+ // For PATCH/Update bodies where every field is optional, the test
217
+ // emitter previously fell back to `expect(fetchBody()).toBeDefined()`,
218
+ // which passes even if the serializer writes the wrong keys. Picking a
219
+ // couple of optional fields with deterministic fixture values makes the
220
+ // test actually validate snake_case conversion on the wire.
221
+ const updateModel: Model = {
222
+ name: 'UpdateGroup',
223
+ fields: [
224
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: false },
225
+ { name: 'description', type: { kind: 'primitive', type: 'string' }, required: false },
226
+ ],
227
+ };
228
+
229
+ const updateOp = {
230
+ name: 'updateGroup',
231
+ httpMethod: 'patch' as const,
232
+ path: '/organizations/{organizationId}/groups/{id}',
233
+ pathParams: [
234
+ { name: 'organizationId', type: { kind: 'primitive' as const, type: 'string' as const }, required: true },
235
+ { name: 'id', type: { kind: 'primitive' as const, type: 'string' as const }, required: true },
236
+ ],
237
+ queryParams: [],
238
+ headerParams: [],
239
+ response: { kind: 'model' as const, name: 'Group' },
240
+ requestBody: { kind: 'model' as const, name: 'UpdateGroup' },
241
+ errors: [],
242
+ injectIdempotencyKey: false,
243
+ };
244
+
245
+ const updateService: Service = { name: 'Groups', operations: [updateOp] };
246
+ const updateSpec: ApiSpec = {
247
+ ...spec,
248
+ models: [groupModel, updateModel],
249
+ services: [updateService],
250
+ };
251
+ const tmpRoot = createTrackedSdkRoot();
252
+ try {
253
+ const result = nodeEmitter.generateTests!(updateSpec, {
254
+ ...ctx,
255
+ spec: updateSpec,
256
+ outputDir: tmpRoot,
257
+ emitterOptions: { ownedServices: ['Groups'], regenerateOwnedTests: true },
258
+ } as EmitterContext);
259
+
260
+ const testFile = result.find((f) => f.path === 'src/groups/groups.spec.ts');
261
+ expect(testFile).toBeDefined();
262
+ const content = testFile!.content;
263
+ // The body assertion picks at least one optional field and checks its
264
+ // snake_case wire format — not just `.toBeDefined()`.
265
+ expect(content).toContain('expect(fetchBody()).toEqual(');
266
+ expect(content).toMatch(/expect\.objectContaining\(\{[^}]*\bname\b/);
267
+ } finally {
268
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
269
+ }
270
+ });
214
271
  });