feat: implement complex user invitation handler with tests and configuration
This commit is contained in:
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,
|
||||
}));
|
||||
Reference in New Issue
Block a user