@workos/oagen-emitters 0.3.0 → 0.5.0

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 (128) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint.yml +1 -1
  3. package/.github/workflows/release-please.yml +2 -2
  4. package/.github/workflows/release.yml +1 -1
  5. package/.husky/pre-push +11 -0
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +15 -0
  9. package/README.md +35 -224
  10. package/dist/index.d.mts +12 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -12737
  13. package/dist/plugin-BSop9f9z.mjs +21471 -0
  14. package/dist/plugin-BSop9f9z.mjs.map +1 -0
  15. package/dist/plugin.d.mts +7 -0
  16. package/dist/plugin.d.mts.map +1 -0
  17. package/dist/plugin.mjs +2 -0
  18. package/docs/sdk-architecture/dotnet.md +336 -0
  19. package/oagen.config.ts +5 -343
  20. package/package.json +10 -34
  21. package/smoke/sdk-dotnet.ts +45 -12
  22. package/src/dotnet/client.ts +89 -0
  23. package/src/dotnet/enums.ts +323 -0
  24. package/src/dotnet/fixtures.ts +236 -0
  25. package/src/dotnet/index.ts +248 -0
  26. package/src/dotnet/manifest.ts +36 -0
  27. package/src/dotnet/models.ts +320 -0
  28. package/src/dotnet/naming.ts +368 -0
  29. package/src/dotnet/resources.ts +943 -0
  30. package/src/dotnet/tests.ts +713 -0
  31. package/src/dotnet/type-map.ts +228 -0
  32. package/src/dotnet/wrappers.ts +197 -0
  33. package/src/go/client.ts +35 -3
  34. package/src/go/enums.ts +4 -0
  35. package/src/go/index.ts +15 -7
  36. package/src/go/models.ts +6 -1
  37. package/src/go/naming.ts +5 -17
  38. package/src/go/resources.ts +534 -73
  39. package/src/go/tests.ts +39 -3
  40. package/src/go/type-map.ts +8 -3
  41. package/src/go/wrappers.ts +79 -21
  42. package/src/index.ts +15 -0
  43. package/src/kotlin/client.ts +58 -0
  44. package/src/kotlin/enums.ts +189 -0
  45. package/src/kotlin/index.ts +92 -0
  46. package/src/kotlin/manifest.ts +55 -0
  47. package/src/kotlin/models.ts +486 -0
  48. package/src/kotlin/naming.ts +229 -0
  49. package/src/kotlin/overrides.ts +25 -0
  50. package/src/kotlin/resources.ts +998 -0
  51. package/src/kotlin/tests.ts +1133 -0
  52. package/src/kotlin/type-map.ts +123 -0
  53. package/src/kotlin/wrappers.ts +168 -0
  54. package/src/node/client.ts +84 -7
  55. package/src/node/field-plan.ts +12 -14
  56. package/src/node/fixtures.ts +39 -3
  57. package/src/node/index.ts +1 -0
  58. package/src/node/models.ts +281 -37
  59. package/src/node/resources.ts +319 -95
  60. package/src/node/tests.ts +108 -29
  61. package/src/node/type-map.ts +1 -31
  62. package/src/node/utils.ts +96 -6
  63. package/src/node/wrappers.ts +31 -1
  64. package/src/php/client.ts +11 -3
  65. package/src/php/models.ts +0 -33
  66. package/src/php/naming.ts +2 -21
  67. package/src/php/resources.ts +275 -19
  68. package/src/php/tests.ts +118 -18
  69. package/src/php/type-map.ts +16 -2
  70. package/src/php/wrappers.ts +7 -2
  71. package/src/plugin.ts +50 -0
  72. package/src/python/client.ts +50 -32
  73. package/src/python/enums.ts +35 -10
  74. package/src/python/index.ts +35 -27
  75. package/src/python/models.ts +139 -2
  76. package/src/python/naming.ts +2 -22
  77. package/src/python/resources.ts +234 -17
  78. package/src/python/tests.ts +260 -16
  79. package/src/python/type-map.ts +16 -2
  80. package/src/ruby/client.ts +238 -0
  81. package/src/ruby/enums.ts +149 -0
  82. package/src/ruby/index.ts +93 -0
  83. package/src/ruby/manifest.ts +35 -0
  84. package/src/ruby/models.ts +360 -0
  85. package/src/ruby/naming.ts +187 -0
  86. package/src/ruby/rbi.ts +313 -0
  87. package/src/ruby/resources.ts +799 -0
  88. package/src/ruby/tests.ts +459 -0
  89. package/src/ruby/type-map.ts +97 -0
  90. package/src/ruby/wrappers.ts +161 -0
  91. package/src/shared/model-utils.ts +357 -16
  92. package/src/shared/naming-utils.ts +83 -0
  93. package/src/shared/non-spec-services.ts +13 -0
  94. package/src/shared/resolved-ops.ts +75 -1
  95. package/src/shared/wrapper-utils.ts +12 -1
  96. package/test/dotnet/client.test.ts +121 -0
  97. package/test/dotnet/enums.test.ts +193 -0
  98. package/test/dotnet/errors.test.ts +9 -0
  99. package/test/dotnet/manifest.test.ts +82 -0
  100. package/test/dotnet/models.test.ts +258 -0
  101. package/test/dotnet/resources.test.ts +387 -0
  102. package/test/dotnet/tests.test.ts +202 -0
  103. package/test/entrypoint.test.ts +89 -0
  104. package/test/go/client.test.ts +6 -6
  105. package/test/go/resources.test.ts +156 -7
  106. package/test/kotlin/models.test.ts +135 -0
  107. package/test/kotlin/resources.test.ts +210 -0
  108. package/test/kotlin/tests.test.ts +176 -0
  109. package/test/node/client.test.ts +74 -0
  110. package/test/node/models.test.ts +134 -1
  111. package/test/node/resources.test.ts +343 -34
  112. package/test/node/utils.test.ts +140 -0
  113. package/test/php/client.test.ts +2 -1
  114. package/test/php/models.test.ts +5 -4
  115. package/test/php/resources.test.ts +103 -0
  116. package/test/php/tests.test.ts +67 -0
  117. package/test/plugin.test.ts +50 -0
  118. package/test/python/client.test.ts +56 -0
  119. package/test/python/models.test.ts +99 -0
  120. package/test/python/resources.test.ts +294 -0
  121. package/test/python/tests.test.ts +91 -0
  122. package/test/ruby/client.test.ts +81 -0
  123. package/test/ruby/resources.test.ts +386 -0
  124. package/test/shared/resolved-ops.test.ts +122 -0
  125. package/tsdown.config.ts +1 -1
  126. package/dist/index.mjs.map +0 -1
  127. package/scripts/generate-php.js +0 -13
  128. package/scripts/git-push-with-published-oagen.sh +0 -21
@@ -0,0 +1,210 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { defaultSdkBehavior, type ApiSpec, type EmitterContext, type Service } from '@workos/oagen';
3
+ import { generateResources } from '../../src/kotlin/resources.js';
4
+ import { generateEnums } from '../../src/kotlin/enums.js';
5
+
6
+ const baseSpec: ApiSpec = {
7
+ name: 'Test',
8
+ version: '1.0.0',
9
+ baseUrl: 'https://api.workos.com',
10
+ services: [],
11
+ models: [
12
+ {
13
+ name: 'AuthenticateResponse',
14
+ fields: [{ name: 'access_token', type: { kind: 'primitive', type: 'string' }, required: true }],
15
+ },
16
+ {
17
+ name: 'SSOTokenResponse',
18
+ fields: [{ name: 'access_token', type: { kind: 'primitive', type: 'string' }, required: true }],
19
+ },
20
+ ],
21
+ enums: [],
22
+ sdk: defaultSdkBehavior(),
23
+ };
24
+
25
+ function ctxFor(services: Service[], enums = baseSpec.enums): EmitterContext {
26
+ return {
27
+ namespace: 'workos',
28
+ namespacePascal: 'WorkOS',
29
+ spec: { ...baseSpec, services, enums },
30
+ resolvedOperations: services.flatMap((service) =>
31
+ service.operations.map((operation) => ({
32
+ service,
33
+ operation,
34
+ methodName: operation.name,
35
+ mountOn: service.name,
36
+ defaults: {},
37
+ inferFromClient: [],
38
+ urlBuilder: false,
39
+ })),
40
+ ),
41
+ };
42
+ }
43
+
44
+ describe('kotlin/resources', () => {
45
+ it('collapses duplicated query/body params into a single Kotlin parameter', () => {
46
+ const services: Service[] = [
47
+ {
48
+ name: 'SSO',
49
+ operations: [
50
+ {
51
+ name: 'getProfileAndToken',
52
+ httpMethod: 'post',
53
+ path: '/sso/token',
54
+ pathParams: [],
55
+ queryParams: [{ name: 'code', type: { kind: 'primitive', type: 'string' }, required: true }],
56
+ headerParams: [],
57
+ requestBody: { kind: 'model', name: 'GetProfileAndTokenRequest' },
58
+ response: { kind: 'model', name: 'SSOTokenResponse' },
59
+ errors: [],
60
+ injectIdempotencyKey: false,
61
+ },
62
+ ],
63
+ },
64
+ ];
65
+ const spec = {
66
+ ...baseSpec,
67
+ services,
68
+ models: [
69
+ ...baseSpec.models,
70
+ {
71
+ name: 'GetProfileAndTokenRequest',
72
+ fields: [{ name: 'code', type: { kind: 'primitive', type: 'string' }, required: true }],
73
+ },
74
+ ],
75
+ };
76
+ const files = generateResources(services, { ...ctxFor(services), spec: spec as ApiSpec });
77
+ const ssoFile = files.find((file) => file.path.endsWith('/Sso.kt'));
78
+ expect(ssoFile).toBeDefined();
79
+ expect(ssoFile!.content).toContain('fun getProfileAndToken(');
80
+ expect(ssoFile!.content).toContain('code: String');
81
+ expect(ssoFile!.content).not.toContain('bodyCode');
82
+ expect(ssoFile!.content).toContain('params += "code" to code');
83
+ expect(ssoFile!.content).toContain('"code" to code');
84
+ });
85
+
86
+ it('emits a shared authenticate helper for user management authenticate variants', () => {
87
+ const services: Service[] = [
88
+ {
89
+ name: 'UserManagement',
90
+ operations: [
91
+ {
92
+ name: 'authenticateWithPassword',
93
+ httpMethod: 'post',
94
+ path: '/user_management/authenticate',
95
+ pathParams: [],
96
+ queryParams: [],
97
+ headerParams: [],
98
+ requestBody: { kind: 'model', name: 'AuthenticatePasswordRequest' },
99
+ response: { kind: 'model', name: 'AuthenticateResponse' },
100
+ errors: [],
101
+ injectIdempotencyKey: false,
102
+ },
103
+ ],
104
+ },
105
+ ];
106
+ const spec = {
107
+ ...baseSpec,
108
+ services,
109
+ models: [
110
+ ...baseSpec.models,
111
+ {
112
+ name: 'AuthenticatePasswordRequest',
113
+ fields: [
114
+ { name: 'email', type: { kind: 'primitive', type: 'string' }, required: true },
115
+ { name: 'password', type: { kind: 'primitive', type: 'string' }, required: true },
116
+ ],
117
+ },
118
+ ],
119
+ };
120
+ const files = generateResources(services, { ...ctxFor(services), spec: spec as ApiSpec });
121
+ const userManagementFile = files.find((file) => file.path.endsWith('/UserManagement.kt'));
122
+ expect(userManagementFile).toBeDefined();
123
+ expect(userManagementFile!.content).toContain('private fun authenticate(');
124
+ expect(userManagementFile!.content).toContain('grantType = "authorization_code"');
125
+ });
126
+
127
+ it('renames package-level parameter group helpers to avoid Role/Password collisions', () => {
128
+ const services: Service[] = [
129
+ {
130
+ name: 'UserManagement',
131
+ operations: [
132
+ {
133
+ name: 'createUser',
134
+ httpMethod: 'post',
135
+ path: '/users',
136
+ pathParams: [],
137
+ queryParams: [],
138
+ headerParams: [],
139
+ requestBody: { kind: 'model', name: 'CreateUserRequest' },
140
+ response: { kind: 'model', name: 'AuthenticateResponse' },
141
+ errors: [],
142
+ injectIdempotencyKey: false,
143
+ parameterGroups: [
144
+ {
145
+ name: 'Password',
146
+ optional: true,
147
+ variants: [
148
+ {
149
+ name: 'Plaintext',
150
+ parameters: [{ name: 'password', type: { kind: 'primitive', type: 'string' }, required: true }],
151
+ },
152
+ ],
153
+ },
154
+ {
155
+ name: 'Role',
156
+ optional: true,
157
+ variants: [
158
+ {
159
+ name: 'Single',
160
+ parameters: [{ name: 'role_slug', type: { kind: 'primitive', type: 'string' }, required: true }],
161
+ },
162
+ ],
163
+ },
164
+ ],
165
+ },
166
+ ],
167
+ },
168
+ ];
169
+ const spec = {
170
+ ...baseSpec,
171
+ services,
172
+ models: [
173
+ ...baseSpec.models,
174
+ {
175
+ name: 'CreateUserRequest',
176
+ fields: [
177
+ { name: 'password', type: { kind: 'primitive', type: 'string' }, required: false },
178
+ { name: 'role_slug', type: { kind: 'primitive', type: 'string' }, required: false },
179
+ ],
180
+ },
181
+ ],
182
+ };
183
+ const files = generateResources(services, { ...ctxFor(services), spec: spec as ApiSpec });
184
+ const userManagementFile = files.find((file) => file.path.endsWith('/UserManagement.kt'));
185
+ expect(userManagementFile).toBeDefined();
186
+ expect(userManagementFile!.content).toContain('sealed class CreateUserPassword');
187
+ expect(userManagementFile!.content).toContain('sealed class CreateUserRole');
188
+ });
189
+
190
+ it('collapses asc/desc order enums into SortOrder', () => {
191
+ const enums = [
192
+ {
193
+ name: 'EventsOrder',
194
+ values: [{ value: 'asc' }, { value: 'desc' }],
195
+ },
196
+ {
197
+ name: 'OrganizationsOrder',
198
+ values: [{ value: 'asc' }, { value: 'desc' }],
199
+ },
200
+ ];
201
+
202
+ const files = generateEnums(enums as never, ctxFor([], enums as never));
203
+ const sortOrder = files.find((file) => file.path.endsWith('/SortOrder.kt'));
204
+ const aliases = files.filter((file) => file.path.endsWith('Order.kt') && !file.path.endsWith('/SortOrder.kt'));
205
+
206
+ expect(sortOrder).toBeDefined();
207
+ expect(sortOrder!.content).toContain('enum class SortOrder');
208
+ expect(aliases.length).toBeLessThanOrEqual(1);
209
+ });
210
+ });
@@ -0,0 +1,176 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateTests } from '../../src/kotlin/tests.js';
3
+ import { generateEnums } from '../../src/kotlin/enums.js';
4
+ import type { EmitterContext, ApiSpec, Service, Model, ResolvedOperation } from '@workos/oagen';
5
+ import { defaultSdkBehavior } from '@workos/oagen';
6
+
7
+ const models: Model[] = [
8
+ {
9
+ name: 'Organization',
10
+ fields: [
11
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
12
+ { name: 'name', type: { kind: 'primitive', type: 'string' }, required: true },
13
+ ],
14
+ },
15
+ ];
16
+
17
+ const services: Service[] = [
18
+ {
19
+ name: 'Organizations',
20
+ operations: [
21
+ {
22
+ name: 'getOrganization',
23
+ httpMethod: 'get',
24
+ path: '/organizations/{id}',
25
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
26
+ queryParams: [],
27
+ headerParams: [],
28
+ response: { kind: 'model', name: 'Organization' },
29
+ errors: [],
30
+ injectIdempotencyKey: false,
31
+ },
32
+ {
33
+ name: 'deleteOrganization',
34
+ httpMethod: 'delete',
35
+ path: '/organizations/{id}',
36
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
37
+ queryParams: [],
38
+ headerParams: [],
39
+ response: { kind: 'primitive', type: 'unknown' },
40
+ errors: [],
41
+ injectIdempotencyKey: false,
42
+ },
43
+ ],
44
+ },
45
+ ];
46
+
47
+ const spec: ApiSpec = {
48
+ name: 'TestAPI',
49
+ version: '1.0.0',
50
+ baseUrl: 'https://api.workos.com',
51
+ services,
52
+ models,
53
+ enums: [],
54
+ sdk: defaultSdkBehavior(),
55
+ };
56
+
57
+ function buildResolvedOps(services: Service[]): ResolvedOperation[] {
58
+ return services.flatMap((svc) =>
59
+ svc.operations.map((op) => ({
60
+ service: svc,
61
+ operation: op,
62
+ methodName: op.name,
63
+ mountOn: svc.name,
64
+ })),
65
+ ) as ResolvedOperation[];
66
+ }
67
+
68
+ const ctx: EmitterContext = {
69
+ namespace: 'workos',
70
+ namespacePascal: 'WorkOS',
71
+ spec,
72
+ resolvedOperations: buildResolvedOps(services),
73
+ };
74
+
75
+ describe('kotlin/tests', () => {
76
+ it('generates per-mount-group test files', () => {
77
+ generateEnums([], ctx);
78
+ const files = generateTests(spec, ctx);
79
+ const testFile = files.find((f) => f.path.includes('OrganizationsTest.kt'));
80
+ expect(testFile).toBeDefined();
81
+
82
+ const content = testFile!.content;
83
+ expect(content).toContain('class OrganizationsTest');
84
+ expect(content).toContain('TestBase');
85
+ expect(content).toContain('@Test');
86
+ });
87
+
88
+ it('generates happy-path test for void/delete methods', () => {
89
+ generateEnums([], ctx);
90
+ const files = generateTests(spec, ctx);
91
+ const testFile = files.find((f) => f.path.includes('OrganizationsTest.kt'))!;
92
+ const content = testFile.content;
93
+
94
+ // Delete method should have an active test, not a @Disabled placeholder.
95
+ // Method name is trimmed from deleteOrganization -> delete by resolveMethodName.
96
+ expect(content).toContain('delete completes without throwing');
97
+ expect(content).not.toContain('@Disabled("generator: could not synthesize required arguments for delete")');
98
+ });
99
+
100
+ it('generates field-value assertions for non-void responses', () => {
101
+ generateEnums([], ctx);
102
+ const files = generateTests(spec, ctx);
103
+ const testFile = files.find((f) => f.path.includes('OrganizationsTest.kt'))!;
104
+ const content = testFile.content;
105
+
106
+ // GET method returning Organization should assert field values
107
+ expect(content).toContain('assertEquals("sample", result.id)');
108
+ expect(content).toContain('assertEquals("sample", result.name)');
109
+ });
110
+
111
+ it('generates error-mapping tests', () => {
112
+ generateEnums([], ctx);
113
+ const files = generateTests(spec, ctx);
114
+ const testFile = files.find((f) => f.path.includes('OrganizationsTest.kt'))!;
115
+ const content = testFile.content;
116
+
117
+ expect(content).toContain('UnauthorizedException');
118
+ expect(content).toContain('NotFoundException');
119
+ expect(content).toContain('RateLimitException');
120
+ expect(content).toContain('GenericServerException');
121
+ });
122
+
123
+ it('generates round-trip test using synthJson for broader coverage', () => {
124
+ generateEnums([], ctx);
125
+ const files = generateTests(spec, ctx);
126
+ const roundTrip = files.find((f) => f.path.includes('GeneratedModelRoundTripTest.kt'));
127
+ expect(roundTrip).toBeDefined();
128
+
129
+ const content = roundTrip!.content;
130
+ expect(content).toContain('Organization round-trips through Jackson');
131
+ expect(content).toContain('assertEquals(tree1, tree2)');
132
+ });
133
+
134
+ it('generates forward-compat test with OffsetDateTime round-trip', () => {
135
+ generateEnums([], ctx);
136
+ const files = generateTests(spec, ctx);
137
+ const fwdCompat = files.find((f) => f.path.includes('GeneratedForwardCompatTest.kt'));
138
+ expect(fwdCompat).toBeDefined();
139
+
140
+ const content = fwdCompat!.content;
141
+ expect(content).toContain('OffsetDateTime round-trips');
142
+ expect(content).toContain('assertEquals(parsed.toInstant(), reparsed.toInstant())');
143
+ });
144
+
145
+ it('emits valid ISO-8601 for date-time fields in round-trip fixtures', () => {
146
+ const dtModels: Model[] = [
147
+ {
148
+ name: 'Event',
149
+ fields: [
150
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
151
+ {
152
+ name: 'created_at',
153
+ type: { kind: 'primitive', type: 'string', format: 'date-time' },
154
+ required: true,
155
+ },
156
+ ],
157
+ },
158
+ ];
159
+ const dtSpec: ApiSpec = { ...spec, models: dtModels };
160
+ const dtCtx: EmitterContext = {
161
+ ...ctx,
162
+ spec: dtSpec,
163
+ resolvedOperations: buildResolvedOps(services),
164
+ };
165
+
166
+ generateEnums([], dtCtx);
167
+ const files = generateTests(dtSpec, dtCtx);
168
+ const roundTrip = files.find((f) => f.path.includes('GeneratedModelRoundTripTest.kt'));
169
+ expect(roundTrip).toBeDefined();
170
+
171
+ const content = roundTrip!.content;
172
+ // Should use ISO-8601 timestamp, not "sample"
173
+ expect(content).toContain('2024-01-01T00:00:00Z');
174
+ expect(content).not.toMatch(/created_at.*"sample"/);
175
+ });
176
+ });
@@ -194,6 +194,80 @@ describe('generateClient', () => {
194
194
  expect(serviceBarrel!.content).toContain("export * from './authentication-factor.interface';");
195
195
  });
196
196
 
197
+ it('propagates @deprecated from baseline service class to the property declaration', () => {
198
+ // Regression test for PR #1535 reviewer comment r3075509969: when a
199
+ // service class has `@deprecated` in its JSDoc, TS's deprecation-lint
200
+ // only fires at `new X()` call sites — not at `workos.x` access unless
201
+ // the property itself is annotated. The emitter propagates the class
202
+ // deprecation to the property JSDoc so IDEs surface the strikethrough
203
+ // on every access.
204
+ const deprecatedCtx: EmitterContext = {
205
+ ...ctx,
206
+ apiSurface: {
207
+ language: 'node',
208
+ extractedFrom: 'test',
209
+ extractedAt: '2026-01-01',
210
+ classes: {
211
+ Organizations: {
212
+ name: 'Organizations',
213
+ methods: {},
214
+ properties: {},
215
+ constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
216
+ deprecationMessage: 'Use `workos.connect` instead.',
217
+ },
218
+ },
219
+ interfaces: {},
220
+ typeAliases: {},
221
+ enums: {},
222
+ exports: {},
223
+ },
224
+ };
225
+
226
+ const files = generateClient(spec, deprecatedCtx);
227
+ const workosFile = files.find((f) => f.path === 'src/workos.ts')!;
228
+ const content = workosFile.content;
229
+
230
+ // Property JSDoc carries the deprecation and the directive is preserved
231
+ // on the line immediately above the accessor.
232
+ expect(content).toMatch(
233
+ /\/\*\* @deprecated Use `workos\.connect` instead\. \*\/\s+readonly organizations = new Organizations\(this\);/,
234
+ );
235
+ });
236
+
237
+ it('emits a bare @deprecated when the baseline class deprecation has no message', () => {
238
+ const deprecatedCtx: EmitterContext = {
239
+ ...ctx,
240
+ apiSurface: {
241
+ language: 'node',
242
+ extractedFrom: 'test',
243
+ extractedAt: '2026-01-01',
244
+ classes: {
245
+ Organizations: {
246
+ name: 'Organizations',
247
+ methods: {},
248
+ properties: {},
249
+ constructorParams: [{ name: 'workos', type: 'WorkOS', optional: false }],
250
+ deprecationMessage: '',
251
+ },
252
+ },
253
+ interfaces: {},
254
+ typeAliases: {},
255
+ enums: {},
256
+ exports: {},
257
+ },
258
+ };
259
+
260
+ const files = generateClient(spec, deprecatedCtx);
261
+ const workosFile = files.find((f) => f.path === 'src/workos.ts')!;
262
+ expect(workosFile.content).toMatch(/\/\*\* @deprecated \*\/\s+readonly organizations = new Organizations\(this\);/);
263
+ });
264
+
265
+ it('does not emit @deprecated when the baseline class has no deprecationMessage', () => {
266
+ const files = generateClient(spec, ctx);
267
+ const workosFile = files.find((f) => f.path === 'src/workos.ts')!;
268
+ expect(workosFile.content).not.toContain('@deprecated');
269
+ });
270
+
197
271
  it('does not generate error handling in WorkOS client (lives in WorkOSBase)', () => {
198
272
  const files = generateClient(spec, ctx);
199
273
  const workosFile = files.find((f) => f.path === 'src/workos.ts')!;
@@ -182,6 +182,23 @@ describe('generateModels', () => {
182
182
  });
183
183
 
184
184
  it('handles generic type params', () => {
185
+ const service: Service = {
186
+ name: 'DirectorySync',
187
+ operations: [
188
+ {
189
+ name: 'getDirectoryUser',
190
+ httpMethod: 'get',
191
+ path: '/directory_users/{id}',
192
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
193
+ queryParams: [],
194
+ headerParams: [],
195
+ response: { kind: 'model', name: 'DirectoryUser' },
196
+ errors: [],
197
+ injectIdempotencyKey: false,
198
+ },
199
+ ],
200
+ };
201
+
185
202
  const models: Model[] = [
186
203
  {
187
204
  name: 'DirectoryUser',
@@ -204,7 +221,12 @@ describe('generateModels', () => {
204
221
  },
205
222
  ];
206
223
 
207
- const files = generateModels(models, ctx);
224
+ const ctxWithServices: EmitterContext = {
225
+ ...ctx,
226
+ spec: { ...emptySpec, services: [service], models },
227
+ };
228
+
229
+ const files = generateModels(models, ctxWithServices);
208
230
  expect(files[0].content).toContain('export interface DirectoryUser<TCustom = Record<string, any>> {');
209
231
  expect(files[0].content).toContain('export interface DirectoryUserResponse<TCustom = Record<string, any>> {');
210
232
  });
@@ -733,6 +755,23 @@ describe('model deduplication', () => {
733
755
  errors: [],
734
756
  injectIdempotencyKey: false,
735
757
  },
758
+ {
759
+ name: 'getOrganizationRole',
760
+ httpMethod: 'get',
761
+ path: '/organization_roles/{id}',
762
+ pathParams: [
763
+ {
764
+ name: 'id',
765
+ type: { kind: 'primitive', type: 'string' },
766
+ required: true,
767
+ },
768
+ ],
769
+ queryParams: [],
770
+ headerParams: [],
771
+ response: { kind: 'model', name: 'OrganizationRole' },
772
+ errors: [],
773
+ injectIdempotencyKey: false,
774
+ },
736
775
  ],
737
776
  };
738
777
 
@@ -794,4 +833,98 @@ describe('model deduplication', () => {
794
833
  expect(files[1].content).toContain('export type OrganizationRole = EnvironmentRole');
795
834
  expect(files[1].content).toContain('export type OrganizationRoleResponse = EnvironmentRoleResponse');
796
835
  });
836
+
837
+ it('generates Date type for date-time fields even when baseline says string', () => {
838
+ const service: Service = {
839
+ name: 'Authorization',
840
+ operations: [
841
+ {
842
+ name: 'getRoleAssignment',
843
+ httpMethod: 'get',
844
+ path: '/role_assignments/{id}',
845
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
846
+ queryParams: [],
847
+ headerParams: [],
848
+ response: { kind: 'model', name: 'RoleAssignment' },
849
+ errors: [],
850
+ injectIdempotencyKey: false,
851
+ },
852
+ ],
853
+ };
854
+
855
+ const models: Model[] = [
856
+ {
857
+ name: 'RoleAssignment',
858
+ fields: [
859
+ {
860
+ name: 'id',
861
+ type: { kind: 'primitive', type: 'string' },
862
+ required: true,
863
+ },
864
+ {
865
+ name: 'created_at',
866
+ type: { kind: 'primitive', type: 'string', format: 'date-time' },
867
+ required: true,
868
+ },
869
+ {
870
+ name: 'updated_at',
871
+ type: { kind: 'primitive', type: 'string', format: 'date-time' },
872
+ required: true,
873
+ },
874
+ // Extra field not in baseline so modelHasNewFields returns true
875
+ // (allowing the dedup test to proceed with generation)
876
+ {
877
+ name: 'role_name',
878
+ type: { kind: 'primitive', type: 'string' },
879
+ required: true,
880
+ },
881
+ ],
882
+ },
883
+ ];
884
+
885
+ const ctxWithBaseline: EmitterContext = {
886
+ ...ctx,
887
+ spec: { ...emptySpec, services: [service], models },
888
+ apiSurface: {
889
+ language: 'node',
890
+ extractedFrom: 'test',
891
+ extractedAt: '2024-01-01',
892
+ classes: {},
893
+ typeAliases: {},
894
+ enums: {},
895
+ exports: {},
896
+ interfaces: {
897
+ RoleAssignment: {
898
+ name: 'RoleAssignment',
899
+ fields: {
900
+ id: { name: 'id', type: 'string', optional: false },
901
+ createdAt: { name: 'createdAt', type: 'string', optional: false },
902
+ updatedAt: { name: 'updatedAt', type: 'string', optional: false },
903
+ },
904
+ extends: [],
905
+ },
906
+ RoleAssignmentResponse: {
907
+ name: 'RoleAssignmentResponse',
908
+ fields: {
909
+ id: { name: 'id', type: 'string', optional: false },
910
+ created_at: { name: 'created_at', type: 'string', optional: false },
911
+ updated_at: { name: 'updated_at', type: 'string', optional: false },
912
+ },
913
+ extends: [],
914
+ },
915
+ },
916
+ },
917
+ };
918
+
919
+ const files = generateModels(models, ctxWithBaseline);
920
+ const content = files[0].content;
921
+
922
+ // Domain interface should use Date, not string from baseline
923
+ expect(content).toContain(' createdAt: Date;');
924
+ expect(content).toContain(' updatedAt: Date;');
925
+
926
+ // Wire interface should stay as string (JSON native)
927
+ expect(content).toContain(' created_at: string;');
928
+ expect(content).toContain(' updated_at: string;');
929
+ });
797
930
  });