diff --git a/apps/backend/README.md b/apps/backend/README.md index 6dd13e7..be1d58d 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -9,3 +9,21 @@ bun run dev ``` open http://localhost:3000 + +## Tests + +Run all backend tests: +```sh +bun run test +``` + +### Test naming convention + +- `*.test.ts` or `*.spec.ts`: service-level tests. These run with the shared Prisma preload at `test/support/prisma.mock.ts`. +- `*.handler.test.ts`: handler-level tests. These should mock the service module locally with `mock.module(...)` and import the handler after the mock is in place. + +### Recommended pattern + +- Keep business logic tests focused on the service layer. +- Use handler tests only to verify request parsing, status mapping, and response shaping. +- Put shared Prisma mocking helpers in `test/support/`. diff --git a/apps/backend/bunfig.toml b/apps/backend/bunfig.toml new file mode 100644 index 0000000..70c4948 --- /dev/null +++ b/apps/backend/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./test/support/prisma.mock.ts"] diff --git a/apps/backend/package.json b/apps/backend/package.json index 03b81c2..f482c65 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -4,6 +4,7 @@ "dev": "bun run --hot src/server.ts", "start": "bun src/server.ts", "build": "tsc -b", + "test": "bun test", "lint": "biome check .", "lint:fix": "biome check --write .", "format": "biome format --write .", @@ -33,6 +34,7 @@ }, "devDependencies": { "@types/bun": "latest", - "@types/nodemailer": "^8.0.0" + "@types/nodemailer": "^8.0.0", + "bun-mock-prisma": "^1.2.1" } } diff --git a/apps/backend/src/modules/complex/handlers/invite-complex-user.handler.ts b/apps/backend/src/modules/complex/handlers/invite-complex-user.handler.ts index e3293af..49c4149 100644 --- a/apps/backend/src/modules/complex/handlers/invite-complex-user.handler.ts +++ b/apps/backend/src/modules/complex/handlers/invite-complex-user.handler.ts @@ -10,24 +10,38 @@ const complexParamsSchema = z.object({ id: z.uuid(), }); -export async function inviteComplexUserHandler(c: AppContext) { - const user = c.get('user'); - const params = complexParamsSchema.parse(c.req.param()); - const payload = c.req.valid('json' as never) as InviteComplexUserInput; +type InviteComplexUserHandlerDeps = { + inviteComplexUser: typeof inviteComplexUser; + ComplexMembersError: typeof ComplexMembersError; +}; - try { - const result = await inviteComplexUser(user.id, params.id, payload); - return c.json(result, 201); - } catch (error) { - if (error instanceof ComplexMembersError) { - return c.json( - { message: error.message }, - { - status: error.status, - } - ); - } - - throw error; +export function createInviteComplexUserHandler( + deps: InviteComplexUserHandlerDeps = { + inviteComplexUser, + ComplexMembersError, } +) { + return async function inviteComplexUserHandler(c: AppContext) { + const user = c.get('user'); + const params = complexParamsSchema.parse(c.req.param()); + const payload = c.req.valid('json' as never) as InviteComplexUserInput; + + try { + const result = await deps.inviteComplexUser(user.id, params.id, payload); + return c.json(result, 201); + } catch (error) { + if (error instanceof deps.ComplexMembersError) { + return c.json( + { message: error.message }, + { + status: error.status, + } + ); + } + + throw error; + } + }; } + +export const inviteComplexUserHandler = createInviteComplexUserHandler(); diff --git a/apps/backend/test/complex/invite-complex-user.handler.test.ts b/apps/backend/test/complex/invite-complex-user.handler.test.ts new file mode 100644 index 0000000..a70c167 --- /dev/null +++ b/apps/backend/test/complex/invite-complex-user.handler.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, expect, mock, test } from 'bun:test'; + +import type { InviteComplexUserResponse } from '@repo/api-contract'; +import { createInviteComplexUserHandler } from '@/modules/complex/handlers/invite-complex-user.handler'; + +const inviteComplexUserMock = mock( + async () => undefined as unknown as InviteComplexUserResponse +); + +class MockComplexMembersError extends Error { + status: 400 | 403 | 404 | 409; + + constructor(message: string, status: 400 | 403 | 404 | 409 = 400) { + super(message); + this.name = 'ComplexMembersError'; + this.status = status; + } +} + +const inviteComplexUserHandler = createInviteComplexUserHandler({ + inviteComplexUser: inviteComplexUserMock, + ComplexMembersError: MockComplexMembersError, +}); + +type HandlerContext = { + get: (key: 'user' | 'session') => { id: string } | undefined; + req: { + param: () => Record; + valid: () => unknown; + }; + json: ReturnType; +}; + +function createContext(input: { + userId: string; + params: Record; + body: unknown; +}) { + const json = mock((payload: unknown, init?: unknown) => ({ + payload, + init, + })); + + const c = { + get: (key: 'user' | 'session') => { + if (key === 'user') { + return { id: input.userId }; + } + + return undefined; + }, + req: { + param: () => input.params, + valid: () => input.body, + }, + json, + } as unknown as HandlerContext; + + return { c, json }; +} + +beforeEach(() => { + inviteComplexUserMock.mockReset(); + mock.clearAllMocks(); +}); + +test('returns 201 with the service result', async () => { + const serviceResult = { + invitation: { + id: 'inv-1', + complexId: 'complex-1', + email: 'new.member@example.com', + status: 'PENDING', + createdAt: '2026-04-22T00:00:00.000Z', + expiresAt: '2026-04-29T00:00:00.000Z', + acceptedAt: null, + revokedAt: null, + }, + } as const; + + inviteComplexUserMock.mockResolvedValue(serviceResult); + + const { c, json } = createContext({ + userId: 'admin-1', + params: { id: '11111111-1111-4111-8111-111111111111' }, + body: { email: 'new.member@example.com' }, + }); + + const response = await inviteComplexUserHandler(c as never); + + expect(inviteComplexUserMock).toHaveBeenCalledWith( + 'admin-1', + '11111111-1111-4111-8111-111111111111', + { email: 'new.member@example.com' } + ); + expect(json.mock.calls[0]?.[0]).toEqual(serviceResult); + expect(json.mock.calls[0]?.[1]).toBe(201); + expect(response).toBeDefined(); +}); + +test('maps ComplexMembersError to an error response', async () => { + inviteComplexUserMock.mockRejectedValue( + new MockComplexMembersError('Solo un ADMIN puede administrar usuarios.', 403) + ); + + const { c, json } = createContext({ + userId: 'employee-1', + params: { id: '11111111-1111-4111-8111-111111111111' }, + body: { email: 'new.member@example.com' }, + }); + + const response = await inviteComplexUserHandler(c as never); + + expect(inviteComplexUserMock).toHaveBeenCalledWith( + 'employee-1', + '11111111-1111-4111-8111-111111111111', + { email: 'new.member@example.com' } + ); + expect(json.mock.calls[0]?.[0]).toEqual({ + message: 'Solo un ADMIN puede administrar usuarios.', + }); + expect(json.mock.calls[0]?.[1]).toEqual({ status: 403 }); + expect(response).toBeDefined(); +}); diff --git a/apps/backend/test/complex/invite-complex-user.test.ts b/apps/backend/test/complex/invite-complex-user.test.ts new file mode 100644 index 0000000..5dfb832 --- /dev/null +++ b/apps/backend/test/complex/invite-complex-user.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, expect, mock, test } from 'bun:test'; + +import { prismaMock, sendMailMock, transactionMock } from '../support/prisma.mock'; +import { + ComplexMembersError, + inviteComplexUser, +} from '@/modules/complex/services/complex-members.service'; + +beforeEach(() => { + prismaMock._reset(); + mock.clearAllMocks(); +}); + +test('creates a pending invitation and sends the email', async () => { + prismaMock.complexUser.findUnique.mockResolvedValue({ role: 'ADMIN' } as never); + prismaMock.complex.findUnique.mockResolvedValue({ complexName: 'Playzer Norte' } as never); + prismaMock.user.findUnique.mockResolvedValue(null); + prismaMock.complexInvitation.findFirst.mockResolvedValue(null); + prismaMock.complexInvitation.create.mockResolvedValue({ + id: 'inv-1', + complexId: 'complex-1', + email: 'new.member@example.com', + tokenHash: 'token-hash', + expiresAt: new Date('2026-04-29T00:00:00.000Z'), + acceptedAt: null, + revokedAt: null, + createdAt: new Date('2026-04-22T00:00:00.000Z'), + } as never); + + const result = await inviteComplexUser('admin-1', 'complex-1', { + email: ' New.Member@example.com ', + }); + + expect(result.invitation.email).toBe('new.member@example.com'); + expect(result.invitation.status).toBe('PENDING'); + expect(prismaMock.complexInvitation.create).toHaveBeenCalledTimes(1); + + const createArgs = prismaMock.complexInvitation.create.mock.calls[0]?.[0] as { + data: { + complexId: string; + email: string; + id: string; + tokenHash: string; + expiresAt: Date; + }; + }; + + expect(createArgs.data.complexId).toBe('complex-1'); + expect(createArgs.data.email).toBe('new.member@example.com'); + expect(typeof createArgs.data.id).toBe('string'); + expect(typeof createArgs.data.tokenHash).toBe('string'); + expect(sendMailMock).toHaveBeenCalledTimes(1); + expect(transactionMock).toHaveBeenCalledTimes(1); + + const mailArgs = sendMailMock.mock.calls[0][0] as { + to: string; + subject: string; + }; + + expect(mailArgs.to).toBe('new.member@example.com'); + expect(mailArgs.subject).toContain('Playzer Norte'); +}); + +test('rejects when the invite target already belongs to the complex', async () => { + prismaMock.complexUser.findUnique + .mockResolvedValueOnce({ role: 'ADMIN' } as never) + .mockResolvedValueOnce({ userId: 'member-1' } as never); + prismaMock.complex.findUnique.mockResolvedValue({ complexName: 'Playzer Norte' } as never); + prismaMock.user.findUnique.mockResolvedValue({ id: 'member-1' } as never); + + let caught: unknown; + + try { + await inviteComplexUser('admin-1', 'complex-1', { + email: 'member@example.com', + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(ComplexMembersError); + expect((caught as ComplexMembersError).status).toBe(409); + expect(transactionMock).not.toHaveBeenCalled(); + expect(sendMailMock).not.toHaveBeenCalled(); +}); diff --git a/apps/backend/test/support/prisma.mock.ts b/apps/backend/test/support/prisma.mock.ts new file mode 100644 index 0000000..27029e3 --- /dev/null +++ b/apps/backend/test/support/prisma.mock.ts @@ -0,0 +1,33 @@ +import { mock } from 'bun:test'; +import { createPrismaMock } from 'bun-mock-prisma'; + +import type { PrismaClient } from '@/generated/prisma/client'; +import type { PrismaClientMock } from 'bun-mock-prisma'; + +export const prismaMock = createPrismaMock() as PrismaClientMock; +export const sendMailMock = mock(async (_input: { + to: string; + subject: string; + html: string; + text: string; +}) => undefined); +export const transactionMock = mock(async (fn: (tx: PrismaClientMock) => Promise) => + fn(prismaMock) +); +export const dbMock = { + complexUser: prismaMock.complexUser, + complex: prismaMock.complex, + user: prismaMock.user, + complexInvitation: prismaMock.complexInvitation, + $transaction: transactionMock, +}; + +mock.module('@/lib/prisma', () => ({ + __esModule: true, + db: dbMock, +})); + +mock.module('@/lib/mailer', () => ({ + __esModule: true, + sendMail: sendMailMock, +})); diff --git a/bun.lock b/bun.lock index c1b3646..1fee589 100644 --- a/bun.lock +++ b/bun.lock @@ -37,6 +37,7 @@ "devDependencies": { "@types/bun": "latest", "@types/nodemailer": "^8.0.0", + "bun-mock-prisma": "^1.2.1", }, }, "apps/frontend": { @@ -624,7 +625,7 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], - "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], @@ -728,7 +729,9 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "bun-mock-prisma": ["bun-mock-prisma@1.2.1", "", { "peerDependencies": { "bun-types": ">=1.0.0" } }, "sha512-1c6CByUMk9acH7R87lcVoJqPpUWoqHRu6VwipXzU94s03/FXotACOt2DMgGhfz2xw5218BVoPJiH0QQXUwgmWg=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],