feat: implement collaborator reactivation logic, add access denied handling, and improve metric filtering

This commit is contained in:
2026-07-19 18:56:39 -03:00
parent af6c87acb6
commit 74d6ee6706
27 changed files with 3148 additions and 37 deletions
+200 -3
View File
@@ -14,6 +14,7 @@ import {
UpdateEmployeeParams,
DeleteEmployeesByCompany,
DeleteEmployeeParams,
RemoveCollaboratorParams,
} from "./Employees.Interface";
import UsersManager from "../Users/Users";
import { ICompanyDocument } from "../Companies/Companies.Adapter.Mongoose";
@@ -26,7 +27,9 @@ import { TextObjectFilterResult } from "../../Models/TextObjectFilter.model";
import MetricsList from "../../Models/Metrics/Metrics";
import { NotificationsManager } from "../Notifications/Notifications";
import EmployeesServicesList from "../EmployeesServices/EmployeesServices";
import HeatMapList from "../HeatMap/HeatMap";
import SchedulesList from "../Schedules/Schedules";
import AppointmentList from "../Appointments/Appointments";
import { IncompleteCollaboratorView } from "./Employees.Interface";
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
@@ -51,7 +54,11 @@ class EmployeeManager implements IEmployeesManager {
role: EmployeeRoles
): Promise<boolean> {
//Check if role exist in roles array.
const check = await this.employees.findOne({ companyId: companyId, userId: employeeId });
const check = await this.employees.findOne({
companyId: companyId,
userId: employeeId,
removed: { $ne: true },
} as any);
if (!check) {
return false;
@@ -112,6 +119,53 @@ class EmployeeManager implements IEmployeesManager {
userId: data.userId,
});
// Reactivation: if employee exists and is removed, reactivate instead of creating duplicate
if (employeeCheck && employeeCheck.removed === true) {
// Check quota before reactivation
const canAdd = await MetricsList.canAddEmployee(companyCheck.ownerId);
if (!canAdd) {
throw new Error(
"Ha alcanzado el limite de colaboradores permitidos de acuerdo a su plan."
);
}
// Clear removal metadata
employeeCheck.removed = false;
employeeCheck.removedAt = undefined;
employeeCheck.removedBy = undefined;
// Refresh profileSnapshot from current User
employeeCheck.profileSnapshot = {
firstName: userCheck.firstName || undefined,
lastName: userCheck.lastName || undefined,
email: userCheck.email || undefined,
avatar: userCheck.avatar || undefined,
};
// Reset invitation state for full re-acceptance flow
employeeCheck.hostOk = false;
employeeCheck.guestOk = false;
await employeeCheck.save();
// Increment metrics
await MetricsList.addEmployee({
userId: companyCheck.ownerId,
quantity: 1,
});
// Send invitation notification
await NotificationsManager.sendSystemNotification({
userId: String(data.userId),
subject: "Su usuario ha sido vinculado a una organización",
message: `La compañía ${companyCheck.name} lo ha vinculado como colaborador. Es necesario que acepte la invitación para poder formar parte de esta organización.`,
type: NotificationType.EMPLOYEE,
code: String(employeeCheck._id),
});
return employeeCheck;
}
if (employeeCheck) {
throw new Error("El usuario ya pertenece a la compañia");
}
@@ -346,7 +400,7 @@ class EmployeeManager implements IEmployeesManager {
}
public async findByCompanyId(data: FindEmployeesParams): Promise<CompanyEmployeesView[]> {
const employees = await this.employees.find(data);
const employees = await this.employees.find({ ...data, removed: { $ne: true } } as any);
const view = await Promise.all(
employees.map(async (employee) => {
@@ -422,7 +476,7 @@ class EmployeeManager implements IEmployeesManager {
}
public async textObjectFilter(data: FindEmployeesParams): Promise<TextObjectFilterResult[]> {
const employees = await this.employees.find(data);
const employees = await this.employees.find({ ...data, removed: { $ne: true } } as any);
const formattedResult: TextObjectFilterResult[] = [];
for (const colaborador of employees) {
@@ -522,8 +576,151 @@ class EmployeeManager implements IEmployeesManager {
}
return incompleteCollaborators;
}
public async removeCollaboratorFromCompany(data: RemoveCollaboratorParams): Promise<void> {
// Validate session user
const sessionUser = await UsersManager.users.findOne({ _id: data.sessionUser });
if (!sessionUser) {
throw new Error(
"No se ha encontrado el usuario o no tiene permisos para realizar esta acción"
);
}
// Validate company exists
const companyCheck = await CompaniesManager.companies.findOne({ _id: data.companyId });
if (!companyCheck) {
throw new Error("La compañia no existe");
}
// Validate target employee exists
const employee = await this.employees.findOne({
_id: data.employeeId,
});
if (!employee) {
throw new Error("El colaborador no existe");
}
// Check if already removed
if (employee.removed) {
throw new Error("El colaborador ya fue eliminado de la organización");
}
// Validate permission (admin or owner)
if (
!(await this.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))
) {
throw new Error(NoPermissionMessage());
}
// Self-removal prevention
if (String(employee.userId) === data.sessionUser) {
throw new Error("No puedes eliminarte a ti mismo de la organización");
}
// Owner protection
if (employee.roles && employee.roles.includes(EmployeeRoles.OWNER)) {
throw new Error("No se puede eliminar al propietario de la organización");
}
// Query future appointments for this employee
const futureAppointments = await AppointmentList.Appointments.AppointmentList.find({
companyId: data.companyId,
employeeId: data.employeeId,
start: { $gt: new Date() },
});
// If there are future appointments, require a replacement
if (futureAppointments && futureAppointments.length > 0) {
if (!data.replacementEmployeeId) {
throw new Error(
"El colaborador tiene turnos futuros asignados. Se requiere un empleado de reemplazo."
);
}
// Validate replacement employee
const replacementEmployee = await this.employees.findOne({
_id: data.replacementEmployeeId,
});
if (!replacementEmployee) {
throw new Error("El empleado de reemplazo no existe");
}
if (String(replacementEmployee.companyId) !== data.companyId) {
throw new Error("El empleado de reemplazo no pertenece a esta organización");
}
if (replacementEmployee.removed) {
throw new Error("El empleado de reemplazo fue eliminado de la organización");
}
if (String(replacementEmployee.userId) === data.sessionUser) {
throw new Error(
"No puedes designarte como reemplazo de ti mismo"
);
}
// Reassign future appointments
for (const appointment of futureAppointments) {
appointment.employeeId = data.replacementEmployeeId;
await appointment.save();
}
// Reassign active repeats
const RepeatsList = (await import("../Repeats/Repeats")).default;
const activeRepeats = await RepeatsList.repeats.find({
companyId: data.companyId,
employeeId: data.employeeId,
});
for (const repeat of activeRepeats) {
// Access the underlying Mongoose model to get save() capability
await RepeatsList.repeats.findOne({ _id: repeat.id }).then(async (doc) => {
if (doc) {
doc.employeeId = data.replacementEmployeeId!;
await doc.save();
}
});
}
}
// Snapshot user profile
const targetUser = await UsersManager.users.findOne({ _id: employee.userId });
if (targetUser) {
employee.profileSnapshot = {
firstName: targetUser.firstName || undefined,
lastName: targetUser.lastName || undefined,
email: targetUser.email || undefined,
avatar: targetUser.avatar || undefined,
};
}
// Mark employee as removed
employee.removed = true;
employee.removedAt = new Date();
employee.removedBy = data.sessionUser;
await employee.save();
// Cleanup: delete employee services and heatmap data
await EmployeesServicesList.deleteEmployeeServiceByEmployee({
employeeId: data.employeeId,
});
await HeatMapList.deleteHeatMapByEmployee({
employeeId: data.employeeId,
});
// Decrement metrics
await MetricsList.addEmployee({
userId: companyCheck.ownerId,
quantity: -1,
});
}
}
export { EmployeeManager };
const EmployeesList = new EmployeeManager();
export default EmployeesList;
@@ -23,6 +23,18 @@ export class EmployeesAdapterMongoose implements IEmployeesAdapter {
limit: { type: Number, required: true, default: -1 },
hostOk: { type: Boolean, required: true, default: false },
guestOk: { type: Boolean, required: true, default: false },
removed: { type: Boolean, required: false, default: false },
removedAt: { type: Date, required: false },
removedBy: { type: Schema.Types.ObjectId, required: false, ref: "User" },
profileSnapshot: {
type: {
firstName: { type: String, required: false },
lastName: { type: String, required: false },
email: { type: String, required: false },
avatar: { type: String, required: false },
},
required: false,
},
creationDate: { type: Date, required: true, default: Date.now },
});
@@ -68,6 +68,13 @@ export type DeleteEmployeeParams = {
employeeId: string;
};
export type RemoveCollaboratorParams = {
employeeId: string;
companyId: string;
replacementEmployeeId?: string;
sessionUser: string;
};
export interface IEmployee {
id?: string;
companyId: string;
@@ -77,6 +84,15 @@ export interface IEmployee {
limit?: number;
guestOk?: boolean;
hostOk?: boolean;
removed?: boolean;
removedAt?: Date;
removedBy?: string;
profileSnapshot?: {
firstName?: string;
lastName?: string;
email?: string;
avatar?: string;
};
}
export interface CompanyEmployeesView {
@@ -93,6 +109,14 @@ export interface CompanyEmployeesView {
hostOk: boolean;
fullOk: boolean;
calendarColor: string;
removed?: boolean;
removedAt?: Date;
profileSnapshot?: {
firstName?: string;
lastName?: string;
email?: string;
avatar?: string;
};
}
export interface IncompleteCollaboratorView {
@@ -125,4 +149,5 @@ export interface IEmployeesManager {
deleteEmployee(data: DeleteEmployeeParams): Promise<void>;
rejectGuest(data: ValidateEmployeeParams): Promise<void>;
getIncompleteSetup(sessionUser: string): Promise<IncompleteCollaboratorView[]>;
removeCollaboratorFromCompany(data: RemoveCollaboratorParams): Promise<void>;
}
@@ -0,0 +1,233 @@
import { EmployeeRoles } from "../Employees.Interface";
// Mock all external dependencies before importing the module under test
jest.mock("../../Users/Users", () => ({
__esModule: true,
default: {
users: {
findOne: jest.fn(),
},
getUserFullName: jest.fn(),
},
}));
jest.mock("../../Companies/Companies", () => ({
__esModule: true,
default: {
companies: {
findOne: jest.fn(),
},
},
}));
jest.mock("../../Metrics/Metrics", () => ({
__esModule: true,
default: {
addEmployee: jest.fn(),
canAddEmployee: jest.fn(),
},
}));
jest.mock("../../Notifications/Notifications", () => ({
__esModule: true,
NotificationsManager: {
sendSystemNotification: jest.fn(),
},
}));
jest.mock("../../EmployeesServices/EmployeesServices", () => ({
__esModule: true,
default: {
deleteEmployeeServiceByEmployee: jest.fn(),
},
}));
jest.mock("../../HeatMap/HeatMap", () => ({
__esModule: true,
default: {
deleteHeatMapByEmployee: jest.fn(),
},
}));
jest.mock("../../Repeats/Repeats", () => ({
__esModule: true,
default: {
repeats: {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn(),
},
},
}));
jest.mock("../../Schedules/Schedules", () => ({
__esModule: true,
default: {
find: jest.fn().mockResolvedValue([]),
},
}));
jest.mock("../../Services/Service", () => ({
__esModule: true,
default: {
services: {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn(),
},
},
}));
jest.mock("../../../index", () => ({
io: {
to: jest.fn(() => ({ emit: jest.fn() })),
},
}));
import EmployeesList from "../Employee";
import UsersManager from "../../Users/Users";
describe("Query filtering — removed employees excluded", () => {
let originalAdapter: any;
let mockFind: jest.Mock;
let mockFindOne: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
originalAdapter = EmployeesList.employees;
mockFind = jest.fn();
mockFindOne = jest.fn();
(EmployeesList as any).employees = {
findOne: mockFindOne,
find: mockFind,
create: jest.fn(),
employeeList: {
findOne: mockFindOne,
find: mockFind,
create: jest.fn(),
deleteMany: jest.fn(),
},
};
});
afterEach(() => {
(EmployeesList as any).employees = originalAdapter;
});
// --- findByCompanyId excludes removed ---
describe("findByCompanyId", () => {
it("excludes removed employees from results", async () => {
const activeEmployee = {
_id: "emp-active",
id: "emp-active",
companyId: "company-001",
userId: "user-active",
roles: [EmployeeRoles.EMPLOYEE],
guestOk: true,
hostOk: true,
calendarColor: "#ff9900",
removed: false,
};
// findByCompanyId calls find with removed: { $ne: true }
// The adapter find should return only non-removed employees
mockFind.mockResolvedValue([activeEmployee]);
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({
_id: "user-active",
firstName: "Active",
lastName: "User",
email: "active@example.com",
avatar: "",
});
const result = await EmployeesList.findByCompanyId({
companyId: "company-001",
});
// Verify the query included the removed filter
expect(mockFind).toHaveBeenCalledWith(
expect.objectContaining({
companyId: "company-001",
removed: { $ne: true },
})
);
// Verify only the active employee is in the result
expect(result).toHaveLength(1);
expect(result[0].id).toBe("emp-active");
});
it("returns empty array when all employees are removed", async () => {
// Adapter returns empty because filter excluded all removed
mockFind.mockResolvedValue([]);
const result = await EmployeesList.findByCompanyId({
companyId: "company-001",
});
expect(result).toEqual([]);
expect(mockFind).toHaveBeenCalledWith(
expect.objectContaining({
removed: { $ne: true },
})
);
});
});
// --- textObjectFilter excludes removed ---
describe("textObjectFilter", () => {
it("excludes removed employees from results", async () => {
const activeEmployee = {
_id: "emp-active",
id: "emp-active",
companyId: "company-001",
userId: "user-active",
removed: false,
};
// Only active employees returned after filter
mockFind.mockResolvedValue([activeEmployee]);
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({
_id: "user-active",
firstName: "John",
lastName: "Doe",
avatar: "",
});
const result = await EmployeesList.textObjectFilter({
companyId: "company-001",
});
expect(mockFind).toHaveBeenCalledWith(
expect.objectContaining({
removed: { $ne: true },
})
);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("John Doe");
});
it("returns empty when only removed employees exist", async () => {
mockFind.mockResolvedValue([]);
const result = await EmployeesList.textObjectFilter({
companyId: "company-001",
});
// Verify the removed filter was applied to the query
expect(mockFind).toHaveBeenCalledWith(
expect.objectContaining({
removed: { $ne: true },
})
);
expect(result).toEqual([]);
expect(result).toHaveLength(0);
});
});
});
@@ -0,0 +1,343 @@
import { EmployeeRoles } from "../Employees.Interface";
// Mock all external dependencies before importing the module under test
jest.mock("../../Users/Users", () => ({
__esModule: true,
default: {
users: {
findOne: jest.fn(),
},
getUserFullName: jest.fn(),
},
}));
jest.mock("../../Companies/Companies", () => ({
__esModule: true,
default: {
companies: {
findOne: jest.fn(),
},
},
}));
jest.mock("../../Metrics/Metrics", () => ({
__esModule: true,
default: {
addEmployee: jest.fn(),
canAddEmployee: jest.fn(),
},
}));
jest.mock("../../Notifications/Notifications", () => ({
__esModule: true,
NotificationsManager: {
sendSystemNotification: jest.fn(),
},
}));
jest.mock("../../EmployeesServices/EmployeesServices", () => ({
__esModule: true,
default: {
deleteEmployeeServiceByEmployee: jest.fn(),
},
}));
jest.mock("../../HeatMap/HeatMap", () => ({
__esModule: true,
default: {
deleteHeatMapByEmployee: jest.fn(),
},
}));
jest.mock("../../Repeats/Repeats", () => ({
__esModule: true,
default: {
repeats: {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn(),
},
},
}));
jest.mock("../../../index", () => ({
io: {
to: jest.fn(() => ({ emit: jest.fn() })),
},
}));
import EmployeesList from "../Employee";
import UsersManager from "../../Users/Users";
import CompaniesManager from "../../Companies/Companies";
import MetricsList from "../../Metrics/Metrics";
describe("createEmployee — reactivation of removed collaborator", () => {
let originalAdapter: any;
let mockFindOne: jest.Mock;
let mockCreate: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
originalAdapter = EmployeesList.employees;
mockFindOne = jest.fn();
mockCreate = jest.fn();
(EmployeesList as any).employees = {
findOne: mockFindOne,
find: jest.fn(),
create: mockCreate,
employeeList: {
findOne: mockFindOne,
find: jest.fn(),
create: mockCreate,
},
};
});
afterEach(() => {
(EmployeesList as any).employees = originalAdapter;
});
/**
* Call order in createEmployee:
* 1. UsersManager.users.findOne → session user
* 2. CompaniesManager.companies.findOne → company
* 3. checkPermission → checkRoleById → this.employees.findOne → admin employee record
* 4. UsersManager.users.findOne → user being invited
* 5. this.employees.findOne → existing employee lookup by (companyId, userId)
*/
it("reactivates a removed employee instead of creating a duplicate", async () => {
const adminEmployee = {
_id: "emp-admin",
companyId: "company-001",
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
removed: false,
};
const removedEmployee = {
_id: "emp-removed",
id: "emp-removed",
companyId: "company-001",
userId: "user-002",
roles: [EmployeeRoles.EMPLOYEE],
removed: true,
removedAt: new Date(),
removedBy: "admin-user",
hostOk: true,
guestOk: true,
profileSnapshot: { firstName: "Old", lastName: "Name" },
save: jest.fn().mockResolvedValue(true),
};
const userToInvite = {
_id: "user-002",
firstName: "John",
lastName: "Doe",
email: "john@example.com",
avatar: "new-avatar",
};
// Call 1: session user → Call 2: user being invited
(UsersManager.users.findOne as jest.Mock)
.mockResolvedValueOnce({ _id: "admin-user" })
.mockResolvedValueOnce(userToInvite);
// CompaniesManager.companies.findOne → company
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
name: "Test Company",
});
// this.employees.findOne:
// Call 1: checkRoleById → admin employee record
// Call 2: existing employee lookup → found + removed
mockFindOne
.mockResolvedValueOnce(adminEmployee)
.mockResolvedValueOnce(removedEmployee);
(MetricsList.canAddEmployee as jest.Mock).mockResolvedValue(true);
const result = await EmployeesList.createEmployee({
companyId: "company-001",
userId: "user-002",
sessionUser: "admin-user",
});
// Verify employee was reactivated
expect(removedEmployee.removed).toBe(false);
expect(removedEmployee.removedAt).toBeUndefined();
expect(removedEmployee.removedBy).toBeUndefined();
// Verify profileSnapshot was refreshed from current User
expect(removedEmployee.profileSnapshot).toEqual({
firstName: "John",
lastName: "Doe",
email: "john@example.com",
avatar: "new-avatar",
});
// Verify invitation state was reset
expect(removedEmployee.hostOk).toBe(false);
expect(removedEmployee.guestOk).toBe(false);
// Verify save was called
expect(removedEmployee.save).toHaveBeenCalled();
// Verify metrics were incremented
expect(MetricsList.addEmployee).toHaveBeenCalledWith({
userId: "owner-user",
quantity: 1,
});
// Verify no new employee was created
expect(mockCreate).not.toHaveBeenCalled();
// Verify same employee returned
expect(result.id).toBe("emp-removed");
});
it("throws quota error when reactivation would exceed plan limit", async () => {
const adminEmployee = {
_id: "emp-admin",
companyId: "company-001",
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
removed: false,
};
const removedEmployee = {
_id: "emp-removed",
companyId: "company-001",
userId: "user-002",
removed: true,
save: jest.fn(),
};
// Call 1: session user → Call 2: user being invited
(UsersManager.users.findOne as jest.Mock)
.mockResolvedValueOnce({ _id: "admin-user" })
.mockResolvedValueOnce({ _id: "user-002" });
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
// this.employees.findOne: admin check → removed employee lookup
mockFindOne
.mockResolvedValueOnce(adminEmployee)
.mockResolvedValueOnce(removedEmployee);
// Quota is full
(MetricsList.canAddEmployee as jest.Mock).mockResolvedValue(false);
await expect(
EmployeesList.createEmployee({
companyId: "company-001",
userId: "user-002",
sessionUser: "admin-user",
})
).rejects.toThrow("limite de colaboradores");
// Verify employee was NOT reactivated
expect(removedEmployee.save).not.toHaveBeenCalled();
});
it("throws 'El usuario no existe' when reinviting a removed employee whose User record was deleted", async () => {
// Scenario: Employee record exists and is removed, but the underlying User
// was deleted (e.g. admin cleanup). createEmployee must reject the invitation
// BEFORE attempting reactivation because User validation gates the entire flow.
const removedEmployee = {
_id: "emp-removed",
id: "emp-removed",
companyId: "company-001",
userId: "user-deleted",
removed: true,
save: jest.fn().mockResolvedValue(true),
};
// Call 1: session user (owner, so checkPermission short-circuits)
(UsersManager.users.findOne as jest.Mock)
.mockResolvedValueOnce({ _id: "owner-user" })
.mockResolvedValueOnce(null); // invited user was DELETED
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
// checkPermission short-circuits for owner — no employees.findOne needed.
// But createEmployee still calls employees.findOne AFTER userCheck.
// Since userCheck is null, the method should throw before reaching employees.findOne.
await expect(
EmployeesList.createEmployee({
companyId: "company-001",
userId: "user-deleted",
sessionUser: "owner-user",
})
).rejects.toThrow("El usuario no existe");
// Critical: reactivation must NOT have been attempted
expect(removedEmployee.save).not.toHaveBeenCalled();
expect(MetricsList.addEmployee).not.toHaveBeenCalled();
});
it("preserves same employeeId across removal and reactivation", async () => {
const adminEmployee = {
_id: "emp-admin",
companyId: "company-001",
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
removed: false,
};
const removedEmployee = {
_id: "emp-original-id",
id: "emp-original-id",
companyId: "company-001",
userId: "user-002",
removed: true,
save: jest.fn().mockResolvedValue(true),
};
const userToInvite = {
_id: "user-002",
firstName: "Jane",
lastName: "Smith",
email: "jane@example.com",
avatar: "",
};
// Call 1: session user → Call 2: user being invited
(UsersManager.users.findOne as jest.Mock)
.mockResolvedValueOnce({ _id: "admin-user" })
.mockResolvedValueOnce(userToInvite);
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
// this.employees.findOne: admin check → removed employee lookup
mockFindOne
.mockResolvedValueOnce(adminEmployee)
.mockResolvedValueOnce(removedEmployee);
(MetricsList.canAddEmployee as jest.Mock).mockResolvedValue(true);
const result = await EmployeesList.createEmployee({
companyId: "company-001",
userId: "user-002",
sessionUser: "admin-user",
});
// Same _id preserved
expect(result.id).toBe("emp-original-id");
// No new employee created
expect(mockCreate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,632 @@
import { EmployeeRoles } from "../Employees.Interface";
// Mock all external dependencies before importing the module under test
jest.mock("../../Users/Users", () => ({
__esModule: true,
default: {
users: {
findOne: jest.fn(),
},
getUserFullName: jest.fn(),
},
}));
jest.mock("../../Companies/Companies", () => ({
__esModule: true,
default: {
companies: {
findOne: jest.fn(),
},
},
}));
jest.mock("../../Metrics/Metrics", () => ({
__esModule: true,
default: {
addEmployee: jest.fn(),
canAddEmployee: jest.fn(),
},
}));
jest.mock("../../Notifications/Notifications", () => ({
__esModule: true,
NotificationsManager: {
sendSystemNotification: jest.fn(),
},
}));
jest.mock("../../EmployeesServices/EmployeesServices", () => ({
__esModule: true,
default: {
deleteEmployeeServiceByEmployee: jest.fn(),
},
}));
jest.mock("../../HeatMap/HeatMap", () => ({
__esModule: true,
default: {
deleteHeatMapByEmployee: jest.fn(),
},
}));
jest.mock("../../Repeats/Repeats", () => ({
__esModule: true,
default: {
repeats: {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn(),
},
},
}));
jest.mock("../../Appointments/Appointments", () => ({
__esModule: true,
default: {
Appointments: {
AppointmentList: {
find: jest.fn().mockResolvedValue([]),
},
},
},
}));
jest.mock("../../../index", () => ({
io: {
to: jest.fn(() => ({ emit: jest.fn() })),
},
}));
import EmployeesList from "../Employee";
import UsersManager from "../../Users/Users";
import CompaniesManager from "../../Companies/Companies";
import MetricsList from "../../Metrics/Metrics";
import EmployeesServicesList from "../../EmployeesServices/EmployeesServices";
import HeatMapList from "../../HeatMap/HeatMap";
import AppointmentList from "../../Appointments/Appointments";
// Helper to build a mock employee document with save()
function buildEmployeeDoc(overrides: Record<string, any> = {}) {
const doc = {
_id: "emp-001",
id: "emp-001",
companyId: "company-001",
userId: "user-002",
roles: [EmployeeRoles.EMPLOYEE],
calendarColor: "#ff9900",
hostOk: true,
guestOk: true,
removed: false,
removedAt: undefined,
removedBy: undefined,
profileSnapshot: undefined,
save: jest.fn().mockResolvedValue(true),
...overrides,
};
return doc;
}
describe("removeCollaboratorFromCompany", () => {
let originalAdapter: any;
let mockFindOne: jest.Mock;
let mockAppointmentFind: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
// Save original adapter reference
originalAdapter = EmployeesList.employees;
// Create mock adapter
mockFindOne = jest.fn();
const mockFind = jest.fn();
const mockCreate = jest.fn();
const mockDeleteMany = jest.fn();
(EmployeesList as any).employees = {
findOne: mockFindOne,
find: mockFind,
create: mockCreate,
employeeList: {
findOne: mockFindOne,
find: mockFind,
deleteMany: mockDeleteMany,
create: mockCreate,
},
};
mockAppointmentFind = (AppointmentList.Appointments.AppointmentList.find as jest.Mock).mockResolvedValue([]);
});
afterEach(() => {
// Restore original adapter
(EmployeesList as any).employees = originalAdapter;
});
// --- Session User Validation ---
it("throws when session user does not exist", async () => {
(UsersManager.users.findOne as jest.Mock).mockResolvedValue(null);
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "company-001",
sessionUser: "nonexistent-user",
})
).rejects.toThrow("No se ha encontrado el usuario");
});
// --- Company Validation ---
it("throws when company does not exist", async () => {
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue(null);
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "nonexistent-company",
sessionUser: "admin-user",
})
).rejects.toThrow("La compañia no existe");
});
// --- Employee Not Found ---
it("throws when target employee does not exist", async () => {
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
mockFindOne.mockResolvedValueOnce(null);
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-nonexistent",
companyId: "company-001",
sessionUser: "admin-user",
})
).rejects.toThrow("El colaborador no existe");
});
// --- Already Removed ---
it("throws when employee is already removed", async () => {
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
mockFindOne.mockResolvedValueOnce(buildEmployeeDoc({ removed: true }));
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "company-001",
sessionUser: "admin-user",
})
).rejects.toThrow("El colaborador ya fue eliminado");
});
// --- Permission Check ---
it("throws when non-admin attempts removal", async () => {
const regularEmployee = buildEmployeeDoc({
_id: "emp-regular",
userId: "regular-user",
roles: [EmployeeRoles.EMPLOYEE],
});
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "regular-user" });
mockFindOne
.mockResolvedValueOnce(buildEmployeeDoc({ userId: "target-user" })) // target
.mockResolvedValueOnce(regularEmployee); // session user's employee
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "company-001",
sessionUser: "regular-user",
})
).rejects.toThrow(/No tienes permisos/);
});
// --- Self-Removal Prevention ---
it("throws when admin tries to remove themselves", async () => {
const adminEmployee = buildEmployeeDoc({
_id: "emp-admin",
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
});
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
// First findOne: target employee (the admin themselves)
// Second findOne: session user's employee record
mockFindOne
.mockResolvedValueOnce(adminEmployee)
.mockResolvedValueOnce(adminEmployee);
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-admin",
companyId: "company-001",
sessionUser: "admin-user",
})
).rejects.toThrow("No puedes eliminarte a ti mismo");
});
// --- Owner Protection ---
it("throws when admin tries to remove the owner", async () => {
const ownerEmployee = buildEmployeeDoc({
_id: "emp-owner",
userId: "owner-user",
roles: [EmployeeRoles.OWNER],
});
const adminEmployee = buildEmployeeDoc({
_id: "emp-admin",
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
});
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
mockFindOne
.mockResolvedValueOnce(ownerEmployee)
.mockResolvedValueOnce(adminEmployee);
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-owner",
companyId: "company-001",
sessionUser: "admin-user",
})
).rejects.toThrow("No se puede eliminar al propietario");
});
// --- Happy Path: No Future Appointments ---
it("successfully removes employee with no future appointments", async () => {
const targetEmployee = buildEmployeeDoc({
userId: "target-user",
removed: false,
});
const adminEmployee = buildEmployeeDoc({
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
});
const targetUser = {
_id: "target-user",
firstName: "John",
lastName: "Doe",
email: "john@example.com",
avatar: "avatar-url",
};
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
// First call: session user validation
// Second call: target user lookup (for snapshot)
(UsersManager.users.findOne as jest.Mock)
.mockResolvedValueOnce({ _id: "admin-user" })
.mockResolvedValueOnce(targetUser);
mockFindOne
.mockResolvedValueOnce(targetEmployee) // target
.mockResolvedValueOnce(adminEmployee); // session user
mockAppointmentFind.mockResolvedValue([]);
await EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "company-001",
sessionUser: "admin-user",
});
expect(targetEmployee.save).toHaveBeenCalled();
expect(targetEmployee.removed).toBe(true);
expect(targetEmployee.removedBy).toBe("admin-user");
expect(targetEmployee.removedAt).toBeDefined();
expect(targetEmployee.profileSnapshot).toEqual({
firstName: "John",
lastName: "Doe",
email: "john@example.com",
avatar: "avatar-url",
});
expect(EmployeesServicesList.deleteEmployeeServiceByEmployee).toHaveBeenCalledWith({
employeeId: "emp-001",
});
expect(
HeatMapList.deleteHeatMapByEmployee
).toHaveBeenCalledWith({
employeeId: "emp-001",
});
expect(MetricsList.addEmployee).toHaveBeenCalledWith({
userId: "owner-user",
quantity: -1,
});
});
// --- Owner Can Remove (via company.ownerId check) ---
it("owner can remove a collaborator", async () => {
const targetEmployee = buildEmployeeDoc({
userId: "target-user",
});
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
(UsersManager.users.findOne as jest.Mock)
.mockResolvedValueOnce({ _id: "owner-user" }) // session user
.mockResolvedValueOnce({
_id: "target-user",
firstName: "Jane",
lastName: "Smith",
email: "jane@example.com",
avatar: "",
});
mockFindOne
.mockResolvedValueOnce(targetEmployee)
.mockResolvedValueOnce(null); // owner has no employee record
mockAppointmentFind.mockResolvedValue([]);
await EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "company-001",
sessionUser: "owner-user",
});
expect(targetEmployee.removed).toBe(true);
expect(targetEmployee.removedBy).toBe("owner-user");
expect(EmployeesServicesList.deleteEmployeeServiceByEmployee).toHaveBeenCalledWith({
employeeId: "emp-001",
});
expect(HeatMapList.deleteHeatMapByEmployee).toHaveBeenCalledWith({
employeeId: "emp-001",
});
});
// --- Admin with future appointments but no replacement → error ---
it("throws when employee has future appointments but no replacement provided", async () => {
const targetEmployee = buildEmployeeDoc({
userId: "target-user",
});
const adminEmployee = buildEmployeeDoc({
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
});
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
mockFindOne
.mockResolvedValueOnce(targetEmployee)
.mockResolvedValueOnce(adminEmployee);
// Simulate future appointments exist
mockAppointmentFind.mockResolvedValue([{ _id: "appt-1" }]);
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "company-001",
sessionUser: "admin-user",
})
).rejects.toThrow("turnos futuros asignados");
});
// --- Replacement from different company → error ---
it("throws when replacement employee belongs to a different company", async () => {
const targetEmployee = buildEmployeeDoc({
userId: "target-user",
});
const adminEmployee = buildEmployeeDoc({
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
});
const replacementFromOtherCompany = buildEmployeeDoc({
_id: "emp-replacement",
companyId: "other-company",
userId: "replacement-user",
removed: false,
});
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
mockFindOne
.mockResolvedValueOnce(targetEmployee)
.mockResolvedValueOnce(adminEmployee)
.mockResolvedValueOnce(replacementFromOtherCompany);
mockAppointmentFind.mockResolvedValue([{ _id: "appt-1" }]);
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "company-001",
replacementEmployeeId: "emp-replacement",
sessionUser: "admin-user",
})
).rejects.toThrow("no pertenece a esta organización");
});
// --- Replacement is self → error ---
it("throws when admin tries to designate themselves as replacement", async () => {
const targetEmployee = buildEmployeeDoc({
userId: "target-user",
});
const adminEmployee = buildEmployeeDoc({
_id: "emp-admin",
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
});
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
mockFindOne
.mockResolvedValueOnce(targetEmployee)
.mockResolvedValueOnce(adminEmployee)
.mockResolvedValueOnce(adminEmployee); // replacement is the admin themselves
mockAppointmentFind.mockResolvedValue([{ _id: "appt-1" }]);
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "company-001",
replacementEmployeeId: "emp-admin",
sessionUser: "admin-user",
})
).rejects.toThrow("designarte como reemplazo");
});
// --- Replacement does not exist → error ---
it("throws when replacement employee does not exist", async () => {
const targetEmployee = buildEmployeeDoc({
userId: "target-user",
});
const adminEmployee = buildEmployeeDoc({
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
});
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
(UsersManager.users.findOne as jest.Mock).mockResolvedValue({ _id: "admin-user" });
mockFindOne
.mockResolvedValueOnce(targetEmployee)
.mockResolvedValueOnce(adminEmployee)
.mockResolvedValueOnce(null); // replacement not found
mockAppointmentFind.mockResolvedValue([{ _id: "appt-1" }]);
await expect(
EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "company-001",
replacementEmployeeId: "emp-nonexistent",
sessionUser: "admin-user",
})
).rejects.toThrow("empleado de reemplazo no existe");
});
// --- Removal with replacement proceeds when future appointments exist ---
it("successfully removes employee with replacement when future appointments exist", async () => {
const targetEmployee = buildEmployeeDoc({
userId: "target-user",
});
const adminEmployee = buildEmployeeDoc({
userId: "admin-user",
roles: [EmployeeRoles.ADMIN],
});
const replacementEmployee = buildEmployeeDoc({
_id: "emp-replacement",
companyId: "company-001",
userId: "replacement-user",
removed: false,
});
const targetUser = {
_id: "target-user",
firstName: "John",
lastName: "Doe",
email: "john@example.com",
avatar: "avatar-url",
};
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue({
_id: "company-001",
ownerId: "owner-user",
});
(UsersManager.users.findOne as jest.Mock)
.mockResolvedValueOnce({ _id: "admin-user" })
.mockResolvedValueOnce(targetUser);
mockFindOne
.mockResolvedValueOnce(targetEmployee)
.mockResolvedValueOnce(adminEmployee)
.mockResolvedValueOnce(replacementEmployee);
// Future appointments exist
const mockAppointment = { employeeId: "emp-001", save: jest.fn().mockResolvedValue(true) };
mockAppointmentFind.mockResolvedValue([mockAppointment]);
await EmployeesList.removeCollaboratorFromCompany({
employeeId: "emp-001",
companyId: "company-001",
replacementEmployeeId: "emp-replacement",
sessionUser: "admin-user",
});
expect(targetEmployee.removed).toBe(true);
expect(mockAppointment.employeeId).toBe("emp-replacement");
expect(mockAppointment.save).toHaveBeenCalled();
expect(EmployeesServicesList.deleteEmployeeServiceByEmployee).toHaveBeenCalledWith({
employeeId: "emp-001",
});
expect(HeatMapList.deleteHeatMapByEmployee).toHaveBeenCalledWith({
employeeId: "emp-001",
});
});
});