@workos/oagen-emitters 0.12.1 → 0.12.3

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.
Files changed (45) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint-pr-title.yml +1 -1
  3. package/.github/workflows/lint.yml +1 -1
  4. package/.github/workflows/release-please.yml +2 -2
  5. package/.github/workflows/release.yml +1 -1
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +14 -0
  9. package/dist/index.d.mts.map +1 -1
  10. package/dist/index.mjs +1 -1
  11. package/dist/{plugin-CmfzawTp.mjs → plugin-D2N2ZT5W.mjs} +2566 -1493
  12. package/dist/plugin-D2N2ZT5W.mjs.map +1 -0
  13. package/dist/plugin.mjs +1 -1
  14. package/package.json +6 -6
  15. package/renovate.json +46 -6
  16. package/src/node/client.ts +19 -32
  17. package/src/node/enums.ts +67 -30
  18. package/src/node/errors.ts +2 -8
  19. package/src/node/field-plan.ts +188 -52
  20. package/src/node/fixtures.ts +11 -33
  21. package/src/node/index.ts +354 -20
  22. package/src/node/live-surface.ts +378 -0
  23. package/src/node/models.ts +547 -351
  24. package/src/node/naming.ts +122 -25
  25. package/src/node/node-overrides.ts +77 -0
  26. package/src/node/options.ts +41 -0
  27. package/src/node/path-expression.ts +11 -4
  28. package/src/node/resources.ts +473 -48
  29. package/src/node/sdk-errors.ts +0 -16
  30. package/src/node/tests.ts +152 -93
  31. package/src/node/type-map.ts +40 -18
  32. package/src/node/utils.ts +89 -102
  33. package/src/node/wrappers.ts +0 -20
  34. package/test/node/client.test.ts +106 -1201
  35. package/test/node/enums.test.ts +59 -130
  36. package/test/node/errors.test.ts +2 -3
  37. package/test/node/live-surface.test.ts +240 -0
  38. package/test/node/models.test.ts +396 -765
  39. package/test/node/naming.test.ts +69 -234
  40. package/test/node/resources.test.ts +435 -2025
  41. package/test/node/tests.test.ts +214 -0
  42. package/test/node/type-map.test.ts +49 -54
  43. package/test/node/utils.test.ts +29 -80
  44. package/dist/plugin-CmfzawTp.mjs.map +0 -1
  45. package/test/node/serializers.test.ts +0 -444
@@ -1,18 +1,9 @@
1
1
  import type { SdkBehavior } from '@workos/oagen';
2
2
 
3
- /**
4
- * Node-specific overrides for exception kind names.
5
- *
6
- * The IR `statusCodeMap` uses canonical kind names (e.g. 'Authentication'),
7
- * but the Node SDK historically uses different names for some status codes.
8
- * This map translates the IR kind name to the Node-specific name before
9
- * appending the 'Exception' suffix.
10
- */
11
3
  const NODE_EXCEPTION_KIND_OVERRIDES: Record<string, string> = {
12
4
  Authentication: 'Unauthorized',
13
5
  };
14
6
 
15
- /** Fallback status code map when no SDK behavior is provided. */
16
7
  const DEFAULT_STATUS_CODE_MAP: Record<string, string> = {
17
8
  '400': 'BadRequest',
18
9
  '401': 'Authentication',
@@ -23,13 +14,6 @@ const DEFAULT_STATUS_CODE_MAP: Record<string, string> = {
23
14
  '429': 'RateLimitExceeded',
24
15
  };
25
16
 
26
- /**
27
- * Build the status-code-to-exception-class-name map from SDK behavior,
28
- * applying Node-specific naming overrides.
29
- *
30
- * Example: IR `401: 'Authentication'` becomes `401: 'UnauthorizedException'`
31
- * because Node uses `UnauthorizedException` instead of `AuthenticationException`.
32
- */
33
17
  export function buildNodeStatusExceptions(sdk?: SdkBehavior): Record<number, string> {
34
18
  const statusCodeMap = sdk?.errors?.statusCodeMap ?? DEFAULT_STATUS_CODE_MAP;
35
19
  return Object.fromEntries(
package/src/node/tests.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
1
3
  import type { ApiSpec, Service, Operation, Model, TypeRef, EmitterContext, GeneratedFile } from '@workos/oagen';
2
4
  import { planOperation, toCamelCase } from '@workos/oagen';
3
5
  import { unwrapListModel, ID_PREFIXES } from './fixtures.js';
@@ -12,7 +14,7 @@ import {
12
14
  wireInterfaceName,
13
15
  } from './naming.js';
14
16
  import { generateFixtures } from './fixtures.js';
15
- import { resolveResourceClassName } from './resources.js';
17
+ import { resolveResourceClassName, resolveResourceDir } from './resources.js';
16
18
  import {
17
19
  assignModelsToServices,
18
20
  createServiceDirResolver,
@@ -24,6 +26,30 @@ import {
24
26
  computeNonEventReachable,
25
27
  } from './utils.js';
26
28
  import { groupByMount } from '../shared/resolved-ops.js';
29
+ import { isNodeOwnedService } from './options.js';
30
+
31
+ type BaselineMethod = {
32
+ params: Array<{ name: string; type: string; optional?: boolean; passingStyle?: string }>;
33
+ returnType?: string;
34
+ };
35
+
36
+ function baselineMethodFor(service: Service, method: string, ctx: EmitterContext): BaselineMethod | undefined {
37
+ const serviceClass = resolveResourceClassName(service, ctx);
38
+ return ctx.apiSurface?.classes?.[serviceClass]?.methods?.[method]?.[0] as BaselineMethod | undefined;
39
+ }
40
+
41
+ function optionsObjectParam(method: BaselineMethod | undefined): { name: string; type: string } | undefined {
42
+ if (!method || method.params.length !== 1) return undefined;
43
+ const [param] = method.params;
44
+ if (param.name !== 'options') return undefined;
45
+ if (param.passingStyle && param.passingStyle !== 'options_object') return undefined;
46
+ if (!param.type || /^(Record|object|any|unknown)\b/.test(param.type)) return undefined;
47
+ return { name: param.name, type: param.type };
48
+ }
49
+
50
+ function autoPaginatableItemType(returnType: string | undefined): string | undefined {
51
+ return returnType?.match(/\bAutoPaginatable<\s*([A-Za-z_$][\w$]*)/)?.[1];
52
+ }
27
53
 
28
54
  export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
29
55
  const files: GeneratedFile[] = [];
@@ -76,13 +102,23 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
76
102
  for (const { name: mountName, operations } of testEntries) {
77
103
  if (operations.length === 0) continue;
78
104
  const mergedService: Service = { name: mountName, operations };
79
- const ops = uncoveredOperations(mergedService, ctx);
105
+ const isOwnedService = isNodeOwnedService(ctx, mountName, resolveResourceClassName(mergedService, ctx));
106
+ const ops = isOwnedService ? operations : uncoveredOperations(mergedService, ctx);
80
107
  if (ops.length === 0) continue;
81
108
 
82
109
  // Skip tests for services without a WorkOS property in the baseline
83
110
  const propName = mountAccessors.get(mountName) ?? servicePropertyName(mountName);
84
111
  if (ctx.apiSurface && baselineWorkOSProps.size > 0 && !baselineWorkOSProps.has(propName)) continue;
85
112
 
113
+ // Skip when the resource class diverges from the mount accessor — this
114
+ // happens for services with constructor-incompatible baselines (e.g.
115
+ // hand-written `Webhooks(crypto)` forks the emitted ops onto
116
+ // `WebhooksEndpoints`). The generated test would do
117
+ // `workos.<propName>.fooMethod(...)`, but those methods live on a
118
+ // different class, so the test would fail to compile.
119
+ const resourceClass = resolveResourceClassName(mergedService, ctx);
120
+ if (resourceClass !== mountName) continue;
121
+
86
122
  const testService = ops.length < operations.length ? { ...mergedService, operations: ops } : mergedService;
87
123
  files.push(generateServiceTest(testService, spec, ctx, modelMap, mountAccessors));
88
124
  }
@@ -104,7 +140,7 @@ function generateServiceTest(
104
140
  mountAccessors?: Map<string, string>,
105
141
  ): GeneratedFile {
106
142
  const resolvedName = resolveResourceClassName(service, ctx);
107
- const serviceDir = resolveServiceDir(resolvedName);
143
+ const serviceDir = resolveResourceDir(service, ctx);
108
144
  const serviceClass = resolvedName;
109
145
  const serviceProp = mountAccessors?.get(service.name) ?? servicePropertyName(resolvedName);
110
146
  const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
@@ -148,11 +184,6 @@ function generateServiceTest(
148
184
  const testUtils = ['fetchOnce', 'fetchURL', 'fetchMethod'];
149
185
  if (hasPaginated) testUtils.push('fetchSearchParams');
150
186
  if (hasBody) testUtils.push('fetchBody');
151
- // Import shared test helpers for error and pagination tests
152
- if (hasPaginated) testUtils.push('testEmptyResults', 'testPaginationParams');
153
- // Only import testUnauthorized when at least one operation has a response model or is paginated
154
- const hasErrorTests = plans.some((p) => p.plan.responseModelName || p.plan.isPaginated);
155
- if (hasErrorTests) testUtils.push('testUnauthorized');
156
187
  lines.push('import {');
157
188
  for (const util of testUtils) {
158
189
  lines.push(` ${util},`);
@@ -215,24 +246,20 @@ function generateServiceTest(
215
246
  lines.push(' beforeEach(() => fetch.resetMocks());');
216
247
 
217
248
  for (const { op, plan, method } of plans) {
249
+ const existingMethod = baselineMethodFor(service, method, ctx);
218
250
  lines.push('');
219
251
  lines.push(` describe('${method}', () => {`);
220
252
 
221
253
  if (plan.isPaginated) {
222
- renderPaginatedTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames);
254
+ renderPaginatedTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames, existingMethod);
223
255
  } else if (plan.isDelete) {
224
- renderDeleteTest(lines, op, plan, method, serviceProp, modelMap);
256
+ renderDeleteTest(lines, op, plan, method, serviceProp, modelMap, ctx, existingMethod);
225
257
  } else if (plan.hasBody && plan.responseModelName) {
226
- renderBodyTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames);
258
+ renderBodyTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames, existingMethod);
227
259
  } else if (plan.responseModelName) {
228
- renderGetTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames);
260
+ renderGetTest(lines, op, plan, method, serviceProp, modelMap, ctx, entityHelperNames, existingMethod);
229
261
  } else {
230
- renderVoidTest(lines, op, plan, method, serviceProp, modelMap);
231
- }
232
-
233
- // Error case test for all non-void operations
234
- if (plan.responseModelName || plan.isPaginated) {
235
- renderErrorTest(lines, op, plan, method, serviceProp, modelMap);
262
+ renderVoidTest(lines, op, plan, method, serviceProp, modelMap, ctx, existingMethod);
236
263
  }
237
264
 
238
265
  lines.push(' });');
@@ -281,6 +308,7 @@ function renderPaginatedTest(
281
308
  modelMap: Map<string, Model>,
282
309
  ctx?: EmitterContext,
283
310
  entityHelpers?: Set<string>,
311
+ baselineMethod?: BaselineMethod,
284
312
  ): void {
285
313
  let itemModelName = op.pagination?.itemType.kind === 'model' ? op.pagination.itemType.name : 'Item';
286
314
  // Unwrap list wrapper models to match the fixture file naming in fixtures.ts
@@ -292,11 +320,15 @@ function renderPaginatedTest(
292
320
  }
293
321
  }
294
322
  const pathArgs = buildTestPathArgs(op);
323
+ const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
324
+ const baselineItemType = autoPaginatableItemType(baselineMethod?.returnType);
325
+ const generatedItemType = ctx ? resolveInterfaceName(itemModelName, ctx) : null;
326
+ const skipFieldAssertions = Boolean(baselineItemType && generatedItemType && baselineItemType !== generatedItemType);
295
327
 
296
328
  lines.push(" it('returns paginated results', async () => {");
297
329
  lines.push(` fetchOnce(list${itemModelName}Fixture);`);
298
330
  lines.push('');
299
- lines.push(` const { data, listMetadata } = await workos.${serviceProp}.${method}(${pathArgs});`);
331
+ lines.push(` const { data, listMetadata } = await workos.${serviceProp}.${method}(${optionsArg ?? pathArgs});`);
300
332
  lines.push('');
301
333
  lines.push(" expect(fetchMethod()).toBe('GET');");
302
334
  // Fix #12: Full URL path assertion instead of toContain()
@@ -308,7 +340,9 @@ function renderPaginatedTest(
308
340
 
309
341
  // Assert on first item fields — use entity helper if available
310
342
  const paginatedHelperName = ctx ? `expect${resolveInterfaceName(itemModelName, ctx)}` : null;
311
- if (paginatedHelperName && entityHelpers?.has(paginatedHelperName)) {
343
+ if (skipFieldAssertions) {
344
+ lines.push(' expect(data.length).toBeGreaterThan(0);');
345
+ } else if (paginatedHelperName && entityHelpers?.has(paginatedHelperName)) {
312
346
  lines.push(' expect(data.length).toBeGreaterThan(0);');
313
347
  lines.push(` ${paginatedHelperName}(data[0]);`);
314
348
  } else {
@@ -325,17 +359,6 @@ function renderPaginatedTest(
325
359
  }
326
360
 
327
361
  lines.push(' });');
328
-
329
- // Edge case: handles empty results — use shared helper
330
- lines.push('');
331
- lines.push(` testEmptyResults(() => workos.${serviceProp}.${method}(${pathArgs}));`);
332
-
333
- // Edge case: forwards pagination params — use shared helper
334
- lines.push('');
335
- lines.push(` testPaginationParams(`);
336
- lines.push(` (opts) => workos.${serviceProp}.${method}(${pathArgs ? pathArgs + ', ' : ''}opts),`);
337
- lines.push(` list${itemModelName}Fixture,`);
338
- lines.push(' );');
339
362
  }
340
363
 
341
364
  function renderDeleteTest(
@@ -345,12 +368,15 @@ function renderDeleteTest(
345
368
  method: string,
346
369
  serviceProp: string,
347
370
  modelMap: Map<string, Model>,
371
+ ctx?: EmitterContext,
372
+ baselineMethod?: BaselineMethod,
348
373
  ): void {
349
374
  const pathArgs = buildTestPathArgs(op);
350
375
  // Build realistic payload for body-bearing delete operations
351
376
  const payload = plan.hasBody ? buildTestPayload(op, modelMap) : null;
352
377
  const bodyArg = plan.hasBody ? (payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap)) : '';
353
- const args = plan.hasBody ? (pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg) : pathArgs;
378
+ const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
379
+ const args = optionsArg ?? (plan.hasBody ? (pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg) : pathArgs);
354
380
 
355
381
  lines.push(" it('sends a DELETE request', async () => {");
356
382
  lines.push(' fetchOnce({}, { status: 204 });');
@@ -380,6 +406,7 @@ function renderBodyTest(
380
406
  modelMap: Map<string, Model>,
381
407
  ctx?: EmitterContext,
382
408
  entityHelpers?: Set<string>,
409
+ baselineMethod?: BaselineMethod,
383
410
  ): void {
384
411
  const responseModelName = plan.responseModelName!;
385
412
  const fixture = `${toCamelCase(responseModelName)}Fixture`;
@@ -388,10 +415,15 @@ function renderBodyTest(
388
415
  // Build realistic payload from request body model fields
389
416
  const payload = buildTestPayload(op, modelMap);
390
417
  const payloadArg = payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap);
391
- const allArgs = pathArgs ? `${pathArgs}, ${payloadArg}` : payloadArg;
418
+ const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
419
+ const allArgs = optionsArg ?? (pathArgs ? `${pathArgs}, ${payloadArg}` : payloadArg);
420
+
421
+ const isArrayResponse = !!plan.isArrayResponse;
422
+ const fixtureExpr = isArrayResponse ? `[${fixture}]` : fixture;
423
+ const accessor = isArrayResponse ? 'result[0]' : 'result';
392
424
 
393
425
  lines.push(" it('sends the correct request and returns result', async () => {");
394
- lines.push(` fetchOnce(${fixture});`);
426
+ lines.push(` fetchOnce(${fixtureExpr});`);
395
427
  lines.push('');
396
428
  lines.push(` const result = await workos.${serviceProp}.${method}(${allArgs});`);
397
429
  lines.push('');
@@ -408,14 +440,18 @@ function renderBodyTest(
408
440
  lines.push(' expect(fetchBody()).toBeDefined();');
409
441
  }
410
442
 
443
+ if (isArrayResponse) {
444
+ lines.push(' expect(Array.isArray(result)).toBe(true);');
445
+ }
446
+
411
447
  // Use entity helper if available, otherwise inline assertions
412
448
  const bodyHelperName = ctx ? `expect${resolveInterfaceName(responseModelName, ctx)}` : null;
413
449
  if (bodyHelperName && entityHelpers?.has(bodyHelperName)) {
414
- lines.push(` ${bodyHelperName}(result);`);
450
+ lines.push(` ${bodyHelperName}(${accessor});`);
415
451
  } else {
416
452
  const responseModel = modelMap.get(responseModelName);
417
453
  if (responseModel) {
418
- const assertions = buildFieldAssertions(responseModel, 'result', modelMap);
454
+ const assertions = buildFieldAssertions(responseModel, accessor, modelMap);
419
455
  if (assertions.length > 0) {
420
456
  for (const assertion of assertions) {
421
457
  lines.push(` ${assertion}`);
@@ -440,29 +476,38 @@ function renderGetTest(
440
476
  modelMap: Map<string, Model>,
441
477
  ctx?: EmitterContext,
442
478
  entityHelpers?: Set<string>,
479
+ baselineMethod?: BaselineMethod,
443
480
  ): void {
444
481
  const responseModelName = plan.responseModelName!;
445
482
  const fixture = `${toCamelCase(responseModelName)}Fixture`;
446
483
  const pathArgs = buildTestPathArgs(op);
484
+ const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
485
+
486
+ const isArrayResponse = !!plan.isArrayResponse;
487
+ const fixtureExpr = isArrayResponse ? `[${fixture}]` : fixture;
488
+ const accessor = isArrayResponse ? 'result[0]' : 'result';
447
489
 
448
490
  lines.push(" it('returns the expected result', async () => {");
449
- lines.push(` fetchOnce(${fixture});`);
491
+ lines.push(` fetchOnce(${fixtureExpr});`);
450
492
  lines.push('');
451
- lines.push(` const result = await workos.${serviceProp}.${method}(${pathArgs});`);
493
+ lines.push(` const result = await workos.${serviceProp}.${method}(${optionsArg ?? pathArgs});`);
452
494
  lines.push('');
453
495
  lines.push(` expect(fetchMethod()).toBe('${op.httpMethod.toUpperCase()}');`);
454
496
  // Fix #12: Full URL path assertion instead of toContain()
455
497
  const expectedPathGet = buildExpectedPath(op);
456
498
  lines.push(` expect(new URL(String(fetchURL())).pathname).toBe('${expectedPathGet}');`);
499
+ if (isArrayResponse) {
500
+ lines.push(' expect(Array.isArray(result)).toBe(true);');
501
+ }
457
502
 
458
503
  // Use entity helper if available, otherwise inline assertions
459
504
  const helperName = ctx ? `expect${resolveInterfaceName(responseModelName, ctx)}` : null;
460
505
  if (helperName && entityHelpers?.has(helperName)) {
461
- lines.push(` ${helperName}(result);`);
506
+ lines.push(` ${helperName}(${accessor});`);
462
507
  } else {
463
508
  const responseModel = modelMap.get(responseModelName);
464
509
  if (responseModel) {
465
- const assertions = buildFieldAssertions(responseModel, 'result', modelMap);
510
+ const assertions = buildFieldAssertions(responseModel, accessor, modelMap);
466
511
  if (assertions.length > 0) {
467
512
  for (const assertion of assertions) {
468
513
  lines.push(` ${assertion}`);
@@ -485,12 +530,15 @@ function renderVoidTest(
485
530
  method: string,
486
531
  serviceProp: string,
487
532
  modelMap: Map<string, Model>,
533
+ ctx?: EmitterContext,
534
+ baselineMethod?: BaselineMethod,
488
535
  ): void {
489
536
  const pathArgs = buildTestPathArgs(op);
490
537
  // Build realistic payload for body-bearing void operations
491
538
  const payload = plan.hasBody ? buildTestPayload(op, modelMap) : null;
492
539
  const bodyArg = plan.hasBody ? (payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap)) : '';
493
- const args = plan.hasBody ? (pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg) : pathArgs;
540
+ const optionsArg = buildOptionsObjectTestArg(op, plan, baselineMethod, modelMap, ctx);
541
+ const args = optionsArg ?? (plan.hasBody ? (pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg) : pathArgs);
494
542
 
495
543
  lines.push(" it('sends the request', async () => {");
496
544
  lines.push(' fetchOnce({});');
@@ -507,57 +555,44 @@ function renderVoidTest(
507
555
  lines.push(' });');
508
556
  }
509
557
 
510
- function renderErrorTest(
511
- lines: string[],
558
+ function buildOptionsObjectTestArg(
512
559
  op: Operation,
513
560
  plan: any,
514
- method: string,
515
- serviceProp: string,
561
+ baselineMethod: BaselineMethod | undefined,
516
562
  modelMap: Map<string, Model>,
517
- ): void {
518
- const args = buildCallArgs(op, plan, modelMap);
519
-
520
- lines.push('');
521
- lines.push(` testUnauthorized(() => workos.${serviceProp}.${method}(${args}));`);
522
-
523
- // Add error-status tests based on the operation's error responses
524
- const errorStatuses = new Set(op.errors.map((e) => e.statusCode));
563
+ ctx?: EmitterContext,
564
+ ): string | null {
565
+ const optionParam = optionsObjectParam(baselineMethod);
566
+ if (!optionParam) return null;
525
567
 
526
- // 404 test for find/get methods
527
- if (errorStatuses.has(404) && (method.startsWith('get') || method.startsWith('find'))) {
528
- lines.push('');
529
- lines.push(" it('throws NotFoundException on 404', async () => {");
530
- lines.push(" fetchOnce('', { status: 404 });");
531
- lines.push(` await expect(workos.${serviceProp}.${method}(${args})).rejects.toThrow();`);
532
- lines.push(' });');
568
+ const entries: string[] = [];
569
+ for (const param of op.pathParams) {
570
+ const localName = fieldName(param.name);
571
+ const optionField = resolveOptionsObjectField(localName, optionParam.type, ctx);
572
+ entries.push(`${optionField}: ${JSON.stringify(pathParamTestValue(param, localName))}`);
533
573
  }
534
574
 
535
- // 422 test for create/update methods
536
- if (errorStatuses.has(422) && (method.startsWith('create') || method.startsWith('update'))) {
537
- lines.push('');
538
- lines.push(" it('throws UnprocessableEntityException on 422', async () => {");
539
- lines.push(" fetchOnce('', { status: 422 });");
540
- lines.push(` await expect(workos.${serviceProp}.${method}(${args})).rejects.toThrow();`);
541
- lines.push(' });');
575
+ if (plan.hasBody) {
576
+ const payload = buildTestPayload(op, modelMap);
577
+ if (payload) entries.push(...objectLiteralEntries(payload.camelCaseObj));
542
578
  }
579
+
580
+ return `{ ${entries.join(', ')} }`;
543
581
  }
544
582
 
545
- /**
546
- * Build the argument string for a method call in tests.
547
- * Shared by renderErrorTest and other test renderers.
548
- */
549
- function buildCallArgs(op: Operation, plan: any, modelMap: Map<string, Model>): string {
550
- const pathArgs = buildTestPathArgs(op);
551
- const isPaginated = plan.isPaginated;
552
- const hasBody = plan.hasBody;
583
+ function objectLiteralEntries(literal: string): string[] {
584
+ const trimmed = literal.trim();
585
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return [];
586
+ const body = trimmed.slice(1, -1).trim();
587
+ return body ? body.split(',').map((entry) => entry.trim()) : [];
588
+ }
553
589
 
554
- if (isPaginated) return pathArgs || '';
555
- if (hasBody) {
556
- const payload = buildTestPayload(op, modelMap);
557
- const bodyArg = payload ? payload.camelCaseObj : fallbackBodyArg(op, modelMap);
558
- return pathArgs ? `${pathArgs}, ${bodyArg}` : bodyArg;
559
- }
560
- return pathArgs || '';
590
+ function resolveOptionsObjectField(localName: string, optionType: string, ctx?: EmitterContext): string {
591
+ const fields = ctx?.apiSurface?.interfaces?.[optionType]?.fields;
592
+ if (!fields) return localName;
593
+ if (fields[localName]) return localName;
594
+ if (localName === 'omId' && fields.organizationMembershipId) return 'organizationMembershipId';
595
+ return localName;
561
596
  }
562
597
 
563
598
  /**
@@ -625,27 +660,37 @@ function generateEntityHelpers(
625
660
  * nested models so we still get meaningful assertions instead of a bare
626
661
  * `toBeDefined()`.
627
662
  */
663
+ function isDateTimeFieldType(type: TypeRef): boolean {
664
+ if (type.kind === 'primitive') return type.format === 'date-time';
665
+ if (type.kind === 'nullable') return isDateTimeFieldType(type.inner);
666
+ return false;
667
+ }
668
+
628
669
  function buildFieldAssertions(model: Model, accessor: string, modelMap?: Map<string, Model>): string[] {
629
670
  const assertions: string[] = [];
630
671
 
631
672
  for (const field of model.fields) {
632
673
  if (!field.required) continue;
674
+ const domainField = fieldName(field.name);
675
+ // `string` + `format: 'date-time'` is deserialized to `Date` by the
676
+ // serializer (see `mapPrimitive` in type-map.ts). Asserting against a
677
+ // string literal would fail Object.is — compare via `.toISOString()`.
678
+ const isDateTime = isDateTimeFieldType(field.type);
679
+ const fieldAccessor = isDateTime ? `${accessor}.${domainField}.toISOString()` : `${accessor}.${domainField}`;
633
680
  // When a field has an example value, use it as the expected assertion value
634
681
  if (field.example !== undefined) {
635
- const domainField = fieldName(field.name);
636
682
  if (typeof field.example === 'object' && field.example !== null) {
637
683
  // Objects and arrays need toEqual with JSON serialization
638
684
  assertions.push(`expect(${accessor}.${domainField}).toEqual(${JSON.stringify(field.example)});`);
639
685
  } else {
640
686
  const exampleLiteral = typeof field.example === 'string' ? `'${field.example}'` : String(field.example);
641
- assertions.push(`expect(${accessor}.${domainField}).toBe(${exampleLiteral});`);
687
+ assertions.push(`expect(${fieldAccessor}).toBe(${exampleLiteral});`);
642
688
  }
643
689
  continue;
644
690
  }
645
691
  const value = fixtureValueForType(field.type, field.name, model.name);
646
692
  if (value === null) continue;
647
- const domainField = fieldName(field.name);
648
- assertions.push(`expect(${accessor}.${domainField}).toBe(${value});`);
693
+ assertions.push(`expect(${fieldAccessor}).toBe(${value});`);
649
694
  }
650
695
 
651
696
  // When no primitive assertions were found (e.g. wrapper types like
@@ -848,6 +893,12 @@ function modelNeedsRoundTripTest(model: Model): boolean {
848
893
  return model.fields.length > 0;
849
894
  }
850
895
 
896
+ function fixtureIsHandOwned(fixturePath: string, ctx: EmitterContext): boolean {
897
+ const root = ctx.outputDir ?? ctx.targetDir;
898
+ if (!root) return false;
899
+ return fs.existsSync(path.join(root, fixturePath));
900
+ }
901
+
851
902
  /**
852
903
  * Generate serializer round-trip tests for models that have both serialize and
853
904
  * deserialize functions and have nested types requiring non-trivial serialization.
@@ -867,14 +918,17 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
867
918
  // Skip models unchanged from baseline (no new fields) since their serializers are not regenerated.
868
919
  // Skip models unreachable from non-event services (no model/serializer files generated).
869
920
  const nonEventReachable = computeNonEventReachable(spec.services, spec.models);
870
- const eligibleModels = spec.models.filter(
871
- (m) =>
921
+ const generatedSerializerModels = (ctx as any)._generatedSerializerModels as Set<string> | undefined;
922
+ const eligibleModels = spec.models.filter((m) => {
923
+ const service = modelToService.get(m.name);
924
+ return (
872
925
  nonEventReachable.has(m.name) &&
873
926
  modelNeedsRoundTripTest(m) &&
874
927
  !isListMetadataModel(m) &&
875
928
  !isListWrapperModel(m) &&
876
- modelHasNewFields(m, ctx),
877
- );
929
+ (generatedSerializerModels?.has(m.name) ?? (modelHasNewFields(m, ctx) || isNodeOwnedService(ctx, service)))
930
+ );
931
+ });
878
932
 
879
933
  if (eligibleModels.length === 0) return files;
880
934
 
@@ -887,6 +941,8 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
887
941
  for (const model of eligibleModels) {
888
942
  const service = modelToService.get(model.name);
889
943
  const dirName = resolveDir(service);
944
+ const fixturePath = `src/${dirName}/fixtures/${fileName(model.name)}.json`;
945
+ if (!fixtureIsHandOwned(fixturePath, ctx)) continue;
890
946
  if (!modelsByDir.has(dirName)) {
891
947
  modelsByDir.set(dirName, []);
892
948
  }
@@ -901,6 +957,7 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
901
957
  const serializerImports: string[] = [];
902
958
  const interfaceImports: string[] = [];
903
959
  const fixtureImports: string[] = [];
960
+ const deserializeOnlyModels = new Set<string>();
904
961
 
905
962
  for (const model of models) {
906
963
  const domainName = resolveInterfaceName(model.name, ctx);
@@ -909,8 +966,10 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
909
966
  const serializerPath = `src/${modelDir}/serializers/${fileName(model.name)}.serializer.ts`;
910
967
  const interfacePath = `src/${modelDir}/interfaces/${fileName(model.name)}.interface.ts`;
911
968
  const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.json`;
969
+ const deserializeOnly = serializeSkipped.has(model.name) || fixtureIsHandOwned(fixturePath, ctx);
970
+ if (deserializeOnly) deserializeOnlyModels.add(model.name);
912
971
 
913
- if (serializeSkipped.has(model.name)) {
972
+ if (deserializeOnly) {
914
973
  serializerImports.push(
915
974
  `import { deserialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
916
975
  );
@@ -941,8 +1000,8 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
941
1000
  const fixtureName = `${toCamelCase(domainName)}Fixture`;
942
1001
  const wireName = wireInterfaceName(domainName);
943
1002
 
944
- if (serializeSkipped.has(model.name)) {
945
- // Deserialize-only test (no serialize function available)
1003
+ if (deserializeOnlyModels.has(model.name)) {
1004
+ // Deserialize-only test for hand-owned fixtures or models without a serializer.
946
1005
  lines.push(`describe('${domainName}Serializer', () => {`);
947
1006
  lines.push(" it('deserializes correctly', () => {");
948
1007
  lines.push(` const fixture = ${fixtureName} as ${wireName};`);
@@ -3,24 +3,56 @@ import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
3
3
  import { wireInterfaceName } from './naming.js';
4
4
 
5
5
  export interface MapTypeRefOpts {
6
- /** Map from model name → default type args (e.g., `'<Record<string, unknown>>'`).
7
- * When present, model refs for generic models get their defaults appended. */
8
6
  genericDefaults?: Map<string, string>;
9
7
  }
10
8
 
9
+ /**
10
+ * Map of enum name → inlined string-union TS source.
11
+ *
12
+ * Set by `index.ts` once per generation run, sourced from `spec.enums` for
13
+ * enums that have no baseline definition in the live SDK. When populated,
14
+ * `mapTypeRef`/`mapWireTypeRef` substitute the union directly at the
15
+ * reference site instead of emitting a separate import — this collapses
16
+ * ~100 single-line enum files into inline literal types.
17
+ */
18
+ let inlineEnumUnions: Map<string, string> = new Map();
19
+ export function setInlineEnumUnions(map: Map<string, string>): void {
20
+ inlineEnumUnions = map;
21
+ }
22
+ export function isInlineEnum(name: string): boolean {
23
+ return inlineEnumUnions.has(name);
24
+ }
25
+
26
+ /**
27
+ * Optional callback that resolves an IR model name to its live-SDK interface
28
+ * name. Set by `index.ts` once per run. When present, `mapTypeRef` and
29
+ * `mapWireTypeRef` use it instead of the raw IR name in their `model:` cases
30
+ * — keeping field-type references in sync with import statements that the
31
+ * caller emits via the same resolver. Without this, a structural match like
32
+ * IR `AuditLogSchemaJson` → live `AuditLogSchemaResponse` would produce
33
+ * `schema: AuditLogSchemaJson` in the body but
34
+ * `import type { AuditLogSchemaResponse }` in the imports, leaving
35
+ * `AuditLogSchemaJson` unbound.
36
+ */
37
+ let domainNameResolver: ((irName: string) => string) | null = null;
38
+ export function setDomainNameResolver(fn: ((irName: string) => string) | null): void {
39
+ domainNameResolver = fn;
40
+ }
41
+ function resolveDomainName(irName: string): string {
42
+ return domainNameResolver ? domainNameResolver(irName) : irName;
43
+ }
44
+
11
45
  /**
12
46
  * Map an IR TypeRef to a TypeScript domain type string.
13
47
  * Domain types use PascalCase model names (e.g., `Organization`).
14
- *
15
- * @param opts.genericDefaults - When present, appends default type args to generic model refs.
16
48
  */
17
49
  export function mapTypeRef(ref: TypeRef, opts?: MapTypeRefOpts): string {
18
50
  const genericDefaults = opts?.genericDefaults;
19
51
  return irMapTypeRef<string>(ref, {
20
52
  primitive: mapPrimitive,
21
53
  array: (_r, items) => `${parenthesizeUnion(items)}[]`,
22
- model: (r) => r.name + (genericDefaults?.get(r.name) ?? ''),
23
- enum: (r) => r.name,
54
+ model: (r) => resolveDomainName(r.name) + (genericDefaults?.get(r.name) ?? ''),
55
+ enum: (r) => inlineEnumUnions.get(r.name) ?? r.name,
24
56
  union: (r, variants) => joinUnionVariants(r, variants),
25
57
  nullable: (_r, inner) => `${inner} | null`,
26
58
  literal: (r) => (typeof r.value === 'string' ? `'${r.value}'` : String(r.value)),
@@ -31,15 +63,14 @@ export function mapTypeRef(ref: TypeRef, opts?: MapTypeRefOpts): string {
31
63
  /**
32
64
  * Map an IR TypeRef to a TypeScript wire/response type string.
33
65
  * Model references get the `Response` suffix (e.g., `OrganizationResponse`).
34
- * Wire types use JSON-native types (string for date-time, number/string for int64).
35
66
  */
36
67
  export function mapWireTypeRef(ref: TypeRef, opts?: { genericDefaults?: Map<string, string> }): string {
37
68
  const genericDefaults = opts?.genericDefaults;
38
69
  return irMapTypeRef<string>(ref, {
39
70
  primitive: mapWirePrimitive,
40
71
  array: (_r, items) => `${parenthesizeUnion(items)}[]`,
41
- model: (r) => wireInterfaceName(r.name) + (genericDefaults?.get(r.name) ?? ''),
42
- enum: (r) => r.name,
72
+ model: (r) => wireInterfaceName(resolveDomainName(r.name)) + (genericDefaults?.get(r.name) ?? ''),
73
+ enum: (r) => inlineEnumUnions.get(r.name) ?? r.name,
43
74
  union: (r, variants) => joinUnionVariants(r, variants),
44
75
  nullable: (_r, inner) => `${inner} | null`,
45
76
  literal: (r) => (typeof r.value === 'string' ? `'${r.value}'` : String(r.value)),
@@ -69,10 +100,6 @@ function mapPrimitive(ref: PrimitiveType): string {
69
100
  }
70
101
  }
71
102
 
72
- /**
73
- * Map an IR PrimitiveType to a TypeScript wire/JSON type string.
74
- * Wire types match JSON encoding: date-time stays string, int64 stays string/number.
75
- */
76
103
  function mapWirePrimitive(ref: PrimitiveType): string {
77
104
  switch (ref.type) {
78
105
  case 'string':
@@ -87,10 +114,6 @@ function mapWirePrimitive(ref: PrimitiveType): string {
87
114
  }
88
115
  }
89
116
 
90
- /**
91
- * Join union variant type strings using the appropriate operator.
92
- * allOf unions use `&` (intersection), oneOf/anyOf/unspecified use `|` (union).
93
- */
94
117
  function joinUnionVariants(ref: UnionType, variants: string[]): string {
95
118
  const unique = [...new Set(variants)];
96
119
  if (ref.compositionKind === 'allOf') {
@@ -100,7 +123,6 @@ function joinUnionVariants(ref: UnionType, variants: string[]): string {
100
123
  return unique.join(' | ');
101
124
  }
102
125
 
103
- /** Wrap union/intersection types in parentheses when used as array item type. */
104
126
  function parenthesizeUnion(type: string): string {
105
127
  return type.includes(' | ') || type.includes(' & ') ? `(${type})` : type;
106
128
  }