@workos/oagen-emitters 0.2.1 → 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 (136) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +13 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +14549 -3385
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/dotnet.md +336 -0
  10. package/docs/sdk-architecture/go.md +338 -0
  11. package/docs/sdk-architecture/php.md +315 -0
  12. package/docs/sdk-architecture/python.md +511 -0
  13. package/oagen.config.ts +328 -2
  14. package/package.json +9 -5
  15. package/scripts/generate-php.js +13 -0
  16. package/scripts/git-push-with-published-oagen.sh +21 -0
  17. package/smoke/sdk-dotnet.ts +45 -12
  18. package/smoke/sdk-go.ts +116 -42
  19. package/smoke/sdk-php.ts +28 -26
  20. package/smoke/sdk-python.ts +5 -2
  21. package/src/dotnet/client.ts +89 -0
  22. package/src/dotnet/enums.ts +323 -0
  23. package/src/dotnet/fixtures.ts +236 -0
  24. package/src/dotnet/index.ts +246 -0
  25. package/src/dotnet/manifest.ts +36 -0
  26. package/src/dotnet/models.ts +344 -0
  27. package/src/dotnet/naming.ts +330 -0
  28. package/src/dotnet/resources.ts +622 -0
  29. package/src/dotnet/tests.ts +693 -0
  30. package/src/dotnet/type-map.ts +201 -0
  31. package/src/dotnet/wrappers.ts +186 -0
  32. package/src/go/client.ts +141 -0
  33. package/src/go/enums.ts +196 -0
  34. package/src/go/fixtures.ts +212 -0
  35. package/src/go/index.ts +84 -0
  36. package/src/go/manifest.ts +36 -0
  37. package/src/go/models.ts +254 -0
  38. package/src/go/naming.ts +179 -0
  39. package/src/go/resources.ts +827 -0
  40. package/src/go/tests.ts +751 -0
  41. package/src/go/type-map.ts +82 -0
  42. package/src/go/wrappers.ts +261 -0
  43. package/src/index.ts +4 -0
  44. package/src/kotlin/client.ts +53 -0
  45. package/src/kotlin/enums.ts +162 -0
  46. package/src/kotlin/index.ts +92 -0
  47. package/src/kotlin/manifest.ts +55 -0
  48. package/src/kotlin/models.ts +395 -0
  49. package/src/kotlin/naming.ts +223 -0
  50. package/src/kotlin/overrides.ts +25 -0
  51. package/src/kotlin/resources.ts +667 -0
  52. package/src/kotlin/tests.ts +1019 -0
  53. package/src/kotlin/type-map.ts +123 -0
  54. package/src/kotlin/wrappers.ts +168 -0
  55. package/src/node/client.ts +128 -115
  56. package/src/node/enums.ts +9 -0
  57. package/src/node/errors.ts +37 -232
  58. package/src/node/field-plan.ts +726 -0
  59. package/src/node/fixtures.ts +9 -1
  60. package/src/node/index.ts +3 -9
  61. package/src/node/models.ts +178 -21
  62. package/src/node/naming.ts +49 -111
  63. package/src/node/resources.ts +527 -397
  64. package/src/node/sdk-errors.ts +41 -0
  65. package/src/node/tests.ts +69 -19
  66. package/src/node/type-map.ts +4 -2
  67. package/src/node/utils.ts +13 -71
  68. package/src/node/wrappers.ts +151 -0
  69. package/src/php/client.ts +179 -0
  70. package/src/php/enums.ts +67 -0
  71. package/src/php/errors.ts +9 -0
  72. package/src/php/fixtures.ts +181 -0
  73. package/src/php/index.ts +96 -0
  74. package/src/php/manifest.ts +36 -0
  75. package/src/php/models.ts +310 -0
  76. package/src/php/naming.ts +279 -0
  77. package/src/php/resources.ts +636 -0
  78. package/src/php/tests.ts +609 -0
  79. package/src/php/type-map.ts +90 -0
  80. package/src/php/utils.ts +18 -0
  81. package/src/php/wrappers.ts +152 -0
  82. package/src/python/client.ts +345 -0
  83. package/src/python/enums.ts +313 -0
  84. package/src/python/fixtures.ts +196 -0
  85. package/src/python/index.ts +95 -0
  86. package/src/python/manifest.ts +38 -0
  87. package/src/python/models.ts +688 -0
  88. package/src/python/naming.ts +189 -0
  89. package/src/python/resources.ts +1322 -0
  90. package/src/python/tests.ts +1335 -0
  91. package/src/python/type-map.ts +93 -0
  92. package/src/python/wrappers.ts +191 -0
  93. package/src/shared/model-utils.ts +472 -0
  94. package/src/shared/naming-utils.ts +154 -0
  95. package/src/shared/non-spec-services.ts +54 -0
  96. package/src/shared/resolved-ops.ts +109 -0
  97. package/src/shared/wrapper-utils.ts +70 -0
  98. package/test/dotnet/client.test.ts +121 -0
  99. package/test/dotnet/enums.test.ts +193 -0
  100. package/test/dotnet/errors.test.ts +9 -0
  101. package/test/dotnet/manifest.test.ts +82 -0
  102. package/test/dotnet/models.test.ts +260 -0
  103. package/test/dotnet/resources.test.ts +255 -0
  104. package/test/dotnet/tests.test.ts +202 -0
  105. package/test/go/client.test.ts +92 -0
  106. package/test/go/enums.test.ts +132 -0
  107. package/test/go/errors.test.ts +9 -0
  108. package/test/go/models.test.ts +265 -0
  109. package/test/go/resources.test.ts +408 -0
  110. package/test/go/tests.test.ts +143 -0
  111. package/test/kotlin/models.test.ts +135 -0
  112. package/test/kotlin/tests.test.ts +176 -0
  113. package/test/node/client.test.ts +92 -12
  114. package/test/node/enums.test.ts +2 -0
  115. package/test/node/errors.test.ts +2 -41
  116. package/test/node/models.test.ts +2 -0
  117. package/test/node/naming.test.ts +23 -0
  118. package/test/node/resources.test.ts +315 -84
  119. package/test/node/serializers.test.ts +3 -1
  120. package/test/node/type-map.test.ts +11 -0
  121. package/test/php/client.test.ts +95 -0
  122. package/test/php/enums.test.ts +173 -0
  123. package/test/php/errors.test.ts +9 -0
  124. package/test/php/models.test.ts +497 -0
  125. package/test/php/resources.test.ts +682 -0
  126. package/test/php/tests.test.ts +185 -0
  127. package/test/python/client.test.ts +200 -0
  128. package/test/python/enums.test.ts +228 -0
  129. package/test/python/errors.test.ts +16 -0
  130. package/test/python/manifest.test.ts +74 -0
  131. package/test/python/models.test.ts +716 -0
  132. package/test/python/resources.test.ts +617 -0
  133. package/test/python/tests.test.ts +202 -0
  134. package/src/node/common.ts +0 -273
  135. package/src/node/config.ts +0 -71
  136. package/src/node/serializers.ts +0 -746
@@ -0,0 +1,751 @@
1
+ import type { ApiSpec, Service, Operation, EmitterContext, GeneratedFile } from '@workos/oagen';
2
+ import { planOperation, toSnakeCase } from '@workos/oagen';
3
+ import { fileName, fieldName as goFieldName, resolveMethodName, methodName as goMethodName } from './naming.js';
4
+ import { resolveResourceClassName, paramsStructName, sortPathParamsByTemplateOrder } from './resources.js';
5
+ import { buildServiceAccessPaths } from './client.js';
6
+ import { generateFixtures } from './fixtures.js';
7
+ import { isListWrapperModel } from './models.js';
8
+ import { groupByMount, buildResolvedLookup, lookupResolved, buildHiddenParams } from '../shared/resolved-ops.js';
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { resolve } from 'node:path';
11
+
12
+ const DEFAULT_MODULE_PATH = 'github.com/workos/workos-go/v6';
13
+
14
+ /** Resolve the Go module path from the output directory's go.mod, or use default. */
15
+ function resolveModulePath(ctx: EmitterContext): string {
16
+ if (ctx.outputDir) {
17
+ const goModPath = resolve(ctx.outputDir, 'go.mod');
18
+ if (existsSync(goModPath)) {
19
+ const content = readFileSync(goModPath, 'utf-8');
20
+ const match = content.match(/^module\s+(\S+)/m);
21
+ if (match) return match[1];
22
+ }
23
+ }
24
+ return DEFAULT_MODULE_PATH;
25
+ }
26
+
27
+ /**
28
+ * Generate Go test files and JSON fixtures.
29
+ */
30
+ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
31
+ const files: GeneratedFile[] = [];
32
+
33
+ // Generate shared test helpers file
34
+ const helperLines: string[] = [];
35
+ helperLines.push(`package ${ctx.namespace}_test`);
36
+ helperLines.push('');
37
+ helperLines.push('import (');
38
+ helperLines.push('\t"net/http"');
39
+ helperLines.push('\t"net/http/httptest"');
40
+ helperLines.push('\t"os"');
41
+ helperLines.push('\t"testing"');
42
+ helperLines.push('');
43
+ helperLines.push(`\t"${resolveModulePath(ctx)}"`);
44
+ helperLines.push('\t"github.com/stretchr/testify/require"');
45
+ helperLines.push(')');
46
+ helperLines.push('');
47
+ helperLines.push('func ptrString(s string) *string { return &s }');
48
+ helperLines.push('func ptrInt(i int) *int { return &i }');
49
+ helperLines.push('');
50
+ helperLines.push(
51
+ `func setupTestServer(t *testing.T, method, path, fixturePath string) (*httptest.Server, *${ctx.namespace}.Client) {`,
52
+ );
53
+ helperLines.push('\tt.Helper()');
54
+ helperLines.push('\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {');
55
+ helperLines.push('\t\trequire.Equal(t, method, r.Method)');
56
+ helperLines.push('\t\trequire.Equal(t, path, r.URL.Path)');
57
+ helperLines.push('\t\tw.Header().Set("Content-Type", "application/json")');
58
+ helperLines.push('\t\tfixture, err := os.ReadFile(fixturePath)');
59
+ helperLines.push('\t\tif err != nil {');
60
+ helperLines.push('\t\t\tt.Fatalf("failed to read fixture: %v", err)');
61
+ helperLines.push('\t\t}');
62
+ helperLines.push('\t\tw.Write(fixture)');
63
+ helperLines.push('\t}))');
64
+ helperLines.push(`\tclient := ${ctx.namespace}.NewClient("sk_test", ${ctx.namespace}.WithBaseURL(server.URL))`);
65
+ helperLines.push('\treturn server, client');
66
+ helperLines.push('}');
67
+ helperLines.push('');
68
+ files.push({
69
+ path: 'helpers_test.go',
70
+ content: helperLines.join('\n'),
71
+ overwriteExisting: true,
72
+ });
73
+
74
+ // Generate fixture JSON files
75
+ const fixtures = generateFixtures(spec);
76
+ for (const fixture of fixtures) {
77
+ files.push({
78
+ path: fixture.path,
79
+ content: fixture.content,
80
+ headerPlacement: 'skip',
81
+ });
82
+ }
83
+
84
+ // Build access path map
85
+ const accessPaths = buildServiceAccessPaths(spec.services, ctx);
86
+
87
+ // Generate per-mount-target test files
88
+ const mountGroups = groupByMount(ctx);
89
+ const testEntries: Array<{ name: string; operations: Operation[] }> =
90
+ mountGroups.size > 0
91
+ ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
92
+ : spec.services.map((s) => ({
93
+ name: resolveResourceClassName(s, ctx),
94
+ operations: s.operations,
95
+ }));
96
+
97
+ for (const { name: mountName, operations } of testEntries) {
98
+ if (operations.length === 0) continue;
99
+ const mergedService: Service = { name: mountName, operations };
100
+ const testFile = generateServiceTest(mergedService, spec, ctx, accessPaths);
101
+ if (testFile) files.push(testFile);
102
+ }
103
+
104
+ return files;
105
+ }
106
+
107
+ function generateServiceTest(
108
+ service: Service,
109
+ spec: ApiSpec,
110
+ ctx: EmitterContext,
111
+ _accessPaths: Map<string, string>,
112
+ ): GeneratedFile | null {
113
+ if (service.operations.length === 0) return null;
114
+
115
+ const resolvedName = resolveResourceClassName(service, ctx);
116
+ const accessorName = resolvedName;
117
+ const testFile = `${toSnakeCase(resolvedName)}_test.go`;
118
+
119
+ const lines: string[] = [];
120
+ lines.push(`package ${ctx.namespace}_test`);
121
+ lines.push('');
122
+ // Check if any operation uses POST/PUT/PATCH with visible model body fields (for import needs).
123
+ // Union-type bodies don't trigger body validation so don't need io/json imports.
124
+ const resolvedLookupForImports = buildResolvedLookup(ctx);
125
+ const hasBodyOps = service.operations.some((op) => {
126
+ const httpMethod = op.httpMethod.toUpperCase();
127
+ if (httpMethod !== 'POST' && httpMethod !== 'PUT' && httpMethod !== 'PATCH') return false;
128
+ if (op.requestBody?.kind !== 'model') return false;
129
+ const resolved = lookupResolved(op, resolvedLookupForImports);
130
+ const hidden = buildHiddenParams(resolved);
131
+ const bodyModel = spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
132
+ if (bodyModel) return bodyModel.fields.some((f) => !hidden.has(f.name));
133
+ return false;
134
+ });
135
+
136
+ lines.push('import (');
137
+ lines.push('\t"context"');
138
+ if (hasBodyOps) {
139
+ lines.push('\t"encoding/json"');
140
+ lines.push('\t"io"');
141
+ }
142
+ lines.push('\t"net/http"');
143
+ lines.push('\t"net/http/httptest"');
144
+ lines.push('\t"os"');
145
+ lines.push('\t"testing"');
146
+ lines.push('');
147
+ lines.push(`\t"${resolveModulePath(ctx)}"`);
148
+ lines.push('\t"github.com/stretchr/testify/require"');
149
+ lines.push(')');
150
+ lines.push('');
151
+ // Test helpers (ptrString, ptrInt, setupTestServer) are in helpers_test.go
152
+
153
+ // Deduplicate test functions by method name
154
+ const emittedTestMethods = new Set<string>();
155
+ for (const op of service.operations) {
156
+ const plan = planOperation(op);
157
+ const method = resolveGoMethodName(op, resolvedName, ctx);
158
+ const isPaginated = plan.isPaginated;
159
+ const isDelete = plan.isDelete;
160
+
161
+ // Skip duplicate method names (same dedup as resources.ts)
162
+ if (emittedTestMethods.has(method)) continue;
163
+ emittedTestMethods.add(method);
164
+
165
+ const testName = `Test${accessorName}_${method}`;
166
+
167
+ if (isPaginated && op.pagination) {
168
+ // Pagination test
169
+ // Find the right fixture -- apply the same unwrap logic as fixtures.ts
170
+ let fixturePath: string | null = null;
171
+ const paginationItemType = op.pagination.itemType;
172
+ if (paginationItemType.kind === 'model') {
173
+ const itemModel = spec.models.find((m) => m.name === paginationItemType.name);
174
+ if (itemModel) {
175
+ let resolved = itemModel;
176
+ if (isListWrapperModel(itemModel)) {
177
+ const dataField = itemModel.fields.find((f) => f.name === 'data');
178
+ if (dataField && dataField.type.kind === 'array' && dataField.type.items.kind === 'model') {
179
+ const inner = spec.models.find((m) => m.name === (dataField.type as any).items.name);
180
+ if (inner) resolved = inner;
181
+ }
182
+ }
183
+ fixturePath = `testdata/list_${fileName(resolved.name)}.json`;
184
+ }
185
+ }
186
+
187
+ const expectedPath = buildExpectedPath(op);
188
+
189
+ // Find a filter param to populate (prefer 'limit', then first visible string param)
190
+ const resolvedLookupPag = buildResolvedLookup(ctx);
191
+ const resolvedOpPag = lookupResolved(op, resolvedLookupPag);
192
+ const hiddenPag = buildHiddenParams(resolvedOpPag);
193
+ const visibleQPs = op.queryParams.filter((qp) => !hiddenPag.has(qp.name));
194
+ const limitParam = visibleQPs.find((qp) => qp.name === 'limit');
195
+ const filterParam =
196
+ limitParam ?? visibleQPs.find((qp) => qp.type.kind === 'primitive' && qp.type.type === 'string');
197
+
198
+ lines.push(`func ${testName}(t *testing.T) {`);
199
+ lines.push('\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {');
200
+ lines.push(`\t\trequire.Equal(t, "${op.httpMethod.toUpperCase()}", r.Method)`);
201
+ lines.push(`\t\trequire.Equal(t, "${expectedPath}", r.URL.Path)`);
202
+ // Assert filter param in query string
203
+ if (filterParam) {
204
+ const expectedVal = filterParam.name === 'limit' ? '10' : `test_${filterParam.name}`;
205
+ lines.push(`\t\trequire.Equal(t, "${expectedVal}", r.URL.Query().Get("${filterParam.name}"))`);
206
+ }
207
+ lines.push('\t\tw.Header().Set("Content-Type", "application/json")');
208
+ lines.push('\t\tw.WriteHeader(http.StatusOK)');
209
+ if (fixturePath) {
210
+ lines.push(`\t\tfixture, err := os.ReadFile("${fixturePath}")`);
211
+ lines.push('\t\tif err != nil {');
212
+ lines.push('\t\t\tt.Fatalf("failed to read fixture: %v", err)');
213
+ lines.push('\t\t}');
214
+ lines.push('\t\tw.Write(fixture)');
215
+ } else {
216
+ lines.push('\t\tw.Write([]byte(`{"data":[],"list_metadata":{"before":null,"after":null}}`))');
217
+ }
218
+ lines.push('\t}))');
219
+ lines.push('\tdefer server.Close()');
220
+ lines.push('');
221
+ lines.push(`\tclient := ${ctx.namespace}.NewClient("sk_test", ${ctx.namespace}.WithBaseURL(server.URL))`);
222
+
223
+ // Build method call with populated filter param
224
+ const callArgs = buildMethodCallArgsWithFilter(op, plan, ctx, resolvedName, filterParam);
225
+ lines.push(`\titer := client.${accessorName}().${method}(${callArgs})`);
226
+ lines.push('\trequire.NotNil(t, iter)');
227
+ if (fixturePath) {
228
+ lines.push('\trequire.True(t, iter.Next())');
229
+ lines.push('\trequire.NoError(t, iter.Err())');
230
+ lines.push('\titem := iter.Current()');
231
+ lines.push('\trequire.NotNil(t, item)');
232
+ }
233
+ lines.push('}');
234
+ lines.push('');
235
+
236
+ // Empty pagination test
237
+ lines.push(`func ${testName}_Empty(t *testing.T) {`);
238
+ lines.push('\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {');
239
+ lines.push('\t\tw.Header().Set("Content-Type", "application/json")');
240
+ lines.push('\t\tw.WriteHeader(http.StatusOK)');
241
+ lines.push('\t\tw.Write([]byte(`{"data":[],"list_metadata":{"before":null,"after":null}}`))');
242
+ lines.push('\t}))');
243
+ lines.push('\tdefer server.Close()');
244
+ lines.push('');
245
+ lines.push(`\tclient := ${ctx.namespace}.NewClient("sk_test", ${ctx.namespace}.WithBaseURL(server.URL))`);
246
+ lines.push(`\titer := client.${accessorName}().${method}(${callArgs})`);
247
+ lines.push('\trequire.False(t, iter.Next())');
248
+ lines.push('\trequire.NoError(t, iter.Err())');
249
+ lines.push('}');
250
+ lines.push('');
251
+ } else if (isDelete) {
252
+ // Delete test
253
+ const expectedPath = buildExpectedPath(op);
254
+ lines.push(`func ${testName}(t *testing.T) {`);
255
+ lines.push('\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {');
256
+ lines.push(`\t\trequire.Equal(t, "${op.httpMethod.toUpperCase()}", r.Method)`);
257
+ lines.push(`\t\trequire.Equal(t, "${expectedPath}", r.URL.Path)`);
258
+ lines.push('\t\tw.WriteHeader(http.StatusNoContent)');
259
+ lines.push('\t}))');
260
+ lines.push('\tdefer server.Close()');
261
+ lines.push('');
262
+ lines.push(`\tclient := ${ctx.namespace}.NewClient("sk_test", ${ctx.namespace}.WithBaseURL(server.URL))`);
263
+
264
+ const callArgs = buildMethodCallArgs(op, plan, ctx, resolvedName);
265
+ lines.push(`\terr := client.${accessorName}().${method}(${callArgs})`);
266
+ lines.push('\trequire.NoError(t, err)');
267
+ lines.push('}');
268
+ lines.push('');
269
+ } else if (plan.responseModelName) {
270
+ // Success test
271
+ const respModel = plan.responseModelName;
272
+ const isArrayResponse = !isPaginated && op.response?.kind === 'array';
273
+ const fixturePath = `testdata/${fileName(respModel)}.json`;
274
+ const expectedPath = buildExpectedPath(op);
275
+
276
+ const httpMethodUpper = op.httpMethod.toUpperCase();
277
+ const isBodyMethod = httpMethodUpper === 'POST' || httpMethodUpper === 'PUT' || httpMethodUpper === 'PATCH';
278
+
279
+ lines.push(`func ${testName}(t *testing.T) {`);
280
+ lines.push('\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {');
281
+ lines.push(`\t\trequire.Equal(t, "${httpMethodUpper}", r.Method)`);
282
+ lines.push(`\t\trequire.Equal(t, "${expectedPath}", r.URL.Path)`);
283
+
284
+ // Validate request body for POST/PUT/PATCH when body fields are actually sent.
285
+ // Skip for union-type bodies (test sends empty Body interface{}) and operations
286
+ // where all body fields are hidden.
287
+ const resolvedLookup = buildResolvedLookup(ctx);
288
+ const resolvedOp = lookupResolved(op, resolvedLookup);
289
+ const hiddenSet = buildHiddenParams(resolvedOp);
290
+ let hasVisibleBody = false;
291
+ if (isBodyMethod && op.requestBody?.kind === 'model') {
292
+ const bodyModel = spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
293
+ if (bodyModel) {
294
+ hasVisibleBody = bodyModel.fields.some((f) => !hiddenSet.has(f.name));
295
+ }
296
+ }
297
+ // Don't validate body for union types -- test sends nil Body
298
+ if (hasVisibleBody) {
299
+ lines.push('\t\tbody, _ := io.ReadAll(r.Body)');
300
+ lines.push('\t\tvar bodyMap map[string]interface{}');
301
+ lines.push('\t\trequire.NoError(t, json.Unmarshal(body, &bodyMap))');
302
+ }
303
+
304
+ lines.push('\t\tw.Header().Set("Content-Type", "application/json")');
305
+ lines.push('\t\tw.WriteHeader(http.StatusOK)');
306
+ if (isArrayResponse) {
307
+ lines.push(`\t\tfixture, err := os.ReadFile("${fixturePath}")`);
308
+ lines.push('\t\tif err != nil {');
309
+ lines.push('\t\t\tt.Fatalf("failed to read fixture: %v", err)');
310
+ lines.push('\t\t}');
311
+ lines.push('\t\tw.Write([]byte("[" + string(fixture) + "]"))');
312
+ } else {
313
+ lines.push(`\t\tfixture, err := os.ReadFile("${fixturePath}")`);
314
+ lines.push('\t\tif err != nil {');
315
+ lines.push('\t\t\tt.Fatalf("failed to read fixture: %v", err)');
316
+ lines.push('\t\t}');
317
+ lines.push('\t\tw.Write(fixture)');
318
+ }
319
+ lines.push('\t}))');
320
+ lines.push('\tdefer server.Close()');
321
+ lines.push('');
322
+ lines.push(`\tclient := ${ctx.namespace}.NewClient("sk_test", ${ctx.namespace}.WithBaseURL(server.URL))`);
323
+
324
+ const callArgs = buildMethodCallArgs(op, plan, ctx, resolvedName);
325
+ lines.push(`\tresult, err := client.${accessorName}().${method}(${callArgs})`);
326
+ lines.push('\trequire.NoError(t, err)');
327
+ if (isArrayResponse) {
328
+ lines.push('\trequire.NotEmpty(t, result)');
329
+ } else {
330
+ lines.push('\trequire.NotNil(t, result)');
331
+ // Add specific field value assertions from fixture data
332
+ const respModelDef = spec.models.find((m) => m.name === respModel);
333
+ if (respModelDef) {
334
+ const fixtureAssertions = buildFixtureAssertions(respModelDef, ctx.namespace);
335
+ for (const assertion of fixtureAssertions) {
336
+ lines.push(`\t${assertion}`);
337
+ }
338
+ }
339
+ }
340
+ lines.push('}');
341
+ lines.push('');
342
+ } else {
343
+ // Void response test
344
+ const expectedPath = buildExpectedPath(op);
345
+ lines.push(`func ${testName}(t *testing.T) {`);
346
+ lines.push('\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {');
347
+ lines.push(`\t\trequire.Equal(t, "${op.httpMethod.toUpperCase()}", r.Method)`);
348
+ lines.push(`\t\trequire.Equal(t, "${expectedPath}", r.URL.Path)`);
349
+ lines.push('\t\tw.WriteHeader(http.StatusOK)');
350
+ lines.push('\t}))');
351
+ lines.push('\tdefer server.Close()');
352
+ lines.push('');
353
+ lines.push(`\tclient := ${ctx.namespace}.NewClient("sk_test", ${ctx.namespace}.WithBaseURL(server.URL))`);
354
+
355
+ const callArgs = buildMethodCallArgs(op, plan, ctx, resolvedName);
356
+ lines.push(`\terr := client.${accessorName}().${method}(${callArgs})`);
357
+ lines.push('\trequire.NoError(t, err)');
358
+ lines.push('}');
359
+ lines.push('');
360
+ }
361
+ }
362
+
363
+ // Generate tests for union split wrapper methods (e.g., AuthenticateWithPassword)
364
+ const resolvedLookup = buildResolvedLookup(ctx);
365
+ for (const op of service.operations) {
366
+ const resolved = lookupResolved(op, resolvedLookup);
367
+ if (!resolved?.wrappers || resolved.wrappers.length === 0) continue;
368
+
369
+ for (const wrapper of resolved.wrappers) {
370
+ const wrapperMethod = goMethodName(wrapper.name);
371
+ if (emittedTestMethods.has(wrapperMethod)) continue;
372
+ emittedTestMethods.add(wrapperMethod);
373
+
374
+ const wrapperParamsStruct = `${wrapperMethod}Params`;
375
+ const responseType = wrapper.responseModelName;
376
+ const testName = `Test${accessorName}_${wrapperMethod}`;
377
+ const fixturePath = responseType ? `testdata/${fileName(responseType)}.json` : null;
378
+
379
+ const wrapperCallArgs: string[] = ['context.Background()'];
380
+ for (const p of sortPathParamsByTemplateOrder(op)) {
381
+ wrapperCallArgs.push(`"test_${p.name}"`);
382
+ }
383
+ wrapperCallArgs.push(`&${ctx.namespace}.${wrapperParamsStruct}{}`);
384
+
385
+ lines.push(
386
+ ...generateWrapperTestLines(
387
+ testName,
388
+ accessorName,
389
+ wrapperMethod,
390
+ op.httpMethod.toUpperCase(),
391
+ buildExpectedPath(op),
392
+ fixturePath,
393
+ wrapperCallArgs.join(', '),
394
+ responseType,
395
+ ctx.namespace,
396
+ ),
397
+ );
398
+ }
399
+ }
400
+
401
+ // Error test (one per file: 401)
402
+ const sampleOp = service.operations[0];
403
+ if (sampleOp) {
404
+ const plan = planOperation(sampleOp);
405
+ const method = resolveGoMethodName(sampleOp, resolvedName, ctx);
406
+ const callArgs = buildMethodCallArgs(sampleOp, plan, ctx, resolvedName);
407
+
408
+ lines.push(`func Test${accessorName}_Error401(t *testing.T) {`);
409
+ lines.push('\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {');
410
+ lines.push('\t\tw.Header().Set("Content-Type", "application/json")');
411
+ lines.push('\t\tw.WriteHeader(http.StatusUnauthorized)');
412
+ lines.push('\t\tw.Write([]byte(`{"code":"unauthorized","message":"Unauthorized"}`))');
413
+ lines.push('\t}))');
414
+ lines.push('\tdefer server.Close()');
415
+ lines.push('');
416
+ lines.push(`\tclient := ${ctx.namespace}.NewClient("sk_test", ${ctx.namespace}.WithBaseURL(server.URL))`);
417
+
418
+ if (plan.isPaginated) {
419
+ lines.push(`\titer := client.${accessorName}().${method}(${callArgs})`);
420
+ lines.push('\trequire.False(t, iter.Next())');
421
+ lines.push(`\trequire.IsType(t, &${ctx.namespace}.AuthenticationError{}, iter.Err())`);
422
+ } else if (plan.isDelete || !plan.responseModelName) {
423
+ lines.push(`\terr := client.${accessorName}().${method}(${callArgs})`);
424
+ lines.push(`\trequire.IsType(t, &${ctx.namespace}.AuthenticationError{}, err)`);
425
+ } else {
426
+ lines.push(`\t_, err := client.${accessorName}().${method}(${callArgs})`);
427
+ lines.push(`\trequire.IsType(t, &${ctx.namespace}.AuthenticationError{}, err)`);
428
+ }
429
+ lines.push('}');
430
+ lines.push('');
431
+
432
+ // Error 404 test
433
+ lines.push(`func Test${accessorName}_Error404(t *testing.T) {`);
434
+ lines.push('\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {');
435
+ lines.push('\t\tw.Header().Set("Content-Type", "application/json")');
436
+ lines.push('\t\tw.WriteHeader(http.StatusNotFound)');
437
+ lines.push('\t\tw.Write([]byte(`{"code":"not_found","message":"Not Found"}`))');
438
+ lines.push('\t}))');
439
+ lines.push('\tdefer server.Close()');
440
+ lines.push('');
441
+ lines.push(`\tclient := ${ctx.namespace}.NewClient("sk_test", ${ctx.namespace}.WithBaseURL(server.URL))`);
442
+
443
+ if (plan.isPaginated) {
444
+ lines.push(`\titer := client.${accessorName}().${method}(${callArgs})`);
445
+ lines.push('\trequire.False(t, iter.Next())');
446
+ lines.push(`\trequire.IsType(t, &${ctx.namespace}.NotFoundError{}, iter.Err())`);
447
+ } else if (plan.isDelete || !plan.responseModelName) {
448
+ lines.push(`\terr := client.${accessorName}().${method}(${callArgs})`);
449
+ lines.push(`\trequire.IsType(t, &${ctx.namespace}.NotFoundError{}, err)`);
450
+ } else {
451
+ lines.push(`\t_, err := client.${accessorName}().${method}(${callArgs})`);
452
+ lines.push(`\trequire.IsType(t, &${ctx.namespace}.NotFoundError{}, err)`);
453
+ }
454
+ lines.push('}');
455
+ lines.push('');
456
+
457
+ // Error 422 test
458
+ lines.push(`func Test${accessorName}_Error422(t *testing.T) {`);
459
+ lines.push('\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {');
460
+ lines.push('\t\tw.Header().Set("Content-Type", "application/json")');
461
+ lines.push('\t\tw.WriteHeader(422)');
462
+ lines.push('\t\tw.Write([]byte(`{"code":"unprocessable_entity","message":"Unprocessable"}`))');
463
+ lines.push('\t}))');
464
+ lines.push('\tdefer server.Close()');
465
+ lines.push('');
466
+ lines.push(`\tclient := ${ctx.namespace}.NewClient("sk_test", ${ctx.namespace}.WithBaseURL(server.URL))`);
467
+
468
+ if (plan.isPaginated) {
469
+ lines.push(`\titer := client.${accessorName}().${method}(${callArgs})`);
470
+ lines.push('\trequire.False(t, iter.Next())');
471
+ lines.push(`\trequire.IsType(t, &${ctx.namespace}.UnprocessableEntityError{}, iter.Err())`);
472
+ } else if (plan.isDelete || !plan.responseModelName) {
473
+ lines.push(`\terr := client.${accessorName}().${method}(${callArgs})`);
474
+ lines.push(`\trequire.IsType(t, &${ctx.namespace}.UnprocessableEntityError{}, err)`);
475
+ } else {
476
+ lines.push(`\t_, err := client.${accessorName}().${method}(${callArgs})`);
477
+ lines.push(`\trequire.IsType(t, &${ctx.namespace}.UnprocessableEntityError{}, err)`);
478
+ }
479
+ lines.push('}');
480
+ lines.push('');
481
+ }
482
+
483
+ return {
484
+ path: testFile,
485
+ content: lines.join('\n'),
486
+ overwriteExisting: true,
487
+ };
488
+ }
489
+
490
+ function resolveGoMethodName(op: Operation, mountName: string, ctx: EmitterContext): string {
491
+ return resolveMethodName(op, { name: mountName, operations: [op] }, ctx);
492
+ }
493
+
494
+ function buildMethodCallArgs(op: Operation, plan: any, ctx: EmitterContext, mountName: string): string {
495
+ const args: string[] = ['context.Background()'];
496
+
497
+ // Path params (sorted by template order)
498
+ for (const p of sortPathParamsByTemplateOrder(op)) {
499
+ args.push(`"test_${p.name}"`);
500
+ }
501
+
502
+ // Params struct if needed — must mirror resources.ts hasParams logic
503
+ const resolvedLookup = buildResolvedLookup(ctx);
504
+ const resolvedOp = lookupResolved(op, resolvedLookup);
505
+ const hidden = buildHiddenParams(resolvedOp);
506
+ const hasVisibleQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name)).length > 0;
507
+ const hasBody = plan.hasBody && op.requestBody;
508
+ let hasVisibleBodyFields = false;
509
+ if (hasBody && op.requestBody?.kind === 'model') {
510
+ const bodyModel = ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
511
+ if (bodyModel) {
512
+ hasVisibleBodyFields = bodyModel.fields.some((f) => !hidden.has(f.name));
513
+ }
514
+ } else if (hasBody) {
515
+ hasVisibleBodyFields = true;
516
+ }
517
+
518
+ if (hasVisibleBodyFields || hasVisibleQueryParams) {
519
+ const method = resolveGoMethodName(op, mountName, ctx);
520
+ const pName = paramsStructName(mountName, method);
521
+ args.push(`&${ctx.namespace}.${pName}{}`);
522
+ }
523
+
524
+ return args.join(', ');
525
+ }
526
+
527
+ /**
528
+ * Build method call args with a populated filter param for list tests.
529
+ * If filterParam is provided, populates it in the params struct initializer.
530
+ */
531
+ function buildMethodCallArgsWithFilter(
532
+ op: Operation,
533
+ plan: any,
534
+ ctx: EmitterContext,
535
+ mountName: string,
536
+ filterParam?: { name: string; type: import('@workos/oagen').TypeRef } | null,
537
+ ): string {
538
+ const args: string[] = ['context.Background()'];
539
+
540
+ for (const p of sortPathParamsByTemplateOrder(op)) {
541
+ args.push(`"test_${p.name}"`);
542
+ }
543
+
544
+ const resolvedLookup = buildResolvedLookup(ctx);
545
+ const resolvedOp = lookupResolved(op, resolvedLookup);
546
+ const hidden = buildHiddenParams(resolvedOp);
547
+ const hasVisibleQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name)).length > 0;
548
+ const hasBody = plan.hasBody && op.requestBody;
549
+ let hasVisibleBodyFields = false;
550
+ if (hasBody && op.requestBody?.kind === 'model') {
551
+ const bodyModel = ctx.spec.models.find((m) => op.requestBody?.kind === 'model' && m.name === op.requestBody.name);
552
+ if (bodyModel) {
553
+ hasVisibleBodyFields = bodyModel.fields.some((f) => !hidden.has(f.name));
554
+ }
555
+ } else if (hasBody) {
556
+ hasVisibleBodyFields = true;
557
+ }
558
+
559
+ if (hasVisibleBodyFields || hasVisibleQueryParams) {
560
+ const method = resolveGoMethodName(op, mountName, ctx);
561
+ const pName = paramsStructName(mountName, method);
562
+ if (filterParam) {
563
+ const isPaginationField = ['before', 'after', 'limit', 'order'].includes(filterParam.name);
564
+ // Check if params struct embeds PaginationParams (has before/after/limit)
565
+ const allQPs = op.queryParams.filter((qp) => !hidden.has(qp.name));
566
+ const embedsPagination = ['before', 'after', 'limit'].every((name) => allQPs.some((qp) => qp.name === name));
567
+ const goField = goFieldName(filterParam.name);
568
+
569
+ let valExpr: string;
570
+ if (filterParam.name === 'limit') {
571
+ valExpr = `${goField}: ptrInt(10)`;
572
+ } else {
573
+ valExpr = `${goField}: ptrString("test_${filterParam.name}")`;
574
+ }
575
+
576
+ if (isPaginationField && embedsPagination) {
577
+ // Pagination fields are embedded — set via embedded struct
578
+ args.push(`&${ctx.namespace}.${pName}{PaginationParams: ${ctx.namespace}.PaginationParams{${valExpr}}}`);
579
+ } else {
580
+ args.push(`&${ctx.namespace}.${pName}{${valExpr}}`);
581
+ }
582
+ } else {
583
+ args.push(`&${ctx.namespace}.${pName}{}`);
584
+ }
585
+ }
586
+
587
+ return args.join(', ');
588
+ }
589
+
590
+ /** Build the expected URL path with test placeholder values. */
591
+ function buildExpectedPath(op: Operation): string {
592
+ let expected = op.path;
593
+ for (const p of sortPathParamsByTemplateOrder(op)) {
594
+ expected = expected.replace(`{${p.name}}`, `test_${p.name}`);
595
+ }
596
+ return expected;
597
+ }
598
+
599
+ function generateWrapperTestLines(
600
+ testName: string,
601
+ accessorName: string,
602
+ wrapperMethod: string,
603
+ httpMethod: string,
604
+ expectedPath: string,
605
+ fixturePath: string | null,
606
+ callArgs: string,
607
+ responseType: string | null,
608
+ namespace: string,
609
+ ): string[] {
610
+ const lines: string[] = [];
611
+ const serverHandler = [
612
+ '\tserver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {',
613
+ `\t\trequire.Equal(t, "${httpMethod}", r.Method)`,
614
+ `\t\trequire.Equal(t, "${expectedPath}", r.URL.Path)`,
615
+ '\t\tw.Header().Set("Content-Type", "application/json")',
616
+ '\t\tw.WriteHeader(http.StatusOK)',
617
+ ];
618
+ if (fixturePath) {
619
+ serverHandler.push(`\t\tfixture, err := os.ReadFile("${fixturePath}")`);
620
+ serverHandler.push('\t\tif err != nil {');
621
+ serverHandler.push('\t\t\tt.Fatalf("failed to read fixture: %v", err)');
622
+ serverHandler.push('\t\t}');
623
+ serverHandler.push('\t\tw.Write(fixture)');
624
+ } else {
625
+ serverHandler.push('\t\tw.Write([]byte(`{}`))');
626
+ }
627
+ serverHandler.push('\t}))');
628
+
629
+ // Add body validation for wrapper POST methods before building handler lines
630
+ const isWrapperBodyMethod = httpMethod === 'POST' || httpMethod === 'PUT' || httpMethod === 'PATCH';
631
+ if (isWrapperBodyMethod) {
632
+ const headerIdx = serverHandler.findIndex((l) => l.includes('w.Header().Set'));
633
+ if (headerIdx >= 0) {
634
+ serverHandler.splice(
635
+ headerIdx,
636
+ 0,
637
+ '\t\tbody, _ := io.ReadAll(r.Body)',
638
+ '\t\tvar bodyMap map[string]interface{}',
639
+ '\t\trequire.NoError(t, json.Unmarshal(body, &bodyMap))',
640
+ );
641
+ }
642
+ }
643
+
644
+ lines.push(`func ${testName}(t *testing.T) {`);
645
+ lines.push(...serverHandler);
646
+ lines.push('\tdefer server.Close()');
647
+ lines.push('');
648
+ lines.push(`\tclient := ${namespace}.NewClient("sk_test", ${namespace}.WithBaseURL(server.URL))`);
649
+
650
+ if (responseType) {
651
+ lines.push(`\tresult, err := client.${accessorName}().${wrapperMethod}(${callArgs})`);
652
+ lines.push('\trequire.NoError(t, err)');
653
+ lines.push('\trequire.NotNil(t, result)');
654
+ } else {
655
+ lines.push(`\terr := client.${accessorName}().${wrapperMethod}(${callArgs})`);
656
+ lines.push('\trequire.NoError(t, err)');
657
+ }
658
+
659
+ lines.push('}');
660
+ lines.push('');
661
+ return lines;
662
+ }
663
+
664
+ /**
665
+ * Build field value assertions from fixture data for a response model.
666
+ * Returns Go assertion lines (without leading \t).
667
+ * Checks id field and 1-2 other required string fields.
668
+ */
669
+ function buildFixtureAssertions(model: import('@workos/oagen').Model, _namespace: string): string[] {
670
+ const assertions: string[] = [];
671
+ const fixtureValues = generateModelFixtureValues(model);
672
+
673
+ // Priority: assert the 'id' field first
674
+ const idField = model.fields.find((f) => f.required && f.name === 'id');
675
+ if (idField && fixtureValues[idField.name] != null) {
676
+ const goField = goFieldName(idField.name);
677
+ const val = fixtureValues[idField.name];
678
+ if (typeof val === 'string' && isGoStringSafe(val)) {
679
+ assertions.push(`require.Equal(t, "${escapeGoString(val)}", result.${goField})`);
680
+ } else {
681
+ assertions.push(`require.NotEmpty(t, result.${goField})`);
682
+ }
683
+ }
684
+
685
+ // Assert 1-2 other required string fields
686
+ let extraCount = 0;
687
+ for (const field of model.fields) {
688
+ if (extraCount >= 2) break;
689
+ if (field.name === 'id') continue;
690
+ if (!field.required) continue;
691
+ if (field.type.kind !== 'primitive' || field.type.type !== 'string') continue;
692
+ const val = fixtureValues[field.name];
693
+ if (val == null) continue;
694
+ const goField = goFieldName(field.name);
695
+ if (typeof val === 'string' && isGoStringSafe(val)) {
696
+ assertions.push(`require.Equal(t, "${escapeGoString(val)}", result.${goField})`);
697
+ } else {
698
+ assertions.push(`require.NotEmpty(t, result.${goField})`);
699
+ }
700
+ extraCount++;
701
+ }
702
+
703
+ // Fallback: at least assert NotEmpty on the first required string field
704
+ if (assertions.length === 0) {
705
+ const targetField =
706
+ model.fields.find((f) => f.required && f.name === 'id') ||
707
+ model.fields.find((f) => f.required && f.type.kind === 'primitive' && f.type.type === 'string');
708
+ if (targetField) {
709
+ assertions.push(`require.NotEmpty(t, result.${goFieldName(targetField.name)})`);
710
+ }
711
+ }
712
+
713
+ return assertions;
714
+ }
715
+
716
+ /** Check if a string value can be safely embedded in a Go double-quoted string. */
717
+ function isGoStringSafe(val: string): boolean {
718
+ // Skip values that look like JSON objects/arrays or contain template expressions
719
+ if (val.includes('{') || val.includes('}') || val.includes('`')) return false;
720
+ return true;
721
+ }
722
+
723
+ /** Escape special characters for a Go double-quoted string. */
724
+ function escapeGoString(val: string): string {
725
+ return val.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
726
+ }
727
+
728
+ /** Generate fixture values for a model (same logic as fixtures.ts but inline). */
729
+ function generateModelFixtureValues(model: import('@workos/oagen').Model): Record<string, any> {
730
+ const fixture: Record<string, any> = {};
731
+ for (const field of model.fields) {
732
+ if (field.example !== undefined) {
733
+ fixture[field.name] = field.example;
734
+ } else if (field.type.kind === 'primitive' && field.type.type === 'string') {
735
+ if (field.name === 'id') {
736
+ const prefix = (ID_PREFIXES as Record<string, string>)[model.name] ?? '';
737
+ fixture[field.name] = `${prefix}01234`;
738
+ } else if (field.name.includes('email')) {
739
+ fixture[field.name] = 'test@example.com';
740
+ } else if (field.name.includes('name')) {
741
+ fixture[field.name] = 'Test';
742
+ } else {
743
+ fixture[field.name] = `test_${field.name}`;
744
+ }
745
+ }
746
+ }
747
+ return fixture;
748
+ }
749
+
750
+ // Re-import fixture ID prefixes
751
+ import { ID_PREFIXES } from './fixtures.js';