Files
turnosxpress/server/src/Models/EmployeesServices/__tests__/EmployeesServices.findByService.test.ts
T

319 lines
11 KiB
TypeScript

// Mock dependencies
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("../../Services/Service", () => ({
__esModule: true,
default: {
services: {
find: jest.fn(),
findOne: jest.fn(),
},
},
}));
jest.mock("../../Notifications/Notifications", () => ({
__esModule: true,
NotificationsManager: {
sendSystemNotification: jest.fn(),
},
}));
jest.mock("../../Employees/Employee", () => {
const mockFindOne = jest.fn();
const mockFind = jest.fn();
const mockFindById = jest.fn();
return {
__esModule: true,
default: {
employees: {
findOne: mockFindOne,
find: mockFind,
employeeList: {
findOne: mockFindOne,
find: mockFind,
},
},
findById: mockFindById,
checkPermission: jest.fn().mockResolvedValue(true),
_mocks: { findOne: mockFindOne, find: mockFind, findById: mockFindById },
},
};
});
import EmployeesServicesList from "../EmployeesServices";
import EmployeesList from "../../Employees/Employee";
describe("EmployeesServices.findByService — removed employee exclusion", () => {
let mockEmployeesFindOne: jest.Mock;
let mockEmployeesFindById: jest.Mock;
let originalEmployeesServicesAdapter: any;
beforeEach(() => {
jest.clearAllMocks();
mockEmployeesFindOne = (EmployeesList as any)._mocks.findOne;
mockEmployeesFindById = (EmployeesList as any)._mocks.findById;
// Save and replace the employeesServices adapter
originalEmployeesServicesAdapter = EmployeesServicesList.employeesServices;
const mockESFindOne = jest.fn();
const mockESFind = jest.fn();
(EmployeesServicesList as any).employeesServices = {
findOne: mockESFindOne,
find: mockESFind,
create: jest.fn(),
delete: jest.fn(),
update: jest.fn(),
paginate: jest.fn(),
employeesServicesList: {
findOne: mockESFindOne,
find: mockESFind,
deleteMany: jest.fn(),
},
};
});
afterEach(() => {
(EmployeesServicesList as any).employeesServices = originalEmployeesServicesAdapter;
});
it("excludes removed employees from findByService results", async () => {
// Service assignments include one for an active employee and one for a removed employee
const activeServiceAssignment = {
_id: "es-001",
employeeId: "emp-active",
serviceId: "svc-001",
companyId: "company-001",
};
const removedServiceAssignment = {
_id: "es-002",
employeeId: "emp-removed",
serviceId: "svc-001",
companyId: "company-001",
};
const activeEmployeeDoc = {
_id: "emp-active",
id: "emp-active",
companyId: "company-001",
userId: "user-active",
removed: false,
};
const removedEmployeeDoc = {
_id: "emp-removed",
id: "emp-removed",
companyId: "company-001",
userId: "user-removed",
removed: true,
};
// employeesServices.find returns both assignments
const mockESFind = (EmployeesServicesList as any).employeesServices.find;
mockESFind.mockResolvedValue([activeServiceAssignment, removedServiceAssignment]);
// EmployeesList.employees.findOne returns the employee docs
mockEmployeesFindOne
.mockResolvedValueOnce(activeEmployeeDoc)
.mockResolvedValueOnce(removedEmployeeDoc);
// EmployeesList.findById for the active employee
mockEmployeesFindById.mockResolvedValue({
id: "emp-active",
fullName: "Active User",
avatar: "",
});
const result = await EmployeesServicesList.findByService({
serviceId: "svc-001",
});
// Only the active employee should appear in results
expect(result).toHaveLength(1);
expect(result[0].id).toBe("emp-active");
expect(result[0].name).toBe("Active User");
});
it("returns empty when all assigned employees are removed", async () => {
const removedServiceAssignment = {
_id: "es-001",
employeeId: "emp-removed",
serviceId: "svc-001",
companyId: "company-001",
};
const removedEmployeeDoc = {
_id: "emp-removed",
id: "emp-removed",
companyId: "company-001",
userId: "user-removed",
removed: true,
};
const mockESFind = (EmployeesServicesList as any).employeesServices.find;
mockESFind.mockResolvedValue([removedServiceAssignment]);
mockEmployeesFindOne.mockResolvedValueOnce(removedEmployeeDoc);
const result = await EmployeesServicesList.findByService({
serviceId: "svc-001",
});
expect(result).toEqual([]);
expect(result).toHaveLength(0);
// Prove the guard stopped processing: employee was found but removed,
// so findById should NEVER have been called for enrichment.
expect((EmployeesList as any)._mocks.findById).not.toHaveBeenCalled();
});
it("excludes employees whose Employee document is null (hard-deleted User)", async () => {
// Scenario: service assignment exists but the Employee record was hard-deleted
// (e.g. cascade cleanup). findByService must skip gracefully.
const activeServiceAssignment = {
_id: "es-001",
employeeId: "emp-active",
serviceId: "svc-001",
companyId: "company-001",
};
const orphanServiceAssignment = {
_id: "es-002",
employeeId: "emp-orphan",
serviceId: "svc-001",
companyId: "company-001",
};
const activeEmployeeDoc = {
_id: "emp-active",
id: "emp-active",
companyId: "company-001",
userId: "user-active",
removed: false,
};
const mockESFind = (EmployeesServicesList as any).employeesServices.find;
mockESFind.mockResolvedValue([activeServiceAssignment, orphanServiceAssignment]);
// First findOne: active employee → found; second: orphan → null (deleted)
mockEmployeesFindOne
.mockResolvedValueOnce(activeEmployeeDoc)
.mockResolvedValueOnce(null);
mockEmployeesFindById.mockResolvedValue({
id: "emp-active",
fullName: "Active User",
avatar: "",
});
const result = await EmployeesServicesList.findByService({
serviceId: "svc-001",
});
// Only the active employee should appear
expect(result).toHaveLength(1);
expect(result[0].id).toBe("emp-active");
// findById was called once for the active employee only
expect(mockEmployeesFindById).toHaveBeenCalledTimes(1);
expect(mockEmployeesFindById).toHaveBeenCalledWith({ id: "emp-active" });
});
it("does not call findById for removed employees, only for active ones", async () => {
// Proves the continue guard prevents enrichment calls for removed employees.
const activeAssignment = {
_id: "es-001",
employeeId: "emp-active",
serviceId: "svc-001",
companyId: "company-001",
};
const removedAssignment = {
_id: "es-002",
employeeId: "emp-removed",
serviceId: "svc-001",
companyId: "company-001",
};
const activeDoc = {
_id: "emp-active",
id: "emp-active",
removed: false,
};
const removedDoc = {
_id: "emp-removed",
id: "emp-removed",
removed: true,
};
const mockESFind = (EmployeesServicesList as any).employeesServices.find;
mockESFind.mockResolvedValue([activeAssignment, removedAssignment]);
mockEmployeesFindOne
.mockResolvedValueOnce(activeDoc)
.mockResolvedValueOnce(removedDoc);
mockEmployeesFindById.mockResolvedValue({
id: "emp-active",
fullName: "Active User",
avatar: "",
});
const result = await EmployeesServicesList.findByService({
serviceId: "svc-001",
});
// findById called ONCE (active only), never for the removed employee
expect(mockEmployeesFindById).toHaveBeenCalledTimes(1);
expect(mockEmployeesFindById).toHaveBeenCalledWith({ id: "emp-active" });
expect(result).toHaveLength(1);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Public Booking Path — Limitation Documentation
// ─────────────────────────────────────────────────────────────────────────────
//
// The actual public booking flow for collaborator resolution is:
//
// Frontend (Appointment.tsx)
// → POST /employees/employees-by-service
// → FindEmployeesByServiceController.findEmployeesByService()
// → new EmployeesService().findByService(requestBody) [thin wrapper: connect() + delegate]
// → EmployeesServicesList.findByService(data) [model — tested above]
//
// The model layer (EmployeesServicesManager.findByService) is the GATEKEEPER
// that applies the `removed` guard. The controller and service classes are thin
// pass-through wrappers with no additional filtering logic.
//
// Full-path integration testing (controller → service → model → DB) is not
// practically unit-testable because:
// 1. The service layer calls mongoose.connect() which requires a real DB
// 2. The controller instantiates the service class internally
// 3. 5+ collections would need seeding (Employees, EmployeesServices, Users, Companies)
//
// The model-level tests above provide the STRONGEST feasible coverage because:
// - They exercise the exact `removed` guard logic (line 444 of EmployeesServices.ts)
// - They prove findById enrichment is skipped for removed/null employees
// - They prove the correct filter is applied to service assignment queries
// - The controller/service layers add no filtering — they are pure delegation
// ─────────────────────────────────────────────────────────────────────────────