feat: implement complex user invitation handler with tests and configuration

This commit is contained in:
Jose Selesan
2026-04-22 15:58:34 -03:00
parent 6ad4fc9ee9
commit 4658b168ed
8 changed files with 302 additions and 21 deletions

View 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();
});

View 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();
});