feat: implement collaborator reactivation logic, add access denied handling, and improve metric filtering
This commit is contained in:
+318
@@ -0,0 +1,318 @@
|
||||
// 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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
Reference in New Issue
Block a user