diff --git a/bun.lock b/bun.lock index 07ace0c..f72f8e6 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "@types/bun": "^1.3.14", "@types/node": "~22.19.7", "@types/semver": "^7.7.1", + "@workos-inc/node": "^10.8.0", "@workos/openapi-spec": "^0.80.0", "husky": "^9.1.7", "oxfmt": "^0.62.0", @@ -148,6 +149,8 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "@workos-inc/node": ["@workos-inc/node@10.8.0", "", {}, "sha512-BmKwxaouV4hYhoJ9K0UPLC9LTpVfvnayvAy3lG9ELbZBoEWeaKkXoYn4W+d3vK7SZQ4qj/8PE7ve7sUKXnpRDA=="], + "@workos/openapi-spec": ["@workos/openapi-spec@0.80.0", "", {}, "sha512-jMAJVVvnWEdT3cq8G8H7U7gWJ3gIC19gRmkxB7DEA7YU2NXbhfGOt2Z4GDtspgljRt/GAp/pPT2+B5oaLIUyjw=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], diff --git a/package.json b/package.json index 3f53360..3a92d84 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@types/bun": "^1.3.14", "@types/node": "~22.19.7", "@types/semver": "^7.7.1", + "@workos-inc/node": "^10.8.0", "@workos/openapi-spec": "^0.80.0", "husky": "^9.1.7", "oxfmt": "^0.62.0", diff --git a/src/core/middleware/error-handler.ts b/src/core/middleware/error-handler.ts index 8043026..de15222 100644 --- a/src/core/middleware/error-handler.ts +++ b/src/core/middleware/error-handler.ts @@ -6,7 +6,7 @@ export class WorkOSApiError extends Error { public status: number, message: string, public code: string, - public errors?: Array<{ field: string; code: string; message?: string }>, + public errors?: Array<{ field?: string; code: string; message?: string }>, ) { super(message); this.name = 'WorkOSApiError'; diff --git a/src/workos/routes/users.spec.ts b/src/workos/routes/users.spec.ts index 7997c6f..afbdd32 100644 --- a/src/workos/routes/users.spec.ts +++ b/src/workos/routes/users.spec.ts @@ -1,6 +1,8 @@ import { describe, it, expect, beforeEach } from 'bun:test'; +import { BadRequestException } from '@workos-inc/node'; import { createServer, type ApiKeyMap } from '../../core/index.js'; import { workosPlugin } from '../index.js'; +import { sdkClient } from '../sdk.test-utils.js'; const apiKeys: ApiKeyMap = { sk_test_users: { environment: 'test' } }; const headers = { Authorization: 'Bearer sk_test_users', 'Content-Type': 'application/json' }; @@ -83,8 +85,11 @@ describe('User routes', () => { method: 'POST', body: JSON.stringify({ email: 'user@x.test' }), }); - expect(second.status).toBe(409); - expect((await json(second)).code).toBe('user_already_exists'); + expect(second.status).toBe(400); + expect(await json(second)).toMatchObject({ + code: 'user_creation_error', + errors: [{ code: 'email_not_available' }], + }); }); // This is the lookup an SDK's listUsers({ email }) maps to, so it is how a caller finds the @@ -106,17 +111,53 @@ describe('User routes', () => { expect(miss.data).toHaveLength(0); }); - it('rejects duplicate email', async () => { - await req('/user_management/users', { - method: 'POST', - body: JSON.stringify({ email: 'dup@test.com' }), - }); + it('matches the user-creation contract for a duplicate email without changing the existing user', async () => { + const created = await json( + await req('/user_management/users', { + method: 'POST', + body: JSON.stringify({ + email: 'dup@test.com', + first_name: 'Original', + last_name: 'User', + password: 'pass123', + }), + }), + ); const res = await req('/user_management/users', { method: 'POST', - body: JSON.stringify({ email: 'dup@test.com' }), + body: JSON.stringify({ + email: 'dup@test.com', + first_name: 'Changed', + last_name: 'User', + email_verified: true, + external_id: 'dup@test.com', + }), }); - expect(res.status).toBe(409); - expect((await json(res)).code).toBe('user_already_exists'); + expect(res.status).toBe(400); + expect(await json(res)).toEqual({ + message: 'Could not create user.', + code: 'user_creation_error', + errors: [{ code: 'email_not_available', message: 'This email is not available.' }], + }); + + const unchanged = await json(await req(`/user_management/users/${created.id}`)); + expect(unchanged).toMatchObject({ + id: created.id, + email: 'dup@test.com', + first_name: 'Original', + last_name: 'User', + email_verified: false, + external_id: null, + }); + }); + + // The body is asserted above; this checks only what the SDK makes of it. + it('is decoded as BadRequestException by @workos-inc/node', async () => { + const workos = sdkClient(app, 'sk_test_users'); + await workos.userManagement.createUser({ email: 'sdk-dup@test.com' }); + const dup = workos.userManagement.createUser({ email: 'sdk-dup@test.com' }); + await expect(dup).rejects.toBeInstanceOf(BadRequestException); + await expect(dup).rejects.toMatchObject({ status: 400 }); }); it('gets user by id', async () => { diff --git a/src/workos/routes/users.ts b/src/workos/routes/users.ts index c707ce9..f5cc6e0 100644 --- a/src/workos/routes/users.ts +++ b/src/workos/routes/users.ts @@ -38,7 +38,13 @@ export function userRoutes(ctx: RouteContext): void { // the ambiguity by insertion order. const existing = findUserByEmail(ws, email); if (existing) { - throw new WorkOSApiError(409, 'A user with this email already exists', 'user_already_exists'); + // The spec (UserlandUsersController_create in @workos/openapi-spec) documents no 409 for this + // endpoint: a taken email is a 400 `user_creation_error` carrying the reason in `errors`, and + // `email_not_available` is the detail production sends. Keep this specific to user creation; + // other 409 contracts remain independently meaningful to their SDK callers. + throw new WorkOSApiError(400, 'Could not create user.', 'user_creation_error', [ + { code: 'email_not_available', message: 'This email is not available.' }, + ]); } if (body.name !== undefined && body.name !== null && typeof body.name !== 'string') { diff --git a/src/workos/sdk.test-utils.ts b/src/workos/sdk.test-utils.ts new file mode 100644 index 0000000..d55b1de --- /dev/null +++ b/src/workos/sdk.test-utils.ts @@ -0,0 +1,14 @@ +import { WorkOS } from '@workos-inc/node'; +import type { createServer } from '../core/index.js'; + +// An @workos-inc/node client whose requests are served in-process by `app` — no port, no +// listener — so a test can assert what the real SDK makes of an emulator response (which +// exception class, which fields). Bun's `typeof fetch` carries `preconnect`, hence the +// Object.assign rather than a bare arrow. +export function sdkClient(app: ReturnType['app'], apiKey: string): WorkOS { + const fetchFn: typeof fetch = Object.assign( + async (input: Request | string | URL, init?: RequestInit) => app.request(input, init), + { preconnect: fetch.preconnect }, + ); + return new WorkOS({ apiKey, apiHostname: 'emulate.test', https: false, maxRetries: 0, fetchFn }); +}