Skip to content

Commit 5636ce7

Browse files
Merge pull request #2073 from OneCommunityGlobal/Aditya-feat/Add-Side-by-Side-Comparison-Mode-for-Actual-vs-Planned-Expenditure-Charts
Rithika taking over for Aditya-feat: Side-by-Side Comparison Mode for Actual vs. Planned Expenditure Charts
2 parents cf68852 + 73f67de commit 5636ce7

5 files changed

Lines changed: 509 additions & 9 deletions

File tree

src/controllers/bmdashboard/__tests__/bmEquipmentController.test.js

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ describe('bmEquipmentController', () => {
1515
findOneAndUpdate: jest.fn(),
1616
create: jest.fn(),
1717
findByIdAndUpdate: jest.fn(),
18+
updateOne: jest.fn(),
1819
};
1920

2021
// Initialize controller with mock model
@@ -130,4 +131,148 @@ describe('bmEquipmentController', () => {
130131
expect(mockRes.status).toHaveBeenCalledWith(201);
131132
});
132133
});
134+
135+
describe('updateEquipmentById', () => {
136+
const validObjectId = '507f1f77bcf86cd799439011';
137+
138+
it('should return 400 for invalid equipment ID', async () => {
139+
mockReq.params.equipmentId = 'invalid-id';
140+
mockReq.body = { purchaseStatus: 'Purchased' };
141+
142+
await controller.updateEquipmentById(mockReq, mockRes);
143+
144+
expect(mockRes.status).toHaveBeenCalledWith(400);
145+
expect(mockRes.send).toHaveBeenCalledWith({ message: 'Invalid equipment ID.' });
146+
expect(mockBuildingEquipment.updateOne).not.toHaveBeenCalled();
147+
});
148+
149+
it('should return 400 for invalid project ID when provided', async () => {
150+
mockReq.params.equipmentId = validObjectId;
151+
mockReq.body = { projectId: 'not-valid', purchaseStatus: 'Purchased' };
152+
153+
await controller.updateEquipmentById(mockReq, mockRes);
154+
155+
expect(mockRes.status).toHaveBeenCalledWith(400);
156+
expect(mockRes.send).toHaveBeenCalledWith({ message: 'Invalid project ID.' });
157+
expect(mockBuildingEquipment.updateOne).not.toHaveBeenCalled();
158+
});
159+
160+
it('should return 400 for invalid purchaseStatus enum', async () => {
161+
mockReq.params.equipmentId = validObjectId;
162+
mockReq.body = { purchaseStatus: 'InvalidStatus' };
163+
164+
await controller.updateEquipmentById(mockReq, mockRes);
165+
166+
expect(mockRes.status).toHaveBeenCalledWith(400);
167+
expect(mockRes.send).toHaveBeenCalledWith(
168+
expect.objectContaining({
169+
message: expect.stringContaining('Invalid purchaseStatus'),
170+
}),
171+
);
172+
expect(mockBuildingEquipment.updateOne).not.toHaveBeenCalled();
173+
});
174+
175+
it('should return 400 for invalid currentUsage enum', async () => {
176+
mockReq.params.equipmentId = validObjectId;
177+
mockReq.body = { currentUsage: 'Broken' };
178+
179+
await controller.updateEquipmentById(mockReq, mockRes);
180+
181+
expect(mockRes.status).toHaveBeenCalledWith(400);
182+
expect(mockRes.send).toHaveBeenCalledWith(
183+
expect.objectContaining({
184+
message: expect.stringContaining('Invalid currentUsage'),
185+
}),
186+
);
187+
expect(mockBuildingEquipment.updateOne).not.toHaveBeenCalled();
188+
});
189+
190+
it('should return 400 for invalid condition enum', async () => {
191+
mockReq.params.equipmentId = validObjectId;
192+
mockReq.body = { condition: 'Unknown' };
193+
194+
await controller.updateEquipmentById(mockReq, mockRes);
195+
196+
expect(mockRes.status).toHaveBeenCalledWith(400);
197+
expect(mockRes.send).toHaveBeenCalledWith(
198+
expect.objectContaining({
199+
message: expect.stringContaining('Invalid condition'),
200+
}),
201+
);
202+
expect(mockBuildingEquipment.updateOne).not.toHaveBeenCalled();
203+
});
204+
205+
it('should return 400 when no valid fields provided to update', async () => {
206+
mockReq.params.equipmentId = validObjectId;
207+
mockReq.body = { unknownField: 'value' };
208+
209+
await controller.updateEquipmentById(mockReq, mockRes);
210+
211+
expect(mockRes.status).toHaveBeenCalledWith(400);
212+
expect(mockRes.send).toHaveBeenCalledWith({
213+
message: 'No valid fields provided to update.',
214+
});
215+
expect(mockBuildingEquipment.updateOne).not.toHaveBeenCalled();
216+
});
217+
218+
it('should return 200 with updated equipment on success', async () => {
219+
const updatedEquipment = {
220+
_id: validObjectId,
221+
purchaseStatus: 'Purchased',
222+
condition: 'Good',
223+
};
224+
mockReq.params.equipmentId = validObjectId;
225+
mockReq.body = { purchaseStatus: 'Purchased', condition: 'Good' };
226+
227+
mockBuildingEquipment.updateOne.mockResolvedValue({});
228+
const mockPopulate = jest.fn().mockReturnThis();
229+
const mockExec = jest.fn().mockResolvedValue(updatedEquipment);
230+
mockBuildingEquipment.findById.mockReturnValue({
231+
populate: mockPopulate,
232+
exec: mockExec,
233+
});
234+
235+
await controller.updateEquipmentById(mockReq, mockRes);
236+
237+
expect(mockBuildingEquipment.updateOne).toHaveBeenCalledWith(
238+
{ _id: validObjectId },
239+
{ $set: expect.objectContaining({ purchaseStatus: 'Purchased', condition: 'Good' }) },
240+
);
241+
expect(mockBuildingEquipment.findById).toHaveBeenCalledWith(validObjectId);
242+
expect(mockRes.status).toHaveBeenCalledWith(200);
243+
expect(mockRes.send).toHaveBeenCalledWith(updatedEquipment);
244+
});
245+
246+
it('should return 404 when equipment not found after update', async () => {
247+
mockReq.params.equipmentId = validObjectId;
248+
mockReq.body = { condition: 'Good' };
249+
250+
mockBuildingEquipment.updateOne.mockResolvedValue({});
251+
const mockPopulate = jest.fn().mockReturnThis();
252+
const mockExec = jest.fn().mockResolvedValue(null);
253+
mockBuildingEquipment.findById.mockReturnValue({
254+
populate: mockPopulate,
255+
exec: mockExec,
256+
});
257+
258+
await controller.updateEquipmentById(mockReq, mockRes);
259+
260+
expect(mockRes.status).toHaveBeenCalledWith(404);
261+
expect(mockRes.send).toHaveBeenCalledWith({ message: 'Equipment not found.' });
262+
});
263+
264+
it('should return 500 on updateOne error', async () => {
265+
mockReq.params.equipmentId = validObjectId;
266+
mockReq.body = { condition: 'Good' };
267+
268+
mockBuildingEquipment.updateOne.mockRejectedValue(new Error('DB error'));
269+
270+
await controller.updateEquipmentById(mockReq, mockRes);
271+
272+
expect(mockRes.status).toHaveBeenCalledWith(500);
273+
expect(mockRes.send).toHaveBeenCalledWith(
274+
expect.objectContaining({ message: expect.any(String) }),
275+
);
276+
});
277+
});
133278
});
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
jest.mock('../../../models/bmdashboard/expenditure', () => ({
2+
distinct: jest.fn(),
3+
aggregate: jest.fn(),
4+
}));
5+
6+
jest.mock('../../../startup/logger', () => ({
7+
logException: jest.fn(),
8+
logInfo: jest.fn(),
9+
}));
10+
11+
const Expenditure = require('../../../models/bmdashboard/expenditure');
12+
const logger = require('../../../startup/logger');
13+
const { getProjectExpensesPie, getProjectIdsWithExpenditure } = require('../expenditureController');
14+
15+
const VALID_ID = '507f1f77bcf86cd799439011';
16+
const INVALID_ID = 'not-a-valid-id';
17+
18+
const makeRes = () => {
19+
const res = {};
20+
res.status = jest.fn().mockReturnThis();
21+
res.json = jest.fn().mockReturnThis();
22+
return res;
23+
};
24+
25+
describe('expenditureController', () => {
26+
beforeEach(() => {
27+
jest.clearAllMocks();
28+
});
29+
30+
// ---------------------------------------------------------------------------
31+
describe('getProjectIdsWithExpenditure', () => {
32+
it('returns 200 with the array of project IDs on success', async () => {
33+
const ids = [VALID_ID, '507f1f77bcf86cd799439012'];
34+
Expenditure.distinct.mockResolvedValue(ids);
35+
36+
const res = makeRes();
37+
await getProjectIdsWithExpenditure({}, res);
38+
39+
expect(Expenditure.distinct).toHaveBeenCalledWith('projectId');
40+
expect(res.status).toHaveBeenCalledWith(200);
41+
expect(res.json).toHaveBeenCalledWith(ids);
42+
});
43+
44+
it('returns 200 with an empty array when no expenditure records exist', async () => {
45+
Expenditure.distinct.mockResolvedValue([]);
46+
47+
const res = makeRes();
48+
await getProjectIdsWithExpenditure({}, res);
49+
50+
expect(res.status).toHaveBeenCalledWith(200);
51+
expect(res.json).toHaveBeenCalledWith([]);
52+
});
53+
54+
it('returns 500 and calls logger on DB error', async () => {
55+
const error = new Error('DB failure');
56+
Expenditure.distinct.mockRejectedValue(error);
57+
58+
const res = makeRes();
59+
await getProjectIdsWithExpenditure({}, res);
60+
61+
expect(res.status).toHaveBeenCalledWith(500);
62+
expect(res.json).toHaveBeenCalledWith({ message: 'Failed to retrieve project IDs' });
63+
expect(logger.logException).toHaveBeenCalledWith(
64+
error,
65+
'expenditureController.getProjectIdsWithExpenditure',
66+
);
67+
});
68+
});
69+
70+
// ---------------------------------------------------------------------------
71+
describe('getProjectExpensesPie', () => {
72+
it('returns 400 for an invalid projectId without calling aggregate', async () => {
73+
const req = { params: { projectId: INVALID_ID } };
74+
const res = makeRes();
75+
76+
await getProjectExpensesPie(req, res);
77+
78+
expect(res.status).toHaveBeenCalledWith(400);
79+
expect(res.json).toHaveBeenCalledWith({ message: 'Invalid project ID' });
80+
expect(Expenditure.aggregate).not.toHaveBeenCalled();
81+
});
82+
83+
it('returns 400 for a short non-hex string without calling aggregate', async () => {
84+
const req = { params: { projectId: '12345' } };
85+
const res = makeRes();
86+
87+
await getProjectExpensesPie(req, res);
88+
89+
expect(res.status).toHaveBeenCalledWith(400);
90+
expect(Expenditure.aggregate).not.toHaveBeenCalled();
91+
});
92+
93+
it('returns 200 with { actual, planned } shape on success', async () => {
94+
Expenditure.aggregate.mockResolvedValue([
95+
{ _id: { type: 'actual', category: 'Labor' }, amount: 1000 },
96+
{ _id: { type: 'actual', category: 'Materials' }, amount: 500 },
97+
{ _id: { type: 'planned', category: 'Equipment' }, amount: 2000 },
98+
]);
99+
100+
const req = { params: { projectId: VALID_ID } };
101+
const res = makeRes();
102+
103+
await getProjectExpensesPie(req, res);
104+
105+
expect(Expenditure.aggregate).toHaveBeenCalledTimes(1);
106+
expect(res.json).toHaveBeenCalledWith({
107+
actual: [
108+
{ category: 'Labor', amount: 1000 },
109+
{ category: 'Materials', amount: 500 },
110+
],
111+
planned: [{ category: 'Equipment', amount: 2000 }],
112+
});
113+
// status should NOT be explicitly set to 200 (res.json implies 200)
114+
expect(res.status).not.toHaveBeenCalled();
115+
});
116+
117+
it('returns { actual: [], planned: [] } when no matching records exist', async () => {
118+
Expenditure.aggregate.mockResolvedValue([]);
119+
120+
const req = { params: { projectId: VALID_ID } };
121+
const res = makeRes();
122+
123+
await getProjectExpensesPie(req, res);
124+
125+
expect(res.json).toHaveBeenCalledWith({ actual: [], planned: [] });
126+
});
127+
128+
it('returns 500 and calls logger with projectId on DB error', async () => {
129+
const error = new Error('Aggregate failed');
130+
Expenditure.aggregate.mockRejectedValue(error);
131+
132+
const req = { params: { projectId: VALID_ID } };
133+
const res = makeRes();
134+
135+
await getProjectExpensesPie(req, res);
136+
137+
expect(res.status).toHaveBeenCalledWith(500);
138+
expect(res.json).toHaveBeenCalledWith({
139+
message: 'Server error retrieving expenses pie data.',
140+
});
141+
expect(logger.logException).toHaveBeenCalledWith(
142+
error,
143+
'expenditureController.getProjectExpensesPie',
144+
{ projectId: VALID_ID },
145+
);
146+
});
147+
});
148+
});
Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
const mongoose = require('mongoose');
22
const Expenditure = require('../../models/bmdashboard/expenditure');
3+
const logger = require('../../startup/logger');
34

45
exports.getProjectExpensesPie = async (req, res) => {
56
try {
67
const { projectId } = req.params;
78

9+
if (!mongoose.Types.ObjectId.isValid(projectId)) {
10+
return res.status(400).json({ message: 'Invalid project ID' });
11+
}
12+
813
const data = await Expenditure.aggregate([
9-
{ $match: { projectId: mongoose.Types.ObjectId(projectId) } },
14+
{ $match: { projectId: new mongoose.Types.ObjectId(projectId) } },
1015
{
1116
$group: {
1217
_id: { type: '$type', category: '$category' },
@@ -22,18 +27,21 @@ exports.getProjectExpensesPie = async (req, res) => {
2227
result[type].push({ category, amount: item.amount });
2328
});
2429

25-
res.json(result);
30+
return res.json(result);
2631
} catch (error) {
27-
console.error(error);
28-
res.status(500).json({ message: 'Server error retrieving expenses pie data.' });
32+
logger.logException(error, 'expenditureController.getProjectExpensesPie', {
33+
projectId: req.params.projectId,
34+
});
35+
return res.status(500).json({ message: 'Server error retrieving expenses pie data.' });
2936
}
3037
};
31-
exports.getProjectIdsWithExpenditure = async (req, res) => {
38+
39+
exports.getProjectIdsWithExpenditure = async (_req, res) => {
3240
try {
3341
const projectIds = await Expenditure.distinct('projectId');
34-
res.status(200).json(projectIds);
42+
return res.status(200).json(projectIds);
3543
} catch (error) {
36-
console.error('Error fetching project IDs:', error);
37-
res.status(500).json({ message: 'Failed to retrieve project IDs' });
44+
logger.logException(error, 'expenditureController.getProjectIdsWithExpenditure');
45+
return res.status(500).json({ message: 'Failed to retrieve project IDs' });
3846
}
3947
};

0 commit comments

Comments
 (0)