@workos/oagen-emitters 0.3.0 → 0.4.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 (65) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +7 -0
  3. package/dist/index.d.mts +4 -1
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +3288 -791
  6. package/dist/index.mjs.map +1 -1
  7. package/docs/sdk-architecture/dotnet.md +336 -0
  8. package/oagen.config.ts +42 -12
  9. package/package.json +2 -2
  10. package/smoke/sdk-dotnet.ts +45 -12
  11. package/src/dotnet/client.ts +89 -0
  12. package/src/dotnet/enums.ts +323 -0
  13. package/src/dotnet/fixtures.ts +236 -0
  14. package/src/dotnet/index.ts +246 -0
  15. package/src/dotnet/manifest.ts +36 -0
  16. package/src/dotnet/models.ts +344 -0
  17. package/src/dotnet/naming.ts +330 -0
  18. package/src/dotnet/resources.ts +622 -0
  19. package/src/dotnet/tests.ts +693 -0
  20. package/src/dotnet/type-map.ts +201 -0
  21. package/src/dotnet/wrappers.ts +186 -0
  22. package/src/go/index.ts +5 -2
  23. package/src/go/naming.ts +5 -17
  24. package/src/index.ts +1 -0
  25. package/src/kotlin/client.ts +53 -0
  26. package/src/kotlin/enums.ts +162 -0
  27. package/src/kotlin/index.ts +92 -0
  28. package/src/kotlin/manifest.ts +55 -0
  29. package/src/kotlin/models.ts +395 -0
  30. package/src/kotlin/naming.ts +223 -0
  31. package/src/kotlin/overrides.ts +25 -0
  32. package/src/kotlin/resources.ts +667 -0
  33. package/src/kotlin/tests.ts +1019 -0
  34. package/src/kotlin/type-map.ts +123 -0
  35. package/src/kotlin/wrappers.ts +168 -0
  36. package/src/node/client.ts +50 -0
  37. package/src/node/index.ts +1 -0
  38. package/src/node/resources.ts +164 -44
  39. package/src/node/tests.ts +37 -7
  40. package/src/php/client.ts +11 -3
  41. package/src/php/naming.ts +2 -21
  42. package/src/php/resources.ts +81 -6
  43. package/src/php/tests.ts +93 -17
  44. package/src/php/wrappers.ts +1 -0
  45. package/src/python/client.ts +37 -29
  46. package/src/python/enums.ts +7 -7
  47. package/src/python/models.ts +1 -1
  48. package/src/python/naming.ts +2 -22
  49. package/src/shared/model-utils.ts +232 -15
  50. package/src/shared/naming-utils.ts +47 -0
  51. package/src/shared/wrapper-utils.ts +12 -1
  52. package/test/dotnet/client.test.ts +121 -0
  53. package/test/dotnet/enums.test.ts +193 -0
  54. package/test/dotnet/errors.test.ts +9 -0
  55. package/test/dotnet/manifest.test.ts +82 -0
  56. package/test/dotnet/models.test.ts +260 -0
  57. package/test/dotnet/resources.test.ts +255 -0
  58. package/test/dotnet/tests.test.ts +202 -0
  59. package/test/kotlin/models.test.ts +135 -0
  60. package/test/kotlin/tests.test.ts +176 -0
  61. package/test/node/client.test.ts +74 -0
  62. package/test/node/resources.test.ts +216 -15
  63. package/test/php/client.test.ts +2 -1
  64. package/test/php/resources.test.ts +38 -0
  65. package/test/php/tests.test.ts +67 -0
@@ -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')!;
@@ -102,13 +102,93 @@ describe('generateResources', () => {
102
102
  // Should have AutoPaginatable type import and createPaginatedList import
103
103
  expect(content).toContain("import type { AutoPaginatable } from '../common/utils/pagination'");
104
104
  expect(content).toContain("import { createPaginatedList } from '../common/utils/fetch-and-deserialize'");
105
+ // Options interface lives in its own file under interfaces/ so the
106
+ // per-service barrel picks it up.
107
+ expect(content).toContain(
108
+ "import type { ListOrganizationsOptions } from './interfaces/list-organizations-options.interface';",
109
+ );
105
110
 
106
- // Should generate options interface
107
- expect(content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
108
- expect(content).toContain('domains?: string[];');
111
+ // The options interface file is emitted separately.
112
+ const optionsFile = files.find(
113
+ (f) => f.path === 'src/organizations/interfaces/list-organizations-options.interface.ts',
114
+ );
115
+ expect(optionsFile).toBeDefined();
116
+ expect(optionsFile!.content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
117
+ expect(optionsFile!.content).toContain('domains?: string[];');
109
118
 
110
119
  // Should return AutoPaginatable
111
120
  expect(content).toContain('Promise<AutoPaginatable<Organization, ListOrganizationsOptions>>');
121
+
122
+ // `domains` has the same camelCase and snake_case spelling, so no wire
123
+ // serializer should be emitted and createPaginatedList should be called
124
+ // with just options (no 5th arg).
125
+ expect(content).not.toContain('serializeListOrganizationsOptions');
126
+ expect(content).toMatch(/createPaginatedList<[^>]+>\([^)]+options\);/);
127
+ });
128
+
129
+ it('emits wire-options serializer for paginated list with camelCase filter fields', () => {
130
+ // Regression test for PR #1535 reviewer comment r3075477146: list
131
+ // methods whose extended filter fields have divergent camelCase/snake_case
132
+ // spellings (e.g. `organizationId` ↔ `organization_id`) must translate
133
+ // keys before hitting the wire, otherwise the API silently ignores them.
134
+ const services: Service[] = [
135
+ {
136
+ name: 'Applications',
137
+ operations: [
138
+ {
139
+ name: 'listApplications',
140
+ httpMethod: 'get',
141
+ path: '/connect/applications',
142
+ pathParams: [],
143
+ queryParams: [
144
+ {
145
+ name: 'organization_id',
146
+ type: { kind: 'primitive', type: 'string' },
147
+ required: false,
148
+ description: 'Filter by organization ID.',
149
+ },
150
+ ],
151
+ headerParams: [],
152
+ response: { kind: 'model', name: 'ConnectApplication' },
153
+ errors: [],
154
+ pagination: {
155
+ strategy: 'cursor',
156
+ param: 'after',
157
+ dataPath: 'data',
158
+ itemType: { kind: 'model', name: 'ConnectApplication' },
159
+ },
160
+ injectIdempotencyKey: false,
161
+ },
162
+ ],
163
+ },
164
+ ];
165
+
166
+ const files = generateResources(services, ctx);
167
+ const content = files[0].content;
168
+
169
+ // Options interface uses camelCase (user-facing) and lives in its own file.
170
+ const optionsFile = files.find(
171
+ (f) => f.path === 'src/applications/interfaces/list-applications-options.interface.ts',
172
+ );
173
+ expect(optionsFile).toBeDefined();
174
+ expect(optionsFile!.content).toContain('export interface ListApplicationsOptions extends PaginationOptions {');
175
+ expect(optionsFile!.content).toContain('organizationId?: string;');
176
+
177
+ // Resource class imports the options type from the interface file.
178
+ expect(content).toContain(
179
+ "import type { ListApplicationsOptions } from './interfaces/list-applications-options.interface';",
180
+ );
181
+
182
+ // Wire-options serializer emits snake_case key for the extension field
183
+ // and leaves standard pagination fields unchanged.
184
+ expect(content).toContain(
185
+ 'const serializeListApplicationsOptions = (options: ListApplicationsOptions): PaginationOptions => {',
186
+ );
187
+ expect(content).toContain('wire.organization_id = options.organizationId');
188
+ expect(content).not.toContain('wire.organizationId');
189
+
190
+ // createPaginatedList is invoked with the serializer as the 5th arg.
191
+ expect(content).toMatch(/createPaginatedList<[^>]+>\([^)]+options,\s*serializeListApplicationsOptions\);/);
112
192
  });
113
193
 
114
194
  it('uses item type not list wrapper type for paginated methods', () => {
@@ -191,6 +271,47 @@ describe('generateResources', () => {
191
271
  expect(content).toContain('await this.workos.delete(');
192
272
  });
193
273
 
274
+ it('generates unpaginated GET returning an array of models', () => {
275
+ // Regression test for PR #1535 reviewer comment (r3074705330): endpoints
276
+ // whose OpenAPI response is `type: array` must return `Model[]` and map
277
+ // the deserializer over each element, not treat the array as a single
278
+ // object (which silently produces garbage at runtime).
279
+ const services: Service[] = [
280
+ {
281
+ name: 'Secrets',
282
+ operations: [
283
+ {
284
+ name: 'listSecrets',
285
+ httpMethod: 'get',
286
+ path: '/applications/{id}/secrets',
287
+ pathParams: [
288
+ {
289
+ name: 'id',
290
+ type: { kind: 'primitive', type: 'string' },
291
+ required: true,
292
+ },
293
+ ],
294
+ queryParams: [],
295
+ headerParams: [],
296
+ response: { kind: 'array', items: { kind: 'model', name: 'Secret' } },
297
+ errors: [],
298
+ injectIdempotencyKey: false,
299
+ },
300
+ ],
301
+ },
302
+ ];
303
+
304
+ const files = generateResources(services, ctx);
305
+ const content = files[0].content;
306
+
307
+ expect(content).toContain('async listSecrets(id: string): Promise<Secret[]>');
308
+ expect(content).toContain('this.workos.get<SecretResponse[]>');
309
+ expect(content).toContain('return data.map(deserializeSecret);');
310
+ // Should NOT produce the single-object form — that was the bug.
311
+ expect(content).not.toMatch(/Promise<Secret>\s*\{/);
312
+ expect(content).not.toContain('return deserializeSecret(data);');
313
+ });
314
+
194
315
  it('generates POST method with body and idempotency', () => {
195
316
  const services: Service[] = [
196
317
  {
@@ -891,14 +1012,20 @@ describe('generateResources', () => {
891
1012
  // Should use the union type for the payload parameter
892
1013
  expect(content).toContain('payload: AuthByPassword | AuthByCode | AuthByMagicAuth');
893
1014
 
894
- // Should dispatch to the correct serializer based on the discriminator
895
- expect(content).toContain('switch ((payload as any).grantType)');
896
- expect(content).toContain("case 'password': return serializeAuthByPassword(payload as any)");
897
- expect(content).toContain("case 'authorization_code': return serializeAuthByCode(payload as any)");
1015
+ // Should dispatch to the correct serializer based on the discriminator,
1016
+ // using the typed discriminator so TS narrows payload per case.
1017
+ expect(content).toContain('switch (payload.grantType)');
1018
+ expect(content).toContain("case 'password': return serializeAuthByPassword(payload)");
1019
+ expect(content).toContain("case 'authorization_code': return serializeAuthByCode(payload)");
898
1020
  expect(content).toContain(
899
- "case 'urn:workos:oauth:grant-type:magic-auth:code': return serializeAuthByMagicAuth(payload as any)",
1021
+ "case 'urn:workos:oauth:grant-type:magic-auth:code': return serializeAuthByMagicAuth(payload)",
900
1022
  );
901
1023
 
1024
+ // Should not use `as any` casts — TS discriminated-union narrowing makes
1025
+ // them unnecessary and they suppress real type mismatches.
1026
+ expect(content).not.toContain('switch ((payload as any)');
1027
+ expect(content).not.toMatch(/return serialize\w+\(payload as any\)/);
1028
+
902
1029
  // Should import serializers for all union variants
903
1030
  expect(content).toContain('serializeAuthByPassword');
904
1031
  expect(content).toContain('serializeAuthByCode');
@@ -906,6 +1033,13 @@ describe('generateResources', () => {
906
1033
 
907
1034
  // Should NOT pass payload directly without serialization
908
1035
  expect(content).not.toMatch(/,\n\s+payload,\n/);
1036
+
1037
+ // Default branch must throw — silently forwarding unserialized camelCase
1038
+ // to the API produces malformed requests when the discriminator is unknown.
1039
+ expect(content).toContain('default:');
1040
+ expect(content).toContain('const _unknown: never = payload');
1041
+ expect(content).toContain('throw new Error');
1042
+ expect(content).not.toMatch(/default:\s*return payload/);
909
1043
  });
910
1044
 
911
1045
  it('generates discriminated union serializer dispatch for void method', () => {
@@ -945,10 +1079,10 @@ describe('generateResources', () => {
945
1079
  const files = generateResources(services, ctx);
946
1080
  const content = files[0].content;
947
1081
 
948
- // Should dispatch to the correct serializer
949
- expect(content).toContain('switch ((payload as any).grantType)');
950
- expect(content).toContain("case 'authorization_code': return serializeTokenByCode(payload as any)");
951
- expect(content).toContain("case 'refresh_token': return serializeTokenByRefresh(payload as any)");
1082
+ // Should dispatch to the correct serializer using the typed discriminator.
1083
+ expect(content).toContain('switch (payload.grantType)');
1084
+ expect(content).toContain("case 'authorization_code': return serializeTokenByCode(payload)");
1085
+ expect(content).toContain("case 'refresh_token': return serializeTokenByRefresh(payload)");
952
1086
  });
953
1087
 
954
1088
  it('uses createPaginatedList helper in paginated methods', () => {
@@ -1048,10 +1182,13 @@ describe('generateResources', () => {
1048
1182
  const content = files[0].content;
1049
1183
 
1050
1184
  // Should use service-prefixed options name instead of generic "ListOptions"
1051
- expect(content).toContain('export interface PaymentsListOptions extends PaginationOptions {');
1185
+ const optionsFile = files.find((f) => f.path.endsWith('payments-list-options.interface.ts'));
1186
+ expect(optionsFile).toBeDefined();
1187
+ expect(optionsFile!.content).toContain('export interface PaymentsListOptions extends PaginationOptions {');
1052
1188
  expect(content).toContain('Promise<AutoPaginatable<Connection, PaymentsListOptions>>');
1053
1189
  // Should NOT use the generic "ListOptions"
1054
1190
  expect(content).not.toContain('export interface ListOptions ');
1191
+ expect(files.every((f) => !f.path.endsWith('/list-options.interface.ts'))).toBe(true);
1055
1192
  });
1056
1193
 
1057
1194
  it('does not prefix ListOptions when method is not "list"', () => {
@@ -1090,10 +1227,11 @@ describe('generateResources', () => {
1090
1227
  ];
1091
1228
 
1092
1229
  const files = generateResources(services, ctx);
1093
- const content = files[0].content;
1094
1230
 
1095
1231
  // Method is "listOrganizations", not "list", so options name should be normal
1096
- expect(content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
1232
+ const optionsFile = files.find((f) => f.path.endsWith('list-organizations-options.interface.ts'));
1233
+ expect(optionsFile).toBeDefined();
1234
+ expect(optionsFile!.content).toContain('export interface ListOrganizationsOptions extends PaginationOptions {');
1097
1235
  });
1098
1236
 
1099
1237
  it('removes skipIfExists when fully-covered service has methods absent from baseline', () => {
@@ -1274,6 +1412,69 @@ describe('generateResources', () => {
1274
1412
  // skipIfExists should stay true because all methods exist in baseline
1275
1413
  expect(files[0].skipIfExists).toBe(true);
1276
1414
  });
1415
+
1416
+ it('removes skipIfExists for purely oagen-managed services (no baseline)', () => {
1417
+ const services: Service[] = [
1418
+ {
1419
+ name: 'Applications',
1420
+ operations: [
1421
+ {
1422
+ name: 'create',
1423
+ httpMethod: 'post',
1424
+ path: '/connect/applications',
1425
+ pathParams: [],
1426
+ queryParams: [],
1427
+ headerParams: [],
1428
+ response: { kind: 'model', name: 'ConnectApplication' },
1429
+ errors: [],
1430
+ injectIdempotencyKey: false,
1431
+ },
1432
+ ],
1433
+ },
1434
+ ];
1435
+
1436
+ const overlayCtx: EmitterContext = {
1437
+ namespace: 'workos',
1438
+ namespacePascal: 'WorkOS',
1439
+ spec: { ...emptySpec, services, models: [] },
1440
+ overlayLookup: {
1441
+ methodByOperation: new Map([
1442
+ [
1443
+ 'POST /connect/applications',
1444
+ {
1445
+ className: 'Applications',
1446
+ methodName: 'create',
1447
+ params: [],
1448
+ returnType: 'ConnectApplication',
1449
+ },
1450
+ ],
1451
+ ]),
1452
+ httpKeyByMethod: new Map(),
1453
+ interfaceByName: new Map(),
1454
+ typeAliasByName: new Map(),
1455
+ requiredExports: new Map(),
1456
+ modelNameByIR: new Map(),
1457
+ fileBySymbol: new Map(),
1458
+ },
1459
+ apiSurface: {
1460
+ language: 'node',
1461
+ extractedFrom: 'test',
1462
+ extractedAt: '2024-01-01',
1463
+ // No baseline class for Applications — purely oagen-managed.
1464
+ classes: {},
1465
+ interfaces: {},
1466
+ typeAliases: {},
1467
+ enums: {},
1468
+ exports: {},
1469
+ },
1470
+ };
1471
+
1472
+ const files = generateResources(services, overlayCtx);
1473
+ expect(files.length).toBe(1);
1474
+
1475
+ // skipIfExists must be removed so emitter improvements always overwrite.
1476
+ expect(files[0].skipIfExists).toBeUndefined();
1477
+ });
1277
1478
  });
1278
1479
 
1279
1480
  describe('resolveResourceClassName', () => {
@@ -74,8 +74,9 @@ describe('generateClient', () => {
74
74
  expect(result[0].content).toContain("string $baseUrl = 'https://api.example.com'");
75
75
  expect(result[0].content).toContain('int $timeout = 60');
76
76
  expect(result[0].content).toContain('int $maxRetries = 3');
77
+ expect(result[0].content).toContain('?string $userAgent = null');
77
78
  expect(result[0].content).toContain(
78
- 'new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler)',
79
+ 'new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler, $userAgent)',
79
80
  );
80
81
  expect(result[0].content).not.toContain('self::$apiKey = $apiKey;');
81
82
  expect(result[0].content).not.toContain('self::$clientId = $clientId;');
@@ -470,6 +470,44 @@ describe('generateResources', () => {
470
470
  // Should inject default and inferred values into query
471
471
  expect(content).toContain("'response_type' => 'code'");
472
472
  expect(content).toContain("$query['client_id'] = $this->client->requireClientId()");
473
+
474
+ // Redirect endpoint: should return string and build URL, not make HTTP request
475
+ expect(content).toContain('): string {');
476
+ expect(content).toContain('$this->client->buildUrl(');
477
+ expect(content).not.toContain('$this->client->request(');
478
+ expect(content).toContain('@return string');
479
+ // Should pass $options to buildUrl for base URL overrides
480
+ expect(content).toContain('$options);');
481
+ });
482
+
483
+ it('generates redirect endpoint that builds URL for GET with primitive unknown response', () => {
484
+ const logoutServices: Service[] = [
485
+ {
486
+ name: 'SSO',
487
+ operations: [
488
+ {
489
+ name: 'getLogoutUrl',
490
+ httpMethod: 'get',
491
+ path: '/sso/logout',
492
+ pathParams: [],
493
+ queryParams: [{ name: 'token', type: { kind: 'primitive', type: 'string' }, required: true }],
494
+ headerParams: [],
495
+ response: { kind: 'primitive', type: 'unknown' },
496
+ errors: [],
497
+ injectIdempotencyKey: false,
498
+ },
499
+ ],
500
+ },
501
+ ];
502
+
503
+ const spec = { ...emptySpec, services: logoutServices };
504
+ const result = generateResources(logoutServices, { ...ctx, spec });
505
+ const content = result[0].content;
506
+
507
+ expect(content).toContain('): string {');
508
+ expect(content).toContain("return $this->client->buildUrl('sso/logout', $query, $options);");
509
+ expect(content).not.toContain('$this->client->request(');
510
+ expect(content).toContain('@return string');
473
511
  });
474
512
 
475
513
  it('skips base method when wrappers exist', () => {