319 lines
11 KiB
TypeScript
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
|
|
// ─────────────────────────────────────────────────────────────────────────────
|