workos 0.11.2 → 0.12.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 (186) hide show
  1. package/README.md +165 -6
  2. package/dist/bin.js +22 -1
  3. package/dist/bin.js.map +1 -1
  4. package/dist/check-coverage.ts +237 -0
  5. package/dist/commands/debug.js +0 -1
  6. package/dist/commands/debug.js.map +1 -1
  7. package/dist/commands/dev.d.ts +23 -0
  8. package/dist/commands/dev.js +139 -0
  9. package/dist/commands/dev.js.map +1 -0
  10. package/dist/commands/emulate.d.ts +6 -0
  11. package/dist/commands/emulate.js +64 -0
  12. package/dist/commands/emulate.js.map +1 -0
  13. package/dist/commands/login.js +0 -4
  14. package/dist/commands/login.js.map +1 -1
  15. package/dist/emulate/core/id.d.ts +48 -0
  16. package/dist/emulate/core/id.js +73 -0
  17. package/dist/emulate/core/id.js.map +1 -0
  18. package/dist/emulate/core/index.d.ts +8 -0
  19. package/dist/emulate/core/index.js +8 -0
  20. package/dist/emulate/core/index.js.map +1 -0
  21. package/dist/emulate/core/jwt.d.ts +28 -0
  22. package/dist/emulate/core/jwt.js +78 -0
  23. package/dist/emulate/core/jwt.js.map +1 -0
  24. package/dist/emulate/core/middleware/auth.d.ts +15 -0
  25. package/dist/emulate/core/middleware/auth.js +17 -0
  26. package/dist/emulate/core/middleware/auth.js.map +1 -0
  27. package/dist/emulate/core/middleware/error-handler.d.ts +22 -0
  28. package/dist/emulate/core/middleware/error-handler.js +72 -0
  29. package/dist/emulate/core/middleware/error-handler.js.map +1 -0
  30. package/dist/emulate/core/pagination.d.ts +27 -0
  31. package/dist/emulate/core/pagination.js +43 -0
  32. package/dist/emulate/core/pagination.js.map +1 -0
  33. package/dist/emulate/core/plugin.d.ts +15 -0
  34. package/dist/emulate/core/plugin.js +2 -0
  35. package/dist/emulate/core/plugin.js.map +1 -0
  36. package/dist/emulate/core/server.d.ts +17 -0
  37. package/dist/emulate/core/server.js +90 -0
  38. package/dist/emulate/core/server.js.map +1 -0
  39. package/dist/emulate/core/store.d.ts +44 -0
  40. package/dist/emulate/core/store.js +169 -0
  41. package/dist/emulate/core/store.js.map +1 -0
  42. package/dist/emulate/index.d.ts +25 -0
  43. package/dist/emulate/index.js +47 -0
  44. package/dist/emulate/index.js.map +1 -0
  45. package/dist/emulate/workos/constants.d.ts +56 -0
  46. package/dist/emulate/workos/constants.js +56 -0
  47. package/dist/emulate/workos/constants.js.map +1 -0
  48. package/dist/emulate/workos/entities.d.ts +360 -0
  49. package/dist/emulate/workos/entities.js +2 -0
  50. package/dist/emulate/workos/entities.js.map +1 -0
  51. package/dist/emulate/workos/event-bus.d.ts +17 -0
  52. package/dist/emulate/workos/event-bus.js +70 -0
  53. package/dist/emulate/workos/event-bus.js.map +1 -0
  54. package/dist/emulate/workos/helpers.d.ts +72 -0
  55. package/dist/emulate/workos/helpers.js +211 -0
  56. package/dist/emulate/workos/helpers.js.map +1 -0
  57. package/dist/emulate/workos/index.d.ts +91 -0
  58. package/dist/emulate/workos/index.js +322 -0
  59. package/dist/emulate/workos/index.js.map +1 -0
  60. package/dist/emulate/workos/role-helpers.d.ts +21 -0
  61. package/dist/emulate/workos/role-helpers.js +130 -0
  62. package/dist/emulate/workos/role-helpers.js.map +1 -0
  63. package/dist/emulate/workos/routes/api-keys.d.ts +2 -0
  64. package/dist/emulate/workos/routes/api-keys.js +32 -0
  65. package/dist/emulate/workos/routes/api-keys.js.map +1 -0
  66. package/dist/emulate/workos/routes/audit-logs.d.ts +2 -0
  67. package/dist/emulate/workos/routes/audit-logs.js +104 -0
  68. package/dist/emulate/workos/routes/audit-logs.js.map +1 -0
  69. package/dist/emulate/workos/routes/auth-challenges.d.ts +2 -0
  70. package/dist/emulate/workos/routes/auth-challenges.js +51 -0
  71. package/dist/emulate/workos/routes/auth-challenges.js.map +1 -0
  72. package/dist/emulate/workos/routes/auth-factors.d.ts +2 -0
  73. package/dist/emulate/workos/routes/auth-factors.js +51 -0
  74. package/dist/emulate/workos/routes/auth-factors.js.map +1 -0
  75. package/dist/emulate/workos/routes/auth.d.ts +2 -0
  76. package/dist/emulate/workos/routes/auth.js +350 -0
  77. package/dist/emulate/workos/routes/auth.js.map +1 -0
  78. package/dist/emulate/workos/routes/authorization-checks.d.ts +10 -0
  79. package/dist/emulate/workos/routes/authorization-checks.js +123 -0
  80. package/dist/emulate/workos/routes/authorization-checks.js.map +1 -0
  81. package/dist/emulate/workos/routes/authorization-org-roles.d.ts +2 -0
  82. package/dist/emulate/workos/routes/authorization-org-roles.js +64 -0
  83. package/dist/emulate/workos/routes/authorization-org-roles.js.map +1 -0
  84. package/dist/emulate/workos/routes/authorization-permissions.d.ts +2 -0
  85. package/dist/emulate/workos/routes/authorization-permissions.js +67 -0
  86. package/dist/emulate/workos/routes/authorization-permissions.js.map +1 -0
  87. package/dist/emulate/workos/routes/authorization-resources.d.ts +2 -0
  88. package/dist/emulate/workos/routes/authorization-resources.js +117 -0
  89. package/dist/emulate/workos/routes/authorization-resources.js.map +1 -0
  90. package/dist/emulate/workos/routes/authorization-roles.d.ts +2 -0
  91. package/dist/emulate/workos/routes/authorization-roles.js +13 -0
  92. package/dist/emulate/workos/routes/authorization-roles.js.map +1 -0
  93. package/dist/emulate/workos/routes/config.d.ts +2 -0
  94. package/dist/emulate/workos/routes/config.js +57 -0
  95. package/dist/emulate/workos/routes/config.js.map +1 -0
  96. package/dist/emulate/workos/routes/connect.d.ts +2 -0
  97. package/dist/emulate/workos/routes/connect.js +65 -0
  98. package/dist/emulate/workos/routes/connect.js.map +1 -0
  99. package/dist/emulate/workos/routes/connections.d.ts +2 -0
  100. package/dist/emulate/workos/routes/connections.js +73 -0
  101. package/dist/emulate/workos/routes/connections.js.map +1 -0
  102. package/dist/emulate/workos/routes/data-integrations.d.ts +2 -0
  103. package/dist/emulate/workos/routes/data-integrations.js +55 -0
  104. package/dist/emulate/workos/routes/data-integrations.js.map +1 -0
  105. package/dist/emulate/workos/routes/directories.d.ts +2 -0
  106. package/dist/emulate/workos/routes/directories.js +90 -0
  107. package/dist/emulate/workos/routes/directories.js.map +1 -0
  108. package/dist/emulate/workos/routes/email-verification.d.ts +2 -0
  109. package/dist/emulate/workos/routes/email-verification.js +49 -0
  110. package/dist/emulate/workos/routes/email-verification.js.map +1 -0
  111. package/dist/emulate/workos/routes/events.d.ts +2 -0
  112. package/dist/emulate/workos/routes/events.js +18 -0
  113. package/dist/emulate/workos/routes/events.js.map +1 -0
  114. package/dist/emulate/workos/routes/feature-flags.d.ts +2 -0
  115. package/dist/emulate/workos/routes/feature-flags.js +103 -0
  116. package/dist/emulate/workos/routes/feature-flags.js.map +1 -0
  117. package/dist/emulate/workos/routes/invitations.d.ts +2 -0
  118. package/dist/emulate/workos/routes/invitations.js +122 -0
  119. package/dist/emulate/workos/routes/invitations.js.map +1 -0
  120. package/dist/emulate/workos/routes/legacy-mfa.d.ts +2 -0
  121. package/dist/emulate/workos/routes/legacy-mfa.js +75 -0
  122. package/dist/emulate/workos/routes/legacy-mfa.js.map +1 -0
  123. package/dist/emulate/workos/routes/magic-auth.d.ts +2 -0
  124. package/dist/emulate/workos/routes/magic-auth.js +32 -0
  125. package/dist/emulate/workos/routes/magic-auth.js.map +1 -0
  126. package/dist/emulate/workos/routes/memberships.d.ts +2 -0
  127. package/dist/emulate/workos/routes/memberships.js +114 -0
  128. package/dist/emulate/workos/routes/memberships.js.map +1 -0
  129. package/dist/emulate/workos/routes/organization-domains.d.ts +2 -0
  130. package/dist/emulate/workos/routes/organization-domains.js +58 -0
  131. package/dist/emulate/workos/routes/organization-domains.js.map +1 -0
  132. package/dist/emulate/workos/routes/organizations.d.ts +2 -0
  133. package/dist/emulate/workos/routes/organizations.js +131 -0
  134. package/dist/emulate/workos/routes/organizations.js.map +1 -0
  135. package/dist/emulate/workos/routes/password-reset.d.ts +2 -0
  136. package/dist/emulate/workos/routes/password-reset.js +61 -0
  137. package/dist/emulate/workos/routes/password-reset.js.map +1 -0
  138. package/dist/emulate/workos/routes/pipes.d.ts +2 -0
  139. package/dist/emulate/workos/routes/pipes.js +82 -0
  140. package/dist/emulate/workos/routes/pipes.js.map +1 -0
  141. package/dist/emulate/workos/routes/portal.d.ts +2 -0
  142. package/dist/emulate/workos/routes/portal.js +18 -0
  143. package/dist/emulate/workos/routes/portal.js.map +1 -0
  144. package/dist/emulate/workos/routes/radar.d.ts +2 -0
  145. package/dist/emulate/workos/routes/radar.js +41 -0
  146. package/dist/emulate/workos/routes/radar.js.map +1 -0
  147. package/dist/emulate/workos/routes/sessions.d.ts +2 -0
  148. package/dist/emulate/workos/routes/sessions.js +51 -0
  149. package/dist/emulate/workos/routes/sessions.js.map +1 -0
  150. package/dist/emulate/workos/routes/sso.d.ts +2 -0
  151. package/dist/emulate/workos/routes/sso.js +161 -0
  152. package/dist/emulate/workos/routes/sso.js.map +1 -0
  153. package/dist/emulate/workos/routes/user-features.d.ts +2 -0
  154. package/dist/emulate/workos/routes/user-features.js +50 -0
  155. package/dist/emulate/workos/routes/user-features.js.map +1 -0
  156. package/dist/emulate/workos/routes/users.d.ts +2 -0
  157. package/dist/emulate/workos/routes/users.js +129 -0
  158. package/dist/emulate/workos/routes/users.js.map +1 -0
  159. package/dist/emulate/workos/routes/webhook-endpoints.d.ts +2 -0
  160. package/dist/emulate/workos/routes/webhook-endpoints.js +66 -0
  161. package/dist/emulate/workos/routes/webhook-endpoints.js.map +1 -0
  162. package/dist/emulate/workos/routes/widgets.d.ts +2 -0
  163. package/dist/emulate/workos/routes/widgets.js +27 -0
  164. package/dist/emulate/workos/routes/widgets.js.map +1 -0
  165. package/dist/emulate/workos/store.d.ts +48 -0
  166. package/dist/emulate/workos/store.js +102 -0
  167. package/dist/emulate/workos/store.js.map +1 -0
  168. package/dist/emulate/workos/webhook-signer.d.ts +1 -0
  169. package/dist/emulate/workos/webhook-signer.js +8 -0
  170. package/dist/emulate/workos/webhook-signer.js.map +1 -0
  171. package/dist/gen-routes-lib.spec.ts +659 -0
  172. package/dist/gen-routes-lib.ts +647 -0
  173. package/dist/gen-routes.ts +96 -0
  174. package/dist/lib/dev-command.d.ts +26 -0
  175. package/dist/lib/dev-command.js +122 -0
  176. package/dist/lib/dev-command.js.map +1 -0
  177. package/dist/lib/run-with-core.js +0 -3
  178. package/dist/lib/run-with-core.js.map +1 -1
  179. package/dist/lib/settings.js +1 -1
  180. package/dist/lib/settings.js.map +1 -1
  181. package/dist/utils/help-json.js +1 -0
  182. package/dist/utils/help-json.js.map +1 -1
  183. package/dist/utils/register-subcommand.d.ts +5 -2
  184. package/dist/utils/register-subcommand.js +16 -19
  185. package/dist/utils/register-subcommand.js.map +1 -1
  186. package/package.json +21 -8
@@ -0,0 +1,659 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ type OpenAPISpec,
4
+ type ParsedEntity,
5
+ type ParsedRoute,
6
+ parseSpec,
7
+ generateEntities,
8
+ generateStore,
9
+ generateHelpers,
10
+ generateRoutes,
11
+ schemaToTsType,
12
+ toSnakeCase,
13
+ toPascalCase,
14
+ toCamelCase,
15
+ pluralize,
16
+ singularize,
17
+ openApiPathToHono,
18
+ } from './gen-routes-lib.js';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Utility helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ describe('toSnakeCase', () => {
25
+ it('converts PascalCase', () => {
26
+ expect(toSnakeCase('Organization')).toBe('organization');
27
+ expect(toSnakeCase('OrganizationDomain')).toBe('organization_domain');
28
+ expect(toSnakeCase('SSOProfile')).toBe('sso_profile');
29
+ });
30
+
31
+ it('handles already snake_case', () => {
32
+ expect(toSnakeCase('organization')).toBe('organization');
33
+ });
34
+ });
35
+
36
+ describe('toPascalCase', () => {
37
+ it('converts snake_case', () => {
38
+ expect(toPascalCase('organization')).toBe('Organization');
39
+ expect(toPascalCase('organization_domain')).toBe('OrganizationDomain');
40
+ });
41
+
42
+ it('converts hyphenated', () => {
43
+ expect(toPascalCase('magic-auth')).toBe('MagicAuth');
44
+ });
45
+ });
46
+
47
+ describe('toCamelCase', () => {
48
+ it('converts snake_case', () => {
49
+ expect(toCamelCase('organization')).toBe('organization');
50
+ expect(toCamelCase('organization_domain')).toBe('organizationDomain');
51
+ });
52
+ });
53
+
54
+ describe('pluralize', () => {
55
+ it('adds -s to regular words', () => {
56
+ expect(pluralize('organization')).toBe('organizations');
57
+ expect(pluralize('user')).toBe('users');
58
+ });
59
+
60
+ it('adds -ies for consonant+y', () => {
61
+ expect(pluralize('identity')).toBe('identities');
62
+ });
63
+
64
+ it('adds -es for words ending in s/x/z', () => {
65
+ expect(pluralize('address')).toBe('addresses');
66
+ });
67
+ });
68
+
69
+ describe('singularize', () => {
70
+ it('removes trailing -s', () => {
71
+ expect(singularize('organizations')).toBe('organization');
72
+ expect(singularize('users')).toBe('user');
73
+ });
74
+
75
+ it('handles -ies', () => {
76
+ expect(singularize('identities')).toBe('identity');
77
+ });
78
+
79
+ it('handles -ses', () => {
80
+ expect(singularize('addresses')).toBe('address');
81
+ });
82
+ });
83
+
84
+ describe('openApiPathToHono', () => {
85
+ it('converts path params', () => {
86
+ expect(openApiPathToHono('/organizations/{id}')).toBe('/organizations/:id');
87
+ expect(openApiPathToHono('/users/{user_id}/sessions')).toBe('/users/:user_id/sessions');
88
+ });
89
+
90
+ it('handles multiple params', () => {
91
+ expect(openApiPathToHono('/orgs/{org_id}/members/{id}')).toBe('/orgs/:org_id/members/:id');
92
+ });
93
+
94
+ it('passes through paths without params', () => {
95
+ expect(openApiPathToHono('/organizations')).toBe('/organizations');
96
+ });
97
+ });
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // schemaToTsType
101
+ // ---------------------------------------------------------------------------
102
+
103
+ describe('schemaToTsType', () => {
104
+ const emptySpec: OpenAPISpec = {};
105
+
106
+ it('converts string type', () => {
107
+ expect(schemaToTsType({ type: 'string' }, emptySpec)).toBe('string');
108
+ });
109
+
110
+ it('converts integer type', () => {
111
+ expect(schemaToTsType({ type: 'integer' }, emptySpec)).toBe('number');
112
+ });
113
+
114
+ it('converts boolean type', () => {
115
+ expect(schemaToTsType({ type: 'boolean' }, emptySpec)).toBe('boolean');
116
+ });
117
+
118
+ it('converts enum to union type', () => {
119
+ expect(schemaToTsType({ type: 'string', enum: ['active', 'inactive'] }, emptySpec)).toBe("'active' | 'inactive'");
120
+ });
121
+
122
+ it('converts array type', () => {
123
+ expect(schemaToTsType({ type: 'array', items: { type: 'string' } }, emptySpec)).toBe('string[]');
124
+ });
125
+
126
+ it('converts object with additionalProperties', () => {
127
+ expect(schemaToTsType({ type: 'object', additionalProperties: { type: 'string' } }, emptySpec)).toBe(
128
+ 'Record<string, string>',
129
+ );
130
+ });
131
+
132
+ it('handles unknown type', () => {
133
+ expect(schemaToTsType({}, emptySpec)).toBe('unknown');
134
+ });
135
+
136
+ it('resolves $ref', () => {
137
+ const spec: OpenAPISpec = {
138
+ components: {
139
+ schemas: {
140
+ Status: { type: 'string', enum: ['active', 'pending'] },
141
+ },
142
+ },
143
+ };
144
+ expect(schemaToTsType({ $ref: '#/components/schemas/Status' }, spec)).toBe("'active' | 'pending'");
145
+ });
146
+ });
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // parseSpec
150
+ // ---------------------------------------------------------------------------
151
+
152
+ describe('parseSpec', () => {
153
+ function makeSpec(overrides: Partial<OpenAPISpec> = {}): OpenAPISpec {
154
+ return {
155
+ openapi: '3.0.0',
156
+ info: { title: 'Test', version: '1.0.0' },
157
+ ...overrides,
158
+ };
159
+ }
160
+
161
+ it('returns empty entities and routes for empty spec', () => {
162
+ const result = parseSpec(makeSpec());
163
+ expect(result.entities).toEqual([]);
164
+ expect(result.routes).toEqual([]);
165
+ });
166
+
167
+ it('extracts an entity from a schema', () => {
168
+ const spec = makeSpec({
169
+ components: {
170
+ schemas: {
171
+ Organization: {
172
+ type: 'object',
173
+ required: ['name'],
174
+ properties: {
175
+ id: { type: 'string' },
176
+ object: { type: 'string', enum: ['organization'] },
177
+ name: { type: 'string' },
178
+ external_id: { type: 'string', nullable: true },
179
+ created_at: { type: 'string', format: 'date-time' },
180
+ updated_at: { type: 'string', format: 'date-time' },
181
+ },
182
+ },
183
+ },
184
+ },
185
+ });
186
+
187
+ const result = parseSpec(spec);
188
+ expect(result.entities).toHaveLength(1);
189
+
190
+ const org = result.entities[0];
191
+ expect(org.name).toBe('Organization');
192
+ expect(org.objectType).toBe('organization');
193
+ expect(org.idPrefix).toBe('org');
194
+ // id, created_at, updated_at should be excluded from fields
195
+ expect(org.fields.find((f) => f.name === 'id')).toBeUndefined();
196
+ expect(org.fields.find((f) => f.name === 'created_at')).toBeUndefined();
197
+ expect(org.fields.find((f) => f.name === 'updated_at')).toBeUndefined();
198
+ // object and name should be present
199
+ expect(org.fields.find((f) => f.name === 'object')).toBeDefined();
200
+ expect(org.fields.find((f) => f.name === 'name')).toBeDefined();
201
+ expect(org.fields.find((f) => f.name === 'external_id')).toBeDefined();
202
+ });
203
+
204
+ it('indexes external_id and fields ending with _id', () => {
205
+ const spec = makeSpec({
206
+ components: {
207
+ schemas: {
208
+ Membership: {
209
+ type: 'object',
210
+ required: ['organization_id', 'user_id'],
211
+ properties: {
212
+ object: { type: 'string' },
213
+ organization_id: { type: 'string' },
214
+ user_id: { type: 'string' },
215
+ external_id: { type: 'string', nullable: true },
216
+ },
217
+ },
218
+ },
219
+ },
220
+ });
221
+
222
+ const result = parseSpec(spec);
223
+ const membership = result.entities[0];
224
+ expect(membership.indexFields).toContain('organization_id');
225
+ expect(membership.indexFields).toContain('user_id');
226
+ expect(membership.indexFields).toContain('external_id');
227
+ });
228
+
229
+ it('extracts routes from paths', () => {
230
+ const spec = makeSpec({
231
+ paths: {
232
+ '/organizations': {
233
+ get: {
234
+ tags: ['organizations'],
235
+ operationId: 'listOrganizations',
236
+ summary: 'List organizations',
237
+ },
238
+ post: {
239
+ tags: ['organizations'],
240
+ operationId: 'createOrganization',
241
+ summary: 'Create organization',
242
+ },
243
+ },
244
+ '/organizations/{id}': {
245
+ get: {
246
+ tags: ['organizations'],
247
+ operationId: 'getOrganization',
248
+ summary: 'Get organization',
249
+ },
250
+ put: {
251
+ tags: ['organizations'],
252
+ operationId: 'updateOrganization',
253
+ summary: 'Update organization',
254
+ },
255
+ delete: {
256
+ tags: ['organizations'],
257
+ operationId: 'deleteOrganization',
258
+ summary: 'Delete organization',
259
+ },
260
+ },
261
+ },
262
+ });
263
+
264
+ const result = parseSpec(spec);
265
+ expect(result.routes).toHaveLength(1);
266
+
267
+ const route = result.routes[0];
268
+ expect(route.tag).toBe('organizations');
269
+ expect(route.filename).toBe('organizations.ts');
270
+ expect(route.functionName).toBe('organizationRoutes');
271
+ expect(route.storeAccessor).toBe('organizations');
272
+ expect(route.formatterName).toBe('formatOrganization');
273
+ expect(route.operations).toHaveLength(5);
274
+
275
+ const listOp = route.operations.find((o) => o.operationId === 'listOrganizations')!;
276
+ expect(listOp.method).toBe('get');
277
+ expect(listOp.isList).toBe(true);
278
+ expect(listOp.hasIdParam).toBe(false);
279
+
280
+ const getOp = route.operations.find((o) => o.operationId === 'getOrganization')!;
281
+ expect(getOp.method).toBe('get');
282
+ expect(getOp.isList).toBe(false);
283
+ expect(getOp.hasIdParam).toBe(true);
284
+ expect(getOp.path).toBe('/organizations/:id');
285
+ });
286
+
287
+ it('infers tag from path when no tags provided', () => {
288
+ const spec = makeSpec({
289
+ paths: {
290
+ '/connections': {
291
+ get: { operationId: 'listConnections' },
292
+ },
293
+ },
294
+ });
295
+
296
+ const result = parseSpec(spec);
297
+ expect(result.routes[0].tag).toBe('connections');
298
+ });
299
+ });
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // Code generation
303
+ // ---------------------------------------------------------------------------
304
+
305
+ const sampleEntity: ParsedEntity = {
306
+ name: 'Organization',
307
+ objectType: 'organization',
308
+ idPrefix: 'org',
309
+ fields: [
310
+ { name: 'object', tsType: "'organization'", nullable: false },
311
+ { name: 'name', tsType: 'string', nullable: false },
312
+ { name: 'external_id', tsType: 'string', nullable: true },
313
+ { name: 'metadata', tsType: 'Record<string, string>', nullable: false },
314
+ ],
315
+ indexFields: ['name', 'external_id'],
316
+ };
317
+
318
+ describe('generateEntities', () => {
319
+ it('generates entity interface', () => {
320
+ const output = generateEntities([sampleEntity]);
321
+ expect(output).toContain("import type { Entity } from '../../core/index.js';");
322
+ expect(output).toContain('export interface WorkOSOrganization extends Entity {');
323
+ expect(output).toContain(" object: 'organization';");
324
+ expect(output).toContain(' name: string;');
325
+ expect(output).toContain(' external_id: string | null;');
326
+ expect(output).toContain(' metadata: Record<string, string>;');
327
+ });
328
+
329
+ it('does not duplicate null in already-nullable types', () => {
330
+ const entity: ParsedEntity = {
331
+ name: 'Test',
332
+ objectType: 'test',
333
+ idPrefix: 'test',
334
+ fields: [{ name: 'value', tsType: 'string | null', nullable: true }],
335
+ indexFields: [],
336
+ };
337
+ const output = generateEntities([entity]);
338
+ // Should not produce "string | null | null"
339
+ expect(output).toContain(' value: string | null;');
340
+ expect(output).not.toContain('null | null');
341
+ });
342
+ });
343
+
344
+ describe('generateStore', () => {
345
+ it('generates store interface and factory', () => {
346
+ const output = generateStore([sampleEntity]);
347
+ expect(output).toContain('export interface WorkOSGeneratedStore {');
348
+ expect(output).toContain(' organizations: Collection<WorkOSOrganization>;');
349
+ expect(output).toContain('export function getWorkOSGeneratedStore(store: Store): WorkOSGeneratedStore {');
350
+ expect(output).toContain(
351
+ "store.collection<WorkOSOrganization>('workos.organizations', 'org', ['name', 'external_id'])",
352
+ );
353
+ });
354
+ });
355
+
356
+ describe('generateHelpers', () => {
357
+ it('generates format functions', () => {
358
+ const output = generateHelpers([sampleEntity]);
359
+ expect(output).toContain(
360
+ 'export function formatOrganization(organization: WorkOSOrganization): Record<string, unknown> {',
361
+ );
362
+ expect(output).toContain(" object: 'organization',");
363
+ expect(output).toContain(' id: organization.id,');
364
+ expect(output).toContain(' name: organization.name,');
365
+ expect(output).toContain(' created_at: organization.created_at,');
366
+ expect(output).toContain(' updated_at: organization.updated_at,');
367
+ });
368
+
369
+ it('generates parseListParams', () => {
370
+ const output = generateHelpers([sampleEntity]);
371
+ expect(output).toContain('export function parseListParams(url: URL)');
372
+ });
373
+ });
374
+
375
+ describe('generateRoutes', () => {
376
+ const sampleRoute: ParsedRoute = {
377
+ tag: 'organizations',
378
+ filename: 'organizations.ts',
379
+ functionName: 'organizationRoutes',
380
+ storeAccessor: 'organizations',
381
+ formatterName: 'formatOrganization',
382
+ operations: [
383
+ { method: 'post', path: '/organizations', hasIdParam: false, isList: false, queryParams: [] },
384
+ {
385
+ method: 'get',
386
+ path: '/organizations',
387
+ operationId: 'listOrganizations',
388
+ summary: 'List organizations',
389
+ hasIdParam: false,
390
+ isList: true,
391
+ queryParams: ['limit', 'order'],
392
+ },
393
+ {
394
+ method: 'get',
395
+ path: '/organizations/:id',
396
+ operationId: 'getOrganization',
397
+ summary: 'Get organization',
398
+ hasIdParam: true,
399
+ isList: false,
400
+ queryParams: [],
401
+ },
402
+ {
403
+ method: 'put',
404
+ path: '/organizations/:id',
405
+ operationId: 'updateOrganization',
406
+ hasIdParam: true,
407
+ isList: false,
408
+ queryParams: [],
409
+ },
410
+ {
411
+ method: 'delete',
412
+ path: '/organizations/:id',
413
+ operationId: 'deleteOrganization',
414
+ hasIdParam: true,
415
+ isList: false,
416
+ queryParams: [],
417
+ },
418
+ ],
419
+ };
420
+
421
+ it('generates route function with correct structure', () => {
422
+ const output = generateRoutes(sampleRoute);
423
+ expect(output).toContain('export function organizationRoutes(ctx: RouteContext): void {');
424
+ expect(output).toContain('const ws = getWorkOSGeneratedStore(store);');
425
+ });
426
+
427
+ it('generates POST handler', () => {
428
+ const output = generateRoutes(sampleRoute);
429
+ expect(output).toContain("app.post('/organizations', async (c) => {");
430
+ expect(output).toContain('const body = await parseJsonBody(c);');
431
+ expect(output).toContain('ws.organizations.insert({');
432
+ expect(output).toContain('return c.json(formatOrganization(item), 201);');
433
+ });
434
+
435
+ it('generates list GET handler', () => {
436
+ const output = generateRoutes(sampleRoute);
437
+ expect(output).toContain("app.get('/organizations', (c) => {");
438
+ expect(output).toContain('const params = parseListParams(url);');
439
+ expect(output).toContain("object: 'list',");
440
+ expect(output).toContain('data: result.data.map(formatOrganization),');
441
+ });
442
+
443
+ it('generates single GET handler', () => {
444
+ const output = generateRoutes(sampleRoute);
445
+ expect(output).toContain("app.get('/organizations/:id', (c) => {");
446
+ expect(output).toContain("ws.organizations.get(c.req.param('id'))");
447
+ expect(output).toContain("if (!item) throw notFound('Organization');");
448
+ });
449
+
450
+ it('generates PUT handler', () => {
451
+ const output = generateRoutes(sampleRoute);
452
+ expect(output).toContain("app.put('/organizations/:id', async (c) => {");
453
+ expect(output).toContain('ws.organizations.update(item.id, body)');
454
+ });
455
+
456
+ it('generates DELETE handler', () => {
457
+ const output = generateRoutes(sampleRoute);
458
+ expect(output).toContain("app.delete('/organizations/:id', (c) => {");
459
+ expect(output).toContain('ws.organizations.delete(item.id);');
460
+ expect(output).toContain('return c.body(null, 204);');
461
+ });
462
+ });
463
+
464
+ // ---------------------------------------------------------------------------
465
+ // Idempotency
466
+ // ---------------------------------------------------------------------------
467
+
468
+ describe('idempotency', () => {
469
+ it('produces identical output when run twice', () => {
470
+ const spec: OpenAPISpec = {
471
+ openapi: '3.0.0',
472
+ info: { title: 'Test', version: '1.0.0' },
473
+ components: {
474
+ schemas: {
475
+ Widget: {
476
+ type: 'object',
477
+ required: ['name'],
478
+ properties: {
479
+ object: { type: 'string' },
480
+ name: { type: 'string' },
481
+ color: { type: 'string', nullable: true },
482
+ },
483
+ },
484
+ },
485
+ },
486
+ paths: {
487
+ '/widgets': {
488
+ get: { tags: ['widgets'], operationId: 'listWidgets' },
489
+ post: { tags: ['widgets'], operationId: 'createWidget' },
490
+ },
491
+ '/widgets/{id}': {
492
+ get: { tags: ['widgets'], operationId: 'getWidget' },
493
+ delete: { tags: ['widgets'], operationId: 'deleteWidget' },
494
+ },
495
+ },
496
+ };
497
+
498
+ const run1 = parseSpec(spec);
499
+ const run2 = parseSpec(spec);
500
+
501
+ expect(generateEntities(run1.entities)).toBe(generateEntities(run2.entities));
502
+ expect(generateStore(run1.entities)).toBe(generateStore(run2.entities));
503
+ expect(generateHelpers(run1.entities)).toBe(generateHelpers(run2.entities));
504
+
505
+ for (let i = 0; i < run1.routes.length; i++) {
506
+ expect(generateRoutes(run1.routes[i])).toBe(generateRoutes(run2.routes[i]));
507
+ }
508
+ });
509
+ });
510
+
511
+ // ---------------------------------------------------------------------------
512
+ // End-to-end: full spec parsing + generation
513
+ // ---------------------------------------------------------------------------
514
+
515
+ describe('end-to-end generation', () => {
516
+ const spec: OpenAPISpec = {
517
+ openapi: '3.0.0',
518
+ info: { title: 'WorkOS', version: '1.0.0' },
519
+ components: {
520
+ schemas: {
521
+ Organization: {
522
+ type: 'object',
523
+ required: ['name', 'object'],
524
+ properties: {
525
+ id: { type: 'string' },
526
+ object: { type: 'string', enum: ['organization'] },
527
+ name: { type: 'string' },
528
+ external_id: { type: 'string', nullable: true },
529
+ metadata: { type: 'object', additionalProperties: { type: 'string' } },
530
+ created_at: { type: 'string', format: 'date-time' },
531
+ updated_at: { type: 'string', format: 'date-time' },
532
+ },
533
+ },
534
+ User: {
535
+ type: 'object',
536
+ required: ['email', 'object'],
537
+ properties: {
538
+ id: { type: 'string' },
539
+ object: { type: 'string', enum: ['user'] },
540
+ email: { type: 'string' },
541
+ first_name: { type: 'string', nullable: true },
542
+ last_name: { type: 'string', nullable: true },
543
+ email_verified: { type: 'boolean' },
544
+ created_at: { type: 'string', format: 'date-time' },
545
+ updated_at: { type: 'string', format: 'date-time' },
546
+ },
547
+ },
548
+ },
549
+ },
550
+ paths: {
551
+ '/organizations': {
552
+ get: {
553
+ tags: ['organizations'],
554
+ operationId: 'listOrganizations',
555
+ summary: 'List organizations',
556
+ parameters: [
557
+ { name: 'limit', in: 'query', schema: { type: 'integer' } },
558
+ { name: 'name', in: 'query', schema: { type: 'string' } },
559
+ ],
560
+ },
561
+ post: {
562
+ tags: ['organizations'],
563
+ operationId: 'createOrganization',
564
+ summary: 'Create organization',
565
+ },
566
+ },
567
+ '/organizations/{id}': {
568
+ get: {
569
+ tags: ['organizations'],
570
+ operationId: 'getOrganization',
571
+ summary: 'Get an organization',
572
+ },
573
+ put: {
574
+ tags: ['organizations'],
575
+ operationId: 'updateOrganization',
576
+ summary: 'Update an organization',
577
+ },
578
+ delete: {
579
+ tags: ['organizations'],
580
+ operationId: 'deleteOrganization',
581
+ summary: 'Delete an organization',
582
+ },
583
+ },
584
+ '/user_management/users': {
585
+ get: {
586
+ tags: ['user_management_users'],
587
+ operationId: 'listUsers',
588
+ summary: 'List users',
589
+ },
590
+ post: {
591
+ tags: ['user_management_users'],
592
+ operationId: 'createUser',
593
+ summary: 'Create user',
594
+ },
595
+ },
596
+ '/user_management/users/{id}': {
597
+ get: {
598
+ tags: ['user_management_users'],
599
+ operationId: 'getUser',
600
+ summary: 'Get user',
601
+ },
602
+ },
603
+ },
604
+ };
605
+
606
+ it('parses entities from schemas', () => {
607
+ const parsed = parseSpec(spec);
608
+ expect(parsed.entities).toHaveLength(2);
609
+ expect(parsed.entities.map((e) => e.name).sort()).toEqual(['Organization', 'User']);
610
+ });
611
+
612
+ it('parses routes from paths', () => {
613
+ const parsed = parseSpec(spec);
614
+ expect(parsed.routes).toHaveLength(2);
615
+ const tags = parsed.routes.map((r) => r.tag).sort();
616
+ expect(tags).toEqual(['organizations', 'user_management_users']);
617
+ });
618
+
619
+ it('generates valid entity code', () => {
620
+ const parsed = parseSpec(spec);
621
+ const entitiesCode = generateEntities(parsed.entities);
622
+ // Should produce valid-looking TypeScript
623
+ expect(entitiesCode).toContain('export interface WorkOSOrganization extends Entity');
624
+ expect(entitiesCode).toContain('export interface WorkOSUser extends Entity');
625
+ });
626
+
627
+ it('generates store with all entities', () => {
628
+ const parsed = parseSpec(spec);
629
+ const storeCode = generateStore(parsed.entities);
630
+ expect(storeCode).toContain('organizations: Collection<WorkOSOrganization>');
631
+ expect(storeCode).toContain('users: Collection<WorkOSUser>');
632
+ });
633
+
634
+ it('generates helpers with format functions', () => {
635
+ const parsed = parseSpec(spec);
636
+ const helpersCode = generateHelpers(parsed.entities);
637
+ expect(helpersCode).toContain('export function formatOrganization');
638
+ expect(helpersCode).toContain('export function formatUser');
639
+ });
640
+
641
+ it('generates route stubs', () => {
642
+ const parsed = parseSpec(spec);
643
+ const orgRoute = parsed.routes.find((r) => r.tag === 'organizations')!;
644
+ const routeCode = generateRoutes(orgRoute);
645
+ expect(routeCode).toContain("app.post('/organizations'");
646
+ expect(routeCode).toContain("app.get('/organizations'");
647
+ expect(routeCode).toContain("app.get('/organizations/:id'");
648
+ expect(routeCode).toContain("app.put('/organizations/:id'");
649
+ expect(routeCode).toContain("app.delete('/organizations/:id'");
650
+ });
651
+
652
+ it('handles query parameters in list endpoints', () => {
653
+ const parsed = parseSpec(spec);
654
+ const orgRoute = parsed.routes.find((r) => r.tag === 'organizations')!;
655
+ const listOp = orgRoute.operations.find((o) => o.isList)!;
656
+ expect(listOp.queryParams).toContain('limit');
657
+ expect(listOp.queryParams).toContain('name');
658
+ });
659
+ });