@workos/oagen-emitters 0.0.1 → 0.2.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.
Files changed (49) hide show
  1. package/.github/workflows/release-please.yml +9 -1
  2. package/.husky/commit-msg +0 -0
  3. package/.husky/pre-commit +1 -0
  4. package/.husky/pre-push +1 -0
  5. package/.oxfmtrc.json +8 -1
  6. package/.prettierignore +1 -0
  7. package/.release-please-manifest.json +3 -0
  8. package/.vscode/settings.json +3 -0
  9. package/CHANGELOG.md +61 -0
  10. package/README.md +2 -2
  11. package/dist/index.d.mts +7 -0
  12. package/dist/index.d.mts.map +1 -0
  13. package/dist/index.mjs +4070 -0
  14. package/dist/index.mjs.map +1 -0
  15. package/package.json +14 -18
  16. package/release-please-config.json +11 -0
  17. package/smoke/sdk-dotnet.ts +17 -3
  18. package/smoke/sdk-elixir.ts +17 -3
  19. package/smoke/sdk-go.ts +21 -4
  20. package/smoke/sdk-kotlin.ts +23 -4
  21. package/smoke/sdk-node.ts +15 -3
  22. package/smoke/sdk-ruby.ts +17 -3
  23. package/smoke/sdk-rust.ts +16 -3
  24. package/src/node/client.ts +521 -206
  25. package/src/node/common.ts +74 -4
  26. package/src/node/config.ts +1 -0
  27. package/src/node/enums.ts +53 -9
  28. package/src/node/errors.ts +82 -3
  29. package/src/node/fixtures.ts +87 -16
  30. package/src/node/index.ts +66 -10
  31. package/src/node/manifest.ts +4 -2
  32. package/src/node/models.ts +251 -124
  33. package/src/node/naming.ts +107 -3
  34. package/src/node/resources.ts +1162 -108
  35. package/src/node/serializers.ts +512 -52
  36. package/src/node/tests.ts +650 -110
  37. package/src/node/type-map.ts +89 -11
  38. package/src/node/utils.ts +426 -113
  39. package/test/node/client.test.ts +1083 -20
  40. package/test/node/enums.test.ts +73 -4
  41. package/test/node/errors.test.ts +4 -21
  42. package/test/node/models.test.ts +499 -5
  43. package/test/node/naming.test.ts +14 -7
  44. package/test/node/resources.test.ts +1568 -9
  45. package/test/node/serializers.test.ts +241 -5
  46. package/tsconfig.json +2 -3
  47. package/{tsup.config.ts → tsdown.config.ts} +1 -1
  48. package/dist/index.d.ts +0 -5
  49. package/dist/index.js +0 -2158
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { generateResources } from '../../src/node/resources.js';
2
+ import { generateResources, resolveResourceClassName, hasCompatibleConstructor } from '../../src/node/resources.js';
3
3
  import type { EmitterContext, ApiSpec, Service } from '@workos/oagen';
4
4
 
5
5
  const emptySpec: ApiSpec = {
@@ -15,7 +15,6 @@ const ctx: EmitterContext = {
15
15
  namespace: 'workos',
16
16
  namespacePascal: 'WorkOS',
17
17
  spec: emptySpec,
18
- irVersion: 6,
19
18
  };
20
19
 
21
20
  describe('generateResources', () => {
@@ -73,7 +72,10 @@ describe('generateResources', () => {
73
72
  queryParams: [
74
73
  {
75
74
  name: 'domains',
76
- type: { kind: 'array', items: { kind: 'primitive', type: 'string' } },
75
+ type: {
76
+ kind: 'array',
77
+ items: { kind: 'primitive', type: 'string' },
78
+ },
77
79
  required: false,
78
80
  },
79
81
  ],
@@ -81,7 +83,8 @@ describe('generateResources', () => {
81
83
  response: { kind: 'model', name: 'Organization' },
82
84
  errors: [],
83
85
  pagination: {
84
- cursorParam: 'after',
86
+ strategy: 'cursor',
87
+ param: 'after',
85
88
  dataPath: 'data',
86
89
  itemType: { kind: 'model', name: 'Organization' },
87
90
  },
@@ -94,9 +97,9 @@ describe('generateResources', () => {
94
97
  const files = generateResources(services, ctx);
95
98
  const content = files[0].content;
96
99
 
97
- // Should have AutoPaginatable imports
98
- expect(content).toContain('import { AutoPaginatable }');
99
- expect(content).toContain('import { fetchAndDeserialize }');
100
+ // Should have AutoPaginatable type import and createPaginatedList import
101
+ expect(content).toContain("import type { AutoPaginatable } from '../common/utils/pagination'");
102
+ expect(content).toContain("import { createPaginatedList } from '../common/utils/fetch-and-deserialize'");
100
103
 
101
104
  // Should generate options interface
102
105
  expect(content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
@@ -106,6 +109,54 @@ describe('generateResources', () => {
106
109
  expect(content).toContain('Promise<AutoPaginatable<Organization, ListOrganizationsOptions>>');
107
110
  });
108
111
 
112
+ it('uses item type not list wrapper type for paginated methods', () => {
113
+ // The response model is the list wrapper (ConnectionList), but the pagination
114
+ // itemType is the actual item (Connection). The generated code should use the
115
+ // item type for fetchAndDeserialize, not the list wrapper.
116
+ const services: Service[] = [
117
+ {
118
+ name: 'SSO',
119
+ operations: [
120
+ {
121
+ name: 'listConnections',
122
+ httpMethod: 'get',
123
+ path: '/connections',
124
+ pathParams: [],
125
+ queryParams: [],
126
+ headerParams: [],
127
+ response: { kind: 'model', name: 'ConnectionList' },
128
+ errors: [],
129
+ pagination: {
130
+ strategy: 'cursor',
131
+ param: 'after',
132
+ dataPath: 'data',
133
+ itemType: { kind: 'model', name: 'Connection' },
134
+ },
135
+ injectIdempotencyKey: false,
136
+ },
137
+ ],
138
+ },
139
+ ];
140
+
141
+ const testCtx: EmitterContext = {
142
+ namespace: 'workos',
143
+ namespacePascal: 'WorkOS',
144
+ spec: { ...emptySpec, services, models: [] },
145
+ };
146
+
147
+ const files = generateResources(services, testCtx);
148
+ const content = files[0].content;
149
+
150
+ // Should use item type (Connection) not list wrapper (ConnectionList)
151
+ expect(content).toContain('createPaginatedList<ConnectionResponse, Connection,');
152
+ expect(content).toContain('deserializeConnection');
153
+ expect(content).toContain('Promise<AutoPaginatable<Connection,');
154
+
155
+ // Should NOT reference the list wrapper type
156
+ expect(content).not.toContain('ConnectionList');
157
+ expect(content).not.toContain('deserializeConnectionList');
158
+ });
159
+
109
160
  it('generates DELETE method returning void', () => {
110
161
  const services: Service[] = [
111
162
  {
@@ -191,12 +242,16 @@ describe('generateResources', () => {
191
242
  namespace: 'workos',
192
243
  namespacePascal: 'WorkOS',
193
244
  spec: { ...emptySpec, services: [mfaService], models: [] },
194
- irVersion: 6,
195
245
  overlayLookup: {
196
246
  methodByOperation: new Map([
197
247
  [
198
248
  'POST /auth/factors/enroll',
199
- { className: 'Mfa', methodName: 'enrollFactor', params: [], returnType: 'void' },
249
+ {
250
+ className: 'Mfa',
251
+ methodName: 'enrollFactor',
252
+ params: [],
253
+ returnType: 'void',
254
+ },
200
255
  ],
201
256
  ]),
202
257
  httpKeyByMethod: new Map(),
@@ -254,7 +309,1511 @@ describe('generateResources', () => {
254
309
  expect(content).toContain(' *');
255
310
  expect(content).toContain(' * You may optionally inform Radar that an attempt was successful.');
256
311
  expect(content).toContain(' * @param id - The unique identifier of the attempt.');
312
+ expect(content).toContain(' * @returns {RadarAttempt}');
257
313
  expect(content).toContain(' * @deprecated');
258
314
  expect(content).toContain(' */');
259
315
  });
316
+
317
+ it('renders @returns for response model', () => {
318
+ const services: Service[] = [
319
+ {
320
+ name: 'Organizations',
321
+ operations: [
322
+ {
323
+ name: 'getOrganization',
324
+ httpMethod: 'get',
325
+ path: '/organizations/{id}',
326
+ pathParams: [
327
+ {
328
+ name: 'id',
329
+ type: { kind: 'primitive', type: 'string' },
330
+ required: true,
331
+ },
332
+ ],
333
+ queryParams: [],
334
+ headerParams: [],
335
+ response: { kind: 'model', name: 'Organization' },
336
+ errors: [],
337
+ injectIdempotencyKey: false,
338
+ },
339
+ ],
340
+ },
341
+ ];
342
+
343
+ const files = generateResources(services, ctx);
344
+ const content = files[0].content;
345
+ expect(content).toContain('@returns {Organization}');
346
+ });
347
+
348
+ it('renders query param docs for non-paginated operations', () => {
349
+ const services: Service[] = [
350
+ {
351
+ name: 'Organizations',
352
+ operations: [
353
+ {
354
+ name: 'getOrganization',
355
+ httpMethod: 'get',
356
+ path: '/organizations/{id}',
357
+ pathParams: [
358
+ {
359
+ name: 'id',
360
+ type: { kind: 'primitive', type: 'string' },
361
+ required: true,
362
+ },
363
+ ],
364
+ queryParams: [
365
+ {
366
+ name: 'include_fields',
367
+ type: { kind: 'primitive', type: 'string' },
368
+ required: false,
369
+ description: 'Comma-separated list of fields to include.',
370
+ },
371
+ ],
372
+ headerParams: [],
373
+ response: { kind: 'model', name: 'Organization' },
374
+ errors: [],
375
+ injectIdempotencyKey: false,
376
+ },
377
+ ],
378
+ },
379
+ ];
380
+
381
+ const files = generateResources(services, ctx);
382
+ const content = files[0].content;
383
+ expect(content).toContain('@param options.includeFields - Comma-separated list of fields to include.');
384
+ });
385
+
386
+ it('renders header and cookie param docs', () => {
387
+ const services: Service[] = [
388
+ {
389
+ name: 'Sessions',
390
+ operations: [
391
+ {
392
+ name: 'getSession',
393
+ httpMethod: 'get',
394
+ path: '/sessions/{id}',
395
+ pathParams: [
396
+ {
397
+ name: 'id',
398
+ type: { kind: 'primitive', type: 'string' },
399
+ required: true,
400
+ },
401
+ ],
402
+ queryParams: [],
403
+ headerParams: [
404
+ {
405
+ name: 'X-Request-Id',
406
+ type: { kind: 'primitive', type: 'string' },
407
+ required: false,
408
+ description: 'Unique request identifier.',
409
+ },
410
+ ],
411
+ cookieParams: [
412
+ {
413
+ name: 'session_token',
414
+ type: { kind: 'primitive', type: 'string' },
415
+ required: true,
416
+ description: 'The session cookie.',
417
+ },
418
+ ],
419
+ response: { kind: 'model', name: 'Session' },
420
+ errors: [],
421
+ injectIdempotencyKey: false,
422
+ },
423
+ ],
424
+ },
425
+ ];
426
+
427
+ const files = generateResources(services, ctx);
428
+ const content = files[0].content;
429
+ // Header and cookie params are intentionally NOT documented in JSDoc —
430
+ // they are not exposed in the method signature (handled internally by the SDK).
431
+ expect(content).not.toContain('@param xRequestId');
432
+ expect(content).not.toContain('@param sessionToken');
433
+ });
434
+
435
+ it('renders single @returns without status-code duplicates', () => {
436
+ const services: Service[] = [
437
+ {
438
+ name: 'Organizations',
439
+ operations: [
440
+ {
441
+ name: 'createOrganization',
442
+ httpMethod: 'post',
443
+ path: '/organizations',
444
+ pathParams: [],
445
+ queryParams: [],
446
+ headerParams: [],
447
+ requestBody: { kind: 'model', name: 'CreateOrganizationInput' },
448
+ response: { kind: 'model', name: 'Organization' },
449
+ successResponses: [
450
+ {
451
+ statusCode: 200,
452
+ type: { kind: 'model', name: 'Organization' },
453
+ },
454
+ {
455
+ statusCode: 201,
456
+ type: { kind: 'model', name: 'Organization' },
457
+ },
458
+ ],
459
+ errors: [],
460
+ injectIdempotencyKey: false,
461
+ },
462
+ ],
463
+ },
464
+ ];
465
+
466
+ const files = generateResources(services, ctx);
467
+ const content = files[0].content;
468
+ // Only emit a single @returns for the primary response model (no status-code variants)
469
+ expect(content).toContain('@returns {Organization}');
470
+ expect(content).not.toContain('@returns {Organization} 200');
471
+ expect(content).not.toContain('@returns {Organization} 201');
472
+ });
473
+
474
+ it('generates DELETE-with-body method using deleteWithBody', () => {
475
+ const services: Service[] = [
476
+ {
477
+ name: 'Radar',
478
+ operations: [
479
+ {
480
+ name: 'deleteRadarListEntry',
481
+ httpMethod: 'delete',
482
+ path: '/radar/lists/{listId}/entries',
483
+ pathParams: [
484
+ {
485
+ name: 'listId',
486
+ type: { kind: 'primitive', type: 'string' },
487
+ required: true,
488
+ },
489
+ ],
490
+ queryParams: [],
491
+ headerParams: [],
492
+ requestBody: { kind: 'model', name: 'DeleteRadarListEntryInput' },
493
+ response: { kind: 'primitive', type: 'unknown' },
494
+ errors: [],
495
+ injectIdempotencyKey: false,
496
+ },
497
+ ],
498
+ },
499
+ ];
500
+
501
+ const files = generateResources(services, ctx);
502
+ const content = files[0].content;
503
+ expect(content).toContain(
504
+ 'async deleteRadarListEntry(listId: string, payload: DeleteRadarListEntryInput): Promise<void>',
505
+ );
506
+ expect(content).toContain('await this.workos.deleteWithBody(');
507
+ expect(content).toContain('serializeDeleteRadarListEntryInput(payload)');
508
+ });
509
+
510
+ it('renders deprecated path params', () => {
511
+ const services: Service[] = [
512
+ {
513
+ name: 'Organizations',
514
+ operations: [
515
+ {
516
+ name: 'getOrganization',
517
+ httpMethod: 'get',
518
+ path: '/organizations/{slug}',
519
+ pathParams: [
520
+ {
521
+ name: 'slug',
522
+ type: { kind: 'primitive', type: 'string' },
523
+ required: true,
524
+ description: 'The organization slug.',
525
+ deprecated: true,
526
+ },
527
+ ],
528
+ queryParams: [],
529
+ headerParams: [],
530
+ response: { kind: 'model', name: 'Organization' },
531
+ errors: [],
532
+ injectIdempotencyKey: false,
533
+ },
534
+ ],
535
+ },
536
+ ];
537
+
538
+ const files = generateResources(services, ctx);
539
+ const content = files[0].content;
540
+ expect(content).toContain('@param slug - (deprecated) The organization slug.');
541
+ });
542
+
543
+ it('generates typed options interface for non-paginated GET with query params', () => {
544
+ const services: Service[] = [
545
+ {
546
+ name: 'Organizations',
547
+ operations: [
548
+ {
549
+ name: 'getOrganization',
550
+ httpMethod: 'get',
551
+ path: '/organizations/{id}',
552
+ pathParams: [
553
+ {
554
+ name: 'id',
555
+ type: { kind: 'primitive', type: 'string' },
556
+ required: true,
557
+ },
558
+ ],
559
+ queryParams: [
560
+ {
561
+ name: 'include_fields',
562
+ type: { kind: 'primitive', type: 'string' },
563
+ required: false,
564
+ description: 'Comma-separated list of fields to include.',
565
+ },
566
+ ],
567
+ headerParams: [],
568
+ response: { kind: 'model', name: 'Organization' },
569
+ errors: [],
570
+ injectIdempotencyKey: false,
571
+ },
572
+ ],
573
+ },
574
+ ];
575
+
576
+ const files = generateResources(services, ctx);
577
+ const content = files[0].content;
578
+
579
+ // Should generate a typed options interface
580
+ expect(content).toContain('export interface GetOrganizationOptions {');
581
+ expect(content).toContain('includeFields?: string;');
582
+
583
+ // Should use the typed options in the method signature
584
+ expect(content).toContain(
585
+ 'async getOrganization(id: string, options?: GetOrganizationOptions): Promise<Organization>',
586
+ );
587
+
588
+ // Should NOT use Record<string, unknown>
589
+ expect(content).not.toContain('Record<string, unknown>');
590
+ });
591
+
592
+ it('generates typed options interface for void GET with query params', () => {
593
+ const services: Service[] = [
594
+ {
595
+ name: 'Auth',
596
+ operations: [
597
+ {
598
+ name: 'authorize',
599
+ httpMethod: 'get',
600
+ path: '/user_management/authorize',
601
+ pathParams: [],
602
+ queryParams: [
603
+ {
604
+ name: 'client_id',
605
+ type: { kind: 'primitive', type: 'string' },
606
+ required: true,
607
+ },
608
+ {
609
+ name: 'redirect_uri',
610
+ type: { kind: 'primitive', type: 'string' },
611
+ required: true,
612
+ },
613
+ {
614
+ name: 'response_type',
615
+ type: { kind: 'primitive', type: 'string' },
616
+ required: true,
617
+ },
618
+ ],
619
+ headerParams: [],
620
+ response: { kind: 'primitive', type: 'unknown' },
621
+ errors: [],
622
+ injectIdempotencyKey: false,
623
+ },
624
+ ],
625
+ },
626
+ ];
627
+
628
+ const files = generateResources(services, ctx);
629
+ const content = files[0].content;
630
+
631
+ // Should generate a typed options interface
632
+ expect(content).toContain('export interface AuthorizeOptions {');
633
+ expect(content).toContain('clientId: string;');
634
+ expect(content).toContain('redirectUri: string;');
635
+ expect(content).toContain('responseType: string;');
636
+
637
+ // Should use the typed options in the method signature
638
+ expect(content).toContain('async authorize(options?: AuthorizeOptions): Promise<void>');
639
+
640
+ // Should pass options as query params
641
+ expect(content).toContain('query: options');
642
+ });
643
+
644
+ it('falls back to pass-through for non-discriminated union when models not in spec', () => {
645
+ const services: Service[] = [
646
+ {
647
+ name: 'Auth',
648
+ operations: [
649
+ {
650
+ name: 'authenticate',
651
+ httpMethod: 'post',
652
+ path: '/user_management/authenticate',
653
+ pathParams: [],
654
+ queryParams: [],
655
+ headerParams: [],
656
+ requestBody: {
657
+ kind: 'union',
658
+ variants: [
659
+ { kind: 'model', name: 'AuthByPassword' },
660
+ { kind: 'model', name: 'AuthByCode' },
661
+ { kind: 'model', name: 'AuthByMagicAuth' },
662
+ ],
663
+ },
664
+ response: { kind: 'model', name: 'AuthenticateResponse' },
665
+ errors: [],
666
+ injectIdempotencyKey: false,
667
+ },
668
+ ],
669
+ },
670
+ ];
671
+
672
+ const files = generateResources(services, ctx);
673
+ const content = files[0].content;
674
+
675
+ // Should use the union type for the payload parameter
676
+ expect(content).toContain('payload: AuthByPassword | AuthByCode | AuthByMagicAuth');
677
+
678
+ // Should NOT use Record<string, unknown>
679
+ expect(content).not.toContain('Record<string, unknown>');
680
+
681
+ // Models not in spec → falls back to pass-through
682
+ expect(content).toContain("'/user_management/authenticate',");
683
+ expect(content).toContain('payload,');
684
+
685
+ // Should import all union variant types
686
+ expect(content).toContain('AuthByPassword');
687
+ expect(content).toContain('AuthByCode');
688
+ expect(content).toContain('AuthByMagicAuth');
689
+ });
690
+
691
+ it('generates field-guard serializer dispatch for non-discriminated union with models', () => {
692
+ const services: Service[] = [
693
+ {
694
+ name: 'Applications',
695
+ operations: [
696
+ {
697
+ name: 'createApplication',
698
+ httpMethod: 'post',
699
+ path: '/connect/applications',
700
+ pathParams: [],
701
+ queryParams: [],
702
+ headerParams: [],
703
+ requestBody: {
704
+ kind: 'union',
705
+ variants: [
706
+ { kind: 'model', name: 'CreateOAuthApplication' },
707
+ { kind: 'model', name: 'CreateM2MApplication' },
708
+ ],
709
+ },
710
+ response: { kind: 'model', name: 'ConnectApplication' },
711
+ errors: [],
712
+ injectIdempotencyKey: false,
713
+ },
714
+ ],
715
+ },
716
+ ];
717
+
718
+ const testCtx: EmitterContext = {
719
+ namespace: 'workos',
720
+ namespacePascal: 'WorkOS',
721
+ spec: {
722
+ ...emptySpec,
723
+ services,
724
+ models: [
725
+ {
726
+ name: 'CreateOAuthApplication',
727
+ fields: [
728
+ {
729
+ name: 'name',
730
+ type: { kind: 'primitive', type: 'string' },
731
+ required: true,
732
+ },
733
+ {
734
+ name: 'redirect_uris',
735
+ type: {
736
+ kind: 'array',
737
+ items: { kind: 'primitive', type: 'string' },
738
+ },
739
+ required: true,
740
+ },
741
+ {
742
+ name: 'uses_pkce',
743
+ type: { kind: 'primitive', type: 'boolean' },
744
+ required: false,
745
+ },
746
+ ],
747
+ },
748
+ {
749
+ name: 'CreateM2MApplication',
750
+ fields: [
751
+ {
752
+ name: 'name',
753
+ type: { kind: 'primitive', type: 'string' },
754
+ required: true,
755
+ },
756
+ {
757
+ name: 'scopes',
758
+ type: {
759
+ kind: 'array',
760
+ items: { kind: 'primitive', type: 'string' },
761
+ },
762
+ required: true,
763
+ },
764
+ ],
765
+ },
766
+ {
767
+ name: 'ConnectApplication',
768
+ fields: [
769
+ {
770
+ name: 'id',
771
+ type: { kind: 'primitive', type: 'string' },
772
+ required: true,
773
+ },
774
+ ],
775
+ },
776
+ ],
777
+ },
778
+ };
779
+
780
+ const files = generateResources(services, testCtx);
781
+ const content = files[0].content;
782
+
783
+ // Should use the union type for the payload parameter
784
+ expect(content).toContain('payload: CreateOAuthApplication | CreateM2MApplication');
785
+
786
+ // Should dispatch via unique required field guards
787
+ expect(content).toContain("'redirectUris' in payload");
788
+ expect(content).toContain('serializeCreateOAuthApplication(payload as any)');
789
+ expect(content).toContain('serializeCreateM2MApplication(payload as any)');
790
+
791
+ // Should import serializers for all union variants
792
+ expect(content).toContain('serializeCreateOAuthApplication');
793
+ expect(content).toContain('serializeCreateM2MApplication');
794
+ });
795
+
796
+ it('generates discriminated union serializer dispatch for request body', () => {
797
+ const services: Service[] = [
798
+ {
799
+ name: 'Auth',
800
+ operations: [
801
+ {
802
+ name: 'authenticate',
803
+ httpMethod: 'post',
804
+ path: '/user_management/authenticate',
805
+ pathParams: [],
806
+ queryParams: [],
807
+ headerParams: [],
808
+ requestBody: {
809
+ kind: 'union',
810
+ variants: [
811
+ { kind: 'model', name: 'AuthByPassword' },
812
+ { kind: 'model', name: 'AuthByCode' },
813
+ { kind: 'model', name: 'AuthByMagicAuth' },
814
+ ],
815
+ discriminator: {
816
+ property: 'grant_type',
817
+ mapping: {
818
+ password: 'AuthByPassword',
819
+ authorization_code: 'AuthByCode',
820
+ 'urn:workos:oauth:grant-type:magic-auth:code': 'AuthByMagicAuth',
821
+ },
822
+ },
823
+ },
824
+ response: { kind: 'model', name: 'AuthenticateResponse' },
825
+ errors: [],
826
+ injectIdempotencyKey: false,
827
+ },
828
+ ],
829
+ },
830
+ ];
831
+
832
+ const files = generateResources(services, ctx);
833
+ const content = files[0].content;
834
+
835
+ // Should use the union type for the payload parameter
836
+ expect(content).toContain('payload: AuthByPassword | AuthByCode | AuthByMagicAuth');
837
+
838
+ // Should dispatch to the correct serializer based on the discriminator
839
+ expect(content).toContain('switch ((payload as any).grantType)');
840
+ expect(content).toContain("case 'password': return serializeAuthByPassword(payload as any)");
841
+ expect(content).toContain("case 'authorization_code': return serializeAuthByCode(payload as any)");
842
+ expect(content).toContain(
843
+ "case 'urn:workos:oauth:grant-type:magic-auth:code': return serializeAuthByMagicAuth(payload as any)",
844
+ );
845
+
846
+ // Should import serializers for all union variants
847
+ expect(content).toContain('serializeAuthByPassword');
848
+ expect(content).toContain('serializeAuthByCode');
849
+ expect(content).toContain('serializeAuthByMagicAuth');
850
+
851
+ // Should NOT pass payload directly without serialization
852
+ expect(content).not.toMatch(/,\n\s+payload,\n/);
853
+ });
854
+
855
+ it('generates discriminated union serializer dispatch for void method', () => {
856
+ const services: Service[] = [
857
+ {
858
+ name: 'Auth',
859
+ operations: [
860
+ {
861
+ name: 'sendToken',
862
+ httpMethod: 'post',
863
+ path: '/auth/token',
864
+ pathParams: [],
865
+ queryParams: [],
866
+ headerParams: [],
867
+ requestBody: {
868
+ kind: 'union',
869
+ variants: [
870
+ { kind: 'model', name: 'TokenByCode' },
871
+ { kind: 'model', name: 'TokenByRefresh' },
872
+ ],
873
+ discriminator: {
874
+ property: 'grant_type',
875
+ mapping: {
876
+ authorization_code: 'TokenByCode',
877
+ refresh_token: 'TokenByRefresh',
878
+ },
879
+ },
880
+ },
881
+ response: { kind: 'primitive', type: 'unknown' },
882
+ errors: [],
883
+ injectIdempotencyKey: false,
884
+ },
885
+ ],
886
+ },
887
+ ];
888
+
889
+ const files = generateResources(services, ctx);
890
+ const content = files[0].content;
891
+
892
+ // Should dispatch to the correct serializer
893
+ expect(content).toContain('switch ((payload as any).grantType)');
894
+ expect(content).toContain("case 'authorization_code': return serializeTokenByCode(payload as any)");
895
+ expect(content).toContain("case 'refresh_token': return serializeTokenByRefresh(payload as any)");
896
+ });
897
+
898
+ it('uses createPaginatedList helper in paginated methods', () => {
899
+ const services: Service[] = [
900
+ {
901
+ name: 'Connections',
902
+ operations: [
903
+ {
904
+ name: 'listConnections',
905
+ httpMethod: 'get',
906
+ path: '/connections',
907
+ pathParams: [],
908
+ queryParams: [],
909
+ headerParams: [],
910
+ response: { kind: 'model', name: 'Connection' },
911
+ errors: [],
912
+ pagination: {
913
+ strategy: 'cursor',
914
+ param: 'after',
915
+ dataPath: 'data',
916
+ itemType: { kind: 'model', name: 'Connection' },
917
+ },
918
+ injectIdempotencyKey: false,
919
+ },
920
+ ],
921
+ },
922
+ ];
923
+
924
+ const files = generateResources(services, ctx);
925
+ const content = files[0].content;
926
+
927
+ // Should use createPaginatedList helper for concise paginated methods
928
+ expect(content).toContain('createPaginatedList<ConnectionResponse, Connection,');
929
+ expect(content).toContain('this.workos,');
930
+ expect(content).toContain('deserializeConnection');
931
+ });
932
+
933
+ it('prefixes ListOptions with service name when method is "list"', () => {
934
+ const services: Service[] = [
935
+ {
936
+ name: 'Payments',
937
+ operations: [
938
+ {
939
+ name: 'list',
940
+ httpMethod: 'get',
941
+ path: '/payments',
942
+ pathParams: [],
943
+ queryParams: [
944
+ {
945
+ name: 'connection_type',
946
+ type: { kind: 'primitive', type: 'string' },
947
+ required: false,
948
+ },
949
+ ],
950
+ headerParams: [],
951
+ response: { kind: 'model', name: 'Connection' },
952
+ errors: [],
953
+ pagination: {
954
+ strategy: 'cursor',
955
+ param: 'after',
956
+ dataPath: 'data',
957
+ itemType: { kind: 'model', name: 'Connection' },
958
+ },
959
+ injectIdempotencyKey: false,
960
+ },
961
+ ],
962
+ },
963
+ ];
964
+
965
+ // Use overlay to resolve method name to "list"
966
+ const overlayCtx: EmitterContext = {
967
+ namespace: 'workos',
968
+ namespacePascal: 'WorkOS',
969
+ spec: { ...emptySpec, services, models: [] },
970
+ overlayLookup: {
971
+ methodByOperation: new Map([
972
+ [
973
+ 'GET /payments',
974
+ {
975
+ className: 'Payments',
976
+ methodName: 'list',
977
+ params: [],
978
+ returnType: 'void',
979
+ },
980
+ ],
981
+ ]),
982
+ httpKeyByMethod: new Map(),
983
+ interfaceByName: new Map(),
984
+ typeAliasByName: new Map(),
985
+ requiredExports: new Map(),
986
+ modelNameByIR: new Map(),
987
+ fileBySymbol: new Map(),
988
+ },
989
+ };
990
+
991
+ const files = generateResources(services, overlayCtx);
992
+ const content = files[0].content;
993
+
994
+ // Should use service-prefixed options name instead of generic "ListOptions"
995
+ expect(content).toContain('export interface PaymentsListOptions extends PaginationOptions {');
996
+ expect(content).toContain('Promise<AutoPaginatable<Connection, PaymentsListOptions>>');
997
+ // Should NOT use the generic "ListOptions"
998
+ expect(content).not.toContain('export interface ListOptions ');
999
+ });
1000
+
1001
+ it('does not prefix ListOptions when method is not "list"', () => {
1002
+ const services: Service[] = [
1003
+ {
1004
+ name: 'Organizations',
1005
+ operations: [
1006
+ {
1007
+ name: 'listOrganizations',
1008
+ httpMethod: 'get',
1009
+ path: '/organizations',
1010
+ pathParams: [],
1011
+ queryParams: [
1012
+ {
1013
+ name: 'domains',
1014
+ type: {
1015
+ kind: 'array',
1016
+ items: { kind: 'primitive', type: 'string' },
1017
+ },
1018
+ required: false,
1019
+ },
1020
+ ],
1021
+ headerParams: [],
1022
+ response: { kind: 'model', name: 'Organization' },
1023
+ errors: [],
1024
+ pagination: {
1025
+ strategy: 'cursor',
1026
+ param: 'after',
1027
+ dataPath: 'data',
1028
+ itemType: { kind: 'model', name: 'Organization' },
1029
+ },
1030
+ injectIdempotencyKey: false,
1031
+ },
1032
+ ],
1033
+ },
1034
+ ];
1035
+
1036
+ const files = generateResources(services, ctx);
1037
+ const content = files[0].content;
1038
+
1039
+ // Method is "listOrganizations", not "list", so options name should be normal
1040
+ expect(content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
1041
+ });
1042
+
1043
+ it('removes skipIfExists when fully-covered service has methods absent from baseline', () => {
1044
+ const services: Service[] = [
1045
+ {
1046
+ name: 'SSOService',
1047
+ operations: [
1048
+ {
1049
+ name: 'getAuthorizationUrl',
1050
+ httpMethod: 'get',
1051
+ path: '/sso/authorize',
1052
+ pathParams: [],
1053
+ queryParams: [],
1054
+ headerParams: [],
1055
+ response: { kind: 'model', name: 'AuthorizationUrl' },
1056
+ errors: [],
1057
+ injectIdempotencyKey: false,
1058
+ },
1059
+ {
1060
+ name: 'logout',
1061
+ httpMethod: 'get',
1062
+ path: '/sso/logout',
1063
+ pathParams: [],
1064
+ queryParams: [],
1065
+ headerParams: [],
1066
+ response: { kind: 'model', name: 'LogoutResult' },
1067
+ errors: [],
1068
+ injectIdempotencyKey: false,
1069
+ },
1070
+ ],
1071
+ },
1072
+ ];
1073
+
1074
+ // Overlay maps both operations to SSO class
1075
+ // Baseline SSO class exists but only has getAuthorizationUrl (logout is missing)
1076
+ const overlayCtx: EmitterContext = {
1077
+ namespace: 'workos',
1078
+ namespacePascal: 'WorkOS',
1079
+ spec: { ...emptySpec, services, models: [] },
1080
+ overlayLookup: {
1081
+ methodByOperation: new Map([
1082
+ [
1083
+ 'GET /sso/authorize',
1084
+ {
1085
+ className: 'SSO',
1086
+ methodName: 'getAuthorizationUrl',
1087
+ params: [],
1088
+ returnType: 'void',
1089
+ },
1090
+ ],
1091
+ [
1092
+ 'GET /sso/logout',
1093
+ {
1094
+ className: 'SSO',
1095
+ methodName: 'logout',
1096
+ params: [],
1097
+ returnType: 'void',
1098
+ },
1099
+ ],
1100
+ ]),
1101
+ httpKeyByMethod: new Map(),
1102
+ interfaceByName: new Map(),
1103
+ typeAliasByName: new Map(),
1104
+ requiredExports: new Map(),
1105
+ modelNameByIR: new Map(),
1106
+ fileBySymbol: new Map(),
1107
+ },
1108
+ apiSurface: {
1109
+ language: 'node',
1110
+ extractedFrom: 'test',
1111
+ extractedAt: '2024-01-01',
1112
+ classes: {
1113
+ SSO: {
1114
+ name: 'SSO',
1115
+ methods: {
1116
+ getAuthorizationUrl: [
1117
+ {
1118
+ name: 'getAuthorizationUrl',
1119
+ params: [],
1120
+ returnType: 'void',
1121
+ async: true,
1122
+ },
1123
+ ],
1124
+ // logout method is intentionally ABSENT
1125
+ },
1126
+ properties: {},
1127
+ constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1128
+ },
1129
+ },
1130
+ interfaces: {},
1131
+ typeAliases: {},
1132
+ enums: {},
1133
+ exports: {},
1134
+ },
1135
+ };
1136
+
1137
+ const files = generateResources(services, overlayCtx);
1138
+ expect(files.length).toBe(1);
1139
+
1140
+ // skipIfExists should be removed because 'logout' is absent from baseline
1141
+ expect(files[0].skipIfExists).toBeUndefined();
1142
+ });
1143
+
1144
+ it('keeps skipIfExists when fully-covered service has all methods in baseline', () => {
1145
+ const services: Service[] = [
1146
+ {
1147
+ name: 'SSOService',
1148
+ operations: [
1149
+ {
1150
+ name: 'getAuthorizationUrl',
1151
+ httpMethod: 'get',
1152
+ path: '/sso/authorize',
1153
+ pathParams: [],
1154
+ queryParams: [],
1155
+ headerParams: [],
1156
+ response: { kind: 'model', name: 'AuthorizationUrl' },
1157
+ errors: [],
1158
+ injectIdempotencyKey: false,
1159
+ },
1160
+ ],
1161
+ },
1162
+ ];
1163
+
1164
+ const overlayCtx: EmitterContext = {
1165
+ namespace: 'workos',
1166
+ namespacePascal: 'WorkOS',
1167
+ spec: { ...emptySpec, services, models: [] },
1168
+ overlayLookup: {
1169
+ methodByOperation: new Map([
1170
+ [
1171
+ 'GET /sso/authorize',
1172
+ {
1173
+ className: 'SSO',
1174
+ methodName: 'getAuthorizationUrl',
1175
+ params: [],
1176
+ returnType: 'void',
1177
+ },
1178
+ ],
1179
+ ]),
1180
+ httpKeyByMethod: new Map(),
1181
+ interfaceByName: new Map(),
1182
+ typeAliasByName: new Map(),
1183
+ requiredExports: new Map(),
1184
+ modelNameByIR: new Map(),
1185
+ fileBySymbol: new Map(),
1186
+ },
1187
+ apiSurface: {
1188
+ language: 'node',
1189
+ extractedFrom: 'test',
1190
+ extractedAt: '2024-01-01',
1191
+ classes: {
1192
+ SSO: {
1193
+ name: 'SSO',
1194
+ methods: {
1195
+ getAuthorizationUrl: [
1196
+ {
1197
+ name: 'getAuthorizationUrl',
1198
+ params: [],
1199
+ returnType: 'void',
1200
+ async: true,
1201
+ },
1202
+ ],
1203
+ },
1204
+ properties: {},
1205
+ constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1206
+ },
1207
+ },
1208
+ interfaces: {},
1209
+ typeAliases: {},
1210
+ enums: {},
1211
+ exports: {},
1212
+ },
1213
+ };
1214
+
1215
+ const files = generateResources(services, overlayCtx);
1216
+ expect(files.length).toBe(1);
1217
+
1218
+ // skipIfExists should stay true because all methods exist in baseline
1219
+ expect(files[0].skipIfExists).toBe(true);
1220
+ });
1221
+ });
1222
+
1223
+ describe('resolveResourceClassName', () => {
1224
+ const webhooksService: Service = {
1225
+ name: 'WebhookEvents',
1226
+ operations: [
1227
+ {
1228
+ name: 'listWebhookEvents',
1229
+ httpMethod: 'get',
1230
+ path: '/webhook_events',
1231
+ pathParams: [],
1232
+ queryParams: [],
1233
+ headerParams: [],
1234
+ response: { kind: 'model', name: 'WebhookEvent' },
1235
+ errors: [],
1236
+ injectIdempotencyKey: false,
1237
+ },
1238
+ ],
1239
+ };
1240
+
1241
+ it('generates separate class when baseline has incompatible constructor', () => {
1242
+ const overlayCtx: EmitterContext = {
1243
+ namespace: 'workos',
1244
+ namespacePascal: 'WorkOS',
1245
+ spec: { ...emptySpec, services: [webhooksService] },
1246
+ overlayLookup: {
1247
+ methodByOperation: new Map([
1248
+ [
1249
+ 'GET /webhook_events',
1250
+ {
1251
+ className: 'Webhooks',
1252
+ methodName: 'listWebhookEvents',
1253
+ params: [],
1254
+ returnType: 'void',
1255
+ },
1256
+ ],
1257
+ ]),
1258
+ httpKeyByMethod: new Map(),
1259
+ interfaceByName: new Map(),
1260
+ typeAliasByName: new Map(),
1261
+ requiredExports: new Map(),
1262
+ modelNameByIR: new Map(),
1263
+ fileBySymbol: new Map(),
1264
+ },
1265
+ apiSurface: {
1266
+ language: 'node',
1267
+ extractedFrom: 'test',
1268
+ extractedAt: '2024-01-01',
1269
+ classes: {
1270
+ Webhooks: {
1271
+ name: 'Webhooks',
1272
+ methods: {},
1273
+ properties: {},
1274
+ constructorParams: [
1275
+ {
1276
+ name: 'cryptoProvider',
1277
+ type: 'CryptoProvider',
1278
+ optional: false,
1279
+ },
1280
+ ],
1281
+ },
1282
+ },
1283
+ interfaces: {},
1284
+ typeAliases: {},
1285
+ enums: {},
1286
+ exports: {},
1287
+ },
1288
+ };
1289
+
1290
+ const result = resolveResourceClassName(webhooksService, overlayCtx);
1291
+ // Falls back to IR name since overlay name has incompatible constructor
1292
+ expect(result).toBe('WebhookEvents');
1293
+ });
1294
+
1295
+ it('uses overlay name when baseline has compatible constructor', () => {
1296
+ const overlayCtx: EmitterContext = {
1297
+ namespace: 'workos',
1298
+ namespacePascal: 'WorkOS',
1299
+ spec: { ...emptySpec, services: [webhooksService] },
1300
+ overlayLookup: {
1301
+ methodByOperation: new Map([
1302
+ [
1303
+ 'GET /webhook_events',
1304
+ {
1305
+ className: 'Webhooks',
1306
+ methodName: 'listWebhookEvents',
1307
+ params: [],
1308
+ returnType: 'void',
1309
+ },
1310
+ ],
1311
+ ]),
1312
+ httpKeyByMethod: new Map(),
1313
+ interfaceByName: new Map(),
1314
+ typeAliasByName: new Map(),
1315
+ requiredExports: new Map(),
1316
+ modelNameByIR: new Map(),
1317
+ fileBySymbol: new Map(),
1318
+ },
1319
+ apiSurface: {
1320
+ language: 'node',
1321
+ extractedFrom: 'test',
1322
+ extractedAt: '2024-01-01',
1323
+ classes: {
1324
+ Webhooks: {
1325
+ name: 'Webhooks',
1326
+ methods: {},
1327
+ properties: {},
1328
+ constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1329
+ },
1330
+ },
1331
+ interfaces: {},
1332
+ typeAliases: {},
1333
+ enums: {},
1334
+ exports: {},
1335
+ },
1336
+ };
1337
+
1338
+ const result = resolveResourceClassName(webhooksService, overlayCtx);
1339
+ expect(result).toBe('Webhooks');
1340
+ });
1341
+
1342
+ it('appends Endpoints suffix when IR name collides with overlay name', () => {
1343
+ const collisionService: Service = {
1344
+ name: 'Webhooks',
1345
+ operations: [
1346
+ {
1347
+ name: 'listWebhooks',
1348
+ httpMethod: 'get',
1349
+ path: '/webhooks',
1350
+ pathParams: [],
1351
+ queryParams: [],
1352
+ headerParams: [],
1353
+ response: { kind: 'model', name: 'Webhook' },
1354
+ errors: [],
1355
+ injectIdempotencyKey: false,
1356
+ },
1357
+ ],
1358
+ };
1359
+
1360
+ const overlayCtx: EmitterContext = {
1361
+ namespace: 'workos',
1362
+ namespacePascal: 'WorkOS',
1363
+ spec: { ...emptySpec, services: [collisionService] },
1364
+ overlayLookup: {
1365
+ methodByOperation: new Map([
1366
+ [
1367
+ 'GET /webhooks',
1368
+ {
1369
+ className: 'Webhooks',
1370
+ methodName: 'listWebhooks',
1371
+ params: [],
1372
+ returnType: 'void',
1373
+ },
1374
+ ],
1375
+ ]),
1376
+ httpKeyByMethod: new Map(),
1377
+ interfaceByName: new Map(),
1378
+ typeAliasByName: new Map(),
1379
+ requiredExports: new Map(),
1380
+ modelNameByIR: new Map(),
1381
+ fileBySymbol: new Map(),
1382
+ },
1383
+ apiSurface: {
1384
+ language: 'node',
1385
+ extractedFrom: 'test',
1386
+ extractedAt: '2024-01-01',
1387
+ classes: {
1388
+ Webhooks: {
1389
+ name: 'Webhooks',
1390
+ methods: {},
1391
+ properties: {},
1392
+ constructorParams: [
1393
+ {
1394
+ name: 'cryptoProvider',
1395
+ type: 'CryptoProvider',
1396
+ optional: false,
1397
+ },
1398
+ ],
1399
+ },
1400
+ },
1401
+ interfaces: {},
1402
+ typeAliases: {},
1403
+ enums: {},
1404
+ exports: {},
1405
+ },
1406
+ };
1407
+
1408
+ const result = resolveResourceClassName(collisionService, overlayCtx);
1409
+ // IR name "Webhooks" collides with overlay name "Webhooks", so append Endpoints
1410
+ expect(result).toBe('WebhooksEndpoints');
1411
+ });
1412
+ });
1413
+
1414
+ describe('hasCompatibleConstructor', () => {
1415
+ it('returns true when no baseline exists', () => {
1416
+ expect(hasCompatibleConstructor('NewService', ctx)).toBe(true);
1417
+ });
1418
+
1419
+ it('returns true when baseline has workos: WorkOS param', () => {
1420
+ const ctxWithSurface: EmitterContext = {
1421
+ ...ctx,
1422
+ apiSurface: {
1423
+ language: 'node',
1424
+ extractedFrom: 'test',
1425
+ extractedAt: '2024-01-01',
1426
+ classes: {
1427
+ Organizations: {
1428
+ name: 'Organizations',
1429
+ methods: {},
1430
+ properties: {},
1431
+ constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1432
+ },
1433
+ },
1434
+ interfaces: {},
1435
+ typeAliases: {},
1436
+ enums: {},
1437
+ exports: {},
1438
+ },
1439
+ };
1440
+
1441
+ expect(hasCompatibleConstructor('Organizations', ctxWithSurface)).toBe(true);
1442
+ });
1443
+
1444
+ it('returns false when baseline has incompatible constructor', () => {
1445
+ const ctxWithSurface: EmitterContext = {
1446
+ ...ctx,
1447
+ apiSurface: {
1448
+ language: 'node',
1449
+ extractedFrom: 'test',
1450
+ extractedAt: '2024-01-01',
1451
+ classes: {
1452
+ Webhooks: {
1453
+ name: 'Webhooks',
1454
+ methods: {},
1455
+ properties: {},
1456
+ constructorParams: [
1457
+ {
1458
+ name: 'cryptoProvider',
1459
+ type: 'CryptoProvider',
1460
+ optional: false,
1461
+ },
1462
+ ],
1463
+ },
1464
+ },
1465
+ interfaces: {},
1466
+ typeAliases: {},
1467
+ enums: {},
1468
+ exports: {},
1469
+ },
1470
+ };
1471
+
1472
+ expect(hasCompatibleConstructor('Webhooks', ctxWithSurface)).toBe(false);
1473
+ });
1474
+
1475
+ it('returns true when baseline has no constructor params', () => {
1476
+ const ctxWithSurface: EmitterContext = {
1477
+ ...ctx,
1478
+ apiSurface: {
1479
+ language: 'node',
1480
+ extractedFrom: 'test',
1481
+ extractedAt: '2024-01-01',
1482
+ classes: {
1483
+ EmptyService: {
1484
+ name: 'EmptyService',
1485
+ methods: {},
1486
+ properties: {},
1487
+ constructorParams: [],
1488
+ },
1489
+ },
1490
+ interfaces: {},
1491
+ typeAliases: {},
1492
+ enums: {},
1493
+ exports: {},
1494
+ },
1495
+ };
1496
+
1497
+ expect(hasCompatibleConstructor('EmptyService', ctxWithSurface)).toBe(true);
1498
+ });
1499
+ });
1500
+
1501
+ describe('partial service coverage', () => {
1502
+ it('generates methods for uncovered operations in partially covered services', () => {
1503
+ const services: Service[] = [
1504
+ {
1505
+ name: 'AuditLogs',
1506
+ operations: [
1507
+ {
1508
+ name: 'createEvent',
1509
+ httpMethod: 'post',
1510
+ path: '/audit_logs/events',
1511
+ pathParams: [],
1512
+ queryParams: [],
1513
+ headerParams: [],
1514
+ response: { kind: 'model', name: 'AuditLogEvent' },
1515
+ errors: [],
1516
+ injectIdempotencyKey: false,
1517
+ },
1518
+ {
1519
+ name: 'getRetention',
1520
+ httpMethod: 'get',
1521
+ path: '/audit_logs/retention',
1522
+ pathParams: [],
1523
+ queryParams: [],
1524
+ headerParams: [],
1525
+ response: { kind: 'model', name: 'AuditLogRetention' },
1526
+ errors: [],
1527
+ injectIdempotencyKey: false,
1528
+ },
1529
+ ],
1530
+ },
1531
+ ];
1532
+
1533
+ // createEvent is covered by existing AuditLogs class, getRetention is NOT
1534
+ const ctxPartial: EmitterContext = {
1535
+ ...ctx,
1536
+ spec: { ...emptySpec, services, models: [] },
1537
+ overlayLookup: {
1538
+ methodByOperation: new Map([
1539
+ [
1540
+ 'POST /audit_logs/events',
1541
+ {
1542
+ className: 'AuditLogs',
1543
+ methodName: 'createEvent',
1544
+ params: [],
1545
+ returnType: 'AuditLogEvent',
1546
+ },
1547
+ ],
1548
+ ]),
1549
+ httpKeyByMethod: new Map(),
1550
+ interfaceByName: new Map(),
1551
+ typeAliasByName: new Map(),
1552
+ requiredExports: new Map(),
1553
+ modelNameByIR: new Map(),
1554
+ fileBySymbol: new Map(),
1555
+ },
1556
+ apiSurface: {
1557
+ language: 'node',
1558
+ extractedFrom: 'test',
1559
+ extractedAt: '2024-01-01',
1560
+ classes: {
1561
+ AuditLogs: {
1562
+ name: 'AuditLogs',
1563
+ methods: {
1564
+ createEvent: [
1565
+ {
1566
+ name: 'createEvent',
1567
+ params: [],
1568
+ returnType: 'AuditLogEvent',
1569
+ async: true,
1570
+ },
1571
+ ],
1572
+ },
1573
+ properties: {},
1574
+ constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1575
+ },
1576
+ },
1577
+ interfaces: {},
1578
+ typeAliases: {},
1579
+ enums: {},
1580
+ exports: {},
1581
+ },
1582
+ };
1583
+
1584
+ const files = generateResources(services, ctxPartial);
1585
+ expect(files.length).toBe(1);
1586
+ const content = files[0].content;
1587
+
1588
+ // Should generate method for uncovered operation
1589
+ expect(content).toContain('async getRetention');
1590
+ // Should also generate covered operation so the merger can apply JSDoc
1591
+ expect(content).toContain('async createEvent');
1592
+ });
1593
+
1594
+ it('generates resource class for fully covered services to provide JSDoc', () => {
1595
+ const services: Service[] = [
1596
+ {
1597
+ name: 'Permissions',
1598
+ operations: [
1599
+ {
1600
+ name: 'listPermissions',
1601
+ description: 'List all permissions.',
1602
+ httpMethod: 'get',
1603
+ path: '/authorization/permissions',
1604
+ pathParams: [],
1605
+ queryParams: [],
1606
+ headerParams: [],
1607
+ response: { kind: 'model', name: 'PermissionList' },
1608
+ errors: [{ statusCode: 404 }],
1609
+ injectIdempotencyKey: false,
1610
+ },
1611
+ ],
1612
+ },
1613
+ ];
1614
+
1615
+ const ctxCovered: EmitterContext = {
1616
+ ...ctx,
1617
+ spec: { ...emptySpec, services, models: [] },
1618
+ overlayLookup: {
1619
+ methodByOperation: new Map([
1620
+ [
1621
+ 'GET /authorization/permissions',
1622
+ {
1623
+ className: 'Permissions',
1624
+ methodName: 'listPermissions',
1625
+ params: [],
1626
+ returnType: 'void',
1627
+ },
1628
+ ],
1629
+ ]),
1630
+ httpKeyByMethod: new Map(),
1631
+ interfaceByName: new Map(),
1632
+ typeAliasByName: new Map(),
1633
+ requiredExports: new Map(),
1634
+ modelNameByIR: new Map(),
1635
+ fileBySymbol: new Map(),
1636
+ },
1637
+ apiSurface: {
1638
+ language: 'node',
1639
+ extractedFrom: 'test',
1640
+ extractedAt: '2024-01-01',
1641
+ classes: {
1642
+ Permissions: {
1643
+ name: 'Permissions',
1644
+ methods: {
1645
+ listPermissions: [
1646
+ {
1647
+ name: 'listPermissions',
1648
+ params: [],
1649
+ returnType: 'void',
1650
+ async: true,
1651
+ },
1652
+ ],
1653
+ },
1654
+ properties: {},
1655
+ constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1656
+ },
1657
+ },
1658
+ interfaces: {},
1659
+ typeAliases: {},
1660
+ enums: {},
1661
+ exports: {},
1662
+ },
1663
+ };
1664
+
1665
+ const files = generateResources(services, ctxCovered);
1666
+ expect(files.length).toBe(1);
1667
+ const content = files[0].content;
1668
+ // Should contain JSDoc with description from the spec
1669
+ expect(content).toContain('List all permissions.');
1670
+ // skipIfExists should remain true for covered services
1671
+ expect(files[0].skipIfExists).toBe(true);
1672
+ });
1673
+
1674
+ it('reconciles method names against api-surface using word-set matching', () => {
1675
+ const services: Service[] = [
1676
+ {
1677
+ name: 'Authorization',
1678
+ operations: [
1679
+ {
1680
+ name: 'listRolesOrganizations',
1681
+ httpMethod: 'get',
1682
+ path: '/authorization/organizations/{organizationId}/roles',
1683
+ pathParams: [
1684
+ {
1685
+ name: 'organizationId',
1686
+ type: { kind: 'primitive', type: 'string' },
1687
+ required: true,
1688
+ },
1689
+ ],
1690
+ queryParams: [],
1691
+ headerParams: [],
1692
+ response: { kind: 'model', name: 'RoleList' },
1693
+ errors: [],
1694
+ pagination: {
1695
+ strategy: 'cursor' as const,
1696
+ param: 'after',
1697
+ itemType: { kind: 'model' as const, name: 'RoleList' },
1698
+ },
1699
+ injectIdempotencyKey: false,
1700
+ },
1701
+ ],
1702
+ },
1703
+ ];
1704
+
1705
+ const ctxRecon: EmitterContext = {
1706
+ ...ctx,
1707
+ spec: {
1708
+ ...emptySpec,
1709
+ services,
1710
+ models: [{ name: 'RoleList', fields: [] }],
1711
+ },
1712
+ overlayLookup: {
1713
+ methodByOperation: new Map(), // no overlay mapping
1714
+ httpKeyByMethod: new Map(),
1715
+ interfaceByName: new Map(),
1716
+ typeAliasByName: new Map(),
1717
+ requiredExports: new Map(),
1718
+ modelNameByIR: new Map(),
1719
+ fileBySymbol: new Map(),
1720
+ },
1721
+ apiSurface: {
1722
+ language: 'node',
1723
+ extractedFrom: 'test',
1724
+ extractedAt: '2024-01-01',
1725
+ classes: {
1726
+ Authorization: {
1727
+ name: 'Authorization',
1728
+ methods: {
1729
+ listOrganizationRoles: [
1730
+ {
1731
+ name: 'listOrganizationRoles',
1732
+ params: [],
1733
+ returnType: 'void',
1734
+ async: true,
1735
+ },
1736
+ ],
1737
+ },
1738
+ properties: {},
1739
+ constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
1740
+ },
1741
+ },
1742
+ interfaces: {},
1743
+ typeAliases: {},
1744
+ enums: {},
1745
+ exports: {},
1746
+ },
1747
+ };
1748
+
1749
+ const files = generateResources(services, ctxRecon);
1750
+ expect(files.length).toBe(1);
1751
+ const content = files[0].content;
1752
+ // Should use reconciled name from api-surface, not spec-derived name
1753
+ expect(content).toContain('async listOrganizationRoles');
1754
+ expect(content).not.toContain('async listRolesOrganizations');
1755
+ });
1756
+
1757
+ it('deduplicates method names for operations on different paths', () => {
1758
+ const services: Service[] = [
1759
+ {
1760
+ name: 'Organizations',
1761
+ operations: [
1762
+ {
1763
+ name: 'create',
1764
+ httpMethod: 'post',
1765
+ path: '/organization_domains',
1766
+ pathParams: [],
1767
+ queryParams: [],
1768
+ headerParams: [],
1769
+ requestBody: { kind: 'model', name: 'CreateOrgDomain' },
1770
+ response: { kind: 'model', name: 'OrgDomain' },
1771
+ errors: [],
1772
+ injectIdempotencyKey: false,
1773
+ },
1774
+ {
1775
+ name: 'create',
1776
+ httpMethod: 'post',
1777
+ path: '/organizations',
1778
+ pathParams: [],
1779
+ queryParams: [],
1780
+ headerParams: [],
1781
+ requestBody: { kind: 'model', name: 'CreateOrg' },
1782
+ response: { kind: 'model', name: 'Organization' },
1783
+ errors: [],
1784
+ injectIdempotencyKey: false,
1785
+ },
1786
+ ],
1787
+ },
1788
+ ];
1789
+
1790
+ const ctxDedup: EmitterContext = {
1791
+ ...ctx,
1792
+ spec: {
1793
+ ...emptySpec,
1794
+ services,
1795
+ models: [
1796
+ { name: 'CreateOrgDomain', fields: [] },
1797
+ { name: 'OrgDomain', fields: [] },
1798
+ { name: 'CreateOrg', fields: [] },
1799
+ { name: 'Organization', fields: [] },
1800
+ ],
1801
+ },
1802
+ };
1803
+
1804
+ const files = generateResources(services, ctxDedup);
1805
+ expect(files.length).toBe(1);
1806
+ const content = files[0].content;
1807
+ // The best-scoring plan keeps the name; the other gets disambiguated.
1808
+ // "create" matches "organizations" path better (the word "create" doesn't
1809
+ // appear in either path, but scoring is equal — first wins).
1810
+ // The other gets a path suffix.
1811
+ const createMatches = content.match(/async create\b/g);
1812
+ // At most one un-suffixed "create"
1813
+ expect(createMatches?.length ?? 0).toBeLessThanOrEqual(1);
1814
+ // The two methods should have different names
1815
+ const methodNames = [...content.matchAll(/async (\w+)\(/g)].map((m) => m[1]);
1816
+ const createMethods = methodNames.filter((n) => n.toLowerCase().startsWith('create'));
1817
+ expect(new Set(createMethods).size).toBe(createMethods.length); // all unique
1818
+ });
260
1819
  });