feat: implement complex user invitation handler with tests and configuration
This commit is contained in:
@@ -9,3 +9,21 @@ bun run dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
open http://localhost:3000
|
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/`.
|
||||||
|
|||||||
2
apps/backend/bunfig.toml
Normal file
2
apps/backend/bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[test]
|
||||||
|
preload = ["./test/support/prisma.mock.ts"]
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"dev": "bun run --hot src/server.ts",
|
"dev": "bun run --hot src/server.ts",
|
||||||
"start": "bun src/server.ts",
|
"start": "bun src/server.ts",
|
||||||
"build": "tsc -b",
|
"build": "tsc -b",
|
||||||
|
"test": "bun test",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"lint:fix": "biome check --write .",
|
"lint:fix": "biome check --write .",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/nodemailer": "^8.0.0"
|
"@types/nodemailer": "^8.0.0",
|
||||||
|
"bun-mock-prisma": "^1.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,24 +10,38 @@ const complexParamsSchema = z.object({
|
|||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function inviteComplexUserHandler(c: AppContext) {
|
type InviteComplexUserHandlerDeps = {
|
||||||
const user = c.get('user');
|
inviteComplexUser: typeof inviteComplexUser;
|
||||||
const params = complexParamsSchema.parse(c.req.param());
|
ComplexMembersError: typeof ComplexMembersError;
|
||||||
const payload = c.req.valid('json' as never) as InviteComplexUserInput;
|
};
|
||||||
|
|
||||||
try {
|
export function createInviteComplexUserHandler(
|
||||||
const result = await inviteComplexUser(user.id, params.id, payload);
|
deps: InviteComplexUserHandlerDeps = {
|
||||||
return c.json(result, 201);
|
inviteComplexUser,
|
||||||
} catch (error) {
|
ComplexMembersError,
|
||||||
if (error instanceof ComplexMembersError) {
|
|
||||||
return c.json(
|
|
||||||
{ message: error.message },
|
|
||||||
{
|
|
||||||
status: error.status,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
) {
|
||||||
|
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();
|
||||||
|
|||||||
124
apps/backend/test/complex/invite-complex-user.handler.test.ts
Normal file
124
apps/backend/test/complex/invite-complex-user.handler.test.ts
Normal file
@@ -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<string, string>;
|
||||||
|
valid: () => unknown;
|
||||||
|
};
|
||||||
|
json: ReturnType<typeof mock>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createContext(input: {
|
||||||
|
userId: string;
|
||||||
|
params: Record<string, string>;
|
||||||
|
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();
|
||||||
|
});
|
||||||
85
apps/backend/test/complex/invite-complex-user.test.ts
Normal file
85
apps/backend/test/complex/invite-complex-user.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
33
apps/backend/test/support/prisma.mock.ts
Normal file
33
apps/backend/test/support/prisma.mock.ts
Normal file
@@ -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<PrismaClient>() as PrismaClientMock<PrismaClient>;
|
||||||
|
export const sendMailMock = mock(async (_input: {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
text: string;
|
||||||
|
}) => undefined);
|
||||||
|
export const transactionMock = mock(async <T>(fn: (tx: PrismaClientMock<PrismaClient>) => Promise<T>) =>
|
||||||
|
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,
|
||||||
|
}));
|
||||||
7
bun.lock
7
bun.lock
@@ -37,6 +37,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
|
"bun-mock-prisma": "^1.2.1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"apps/frontend": {
|
"apps/frontend": {
|
||||||
@@ -624,7 +625,7 @@
|
|||||||
|
|
||||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user