Skip to content

Commit 541a564

Browse files
Merge pull request #2197 from OneCommunityGlobal/feature/siri-phase4-hours-logging
Siri - Feature: phase4 hours logging
2 parents 16cd2f6 + f2700f8 commit 541a564

3 files changed

Lines changed: 236 additions & 0 deletions

File tree

src/controllers/studentTaskController.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,10 +366,78 @@ const studentTaskController = function () {
366366
}
367367
};
368368

369+
/**
370+
* POST /student/tasks/:taskId/log-hours
371+
* Increments loggedHours for a task, capped at suggestedTotalHours.
372+
* Returns updated loggedHours and a canMarkDone eligibility flag.
373+
*/
374+
const logHours = async (req, res) => {
375+
try {
376+
const { taskId } = req.params;
377+
const studentId = req.body.requestor?.requestorId;
378+
379+
if (!taskId || !mongoose.Types.ObjectId.isValid(taskId)) {
380+
return res.status(400).json({ error: 'Invalid Task ID' });
381+
}
382+
if (!studentId || !mongoose.Types.ObjectId.isValid(studentId)) {
383+
return res.status(400).json({ error: 'Invalid Student ID' });
384+
}
385+
386+
const hours = Number(req.body.hours);
387+
if (!Number.isFinite(hours) || hours <= 0) {
388+
return res.status(400).json({ error: 'hours must be a positive number' });
389+
}
390+
391+
const task = await EducationTask.findOne({
392+
_id: mongoose.Types.ObjectId(taskId),
393+
studentId: mongoose.Types.ObjectId(studentId),
394+
});
395+
396+
if (!task) {
397+
return res.status(404).json({ error: 'Task not found or does not belong to you' });
398+
}
399+
400+
if (task.status === 'completed' || task.status === 'graded') {
401+
return res.status(400).json({ error: 'Cannot log hours for a completed task' });
402+
}
403+
404+
const cap = task.suggestedTotalHours > 0 ? task.suggestedTotalHours : Infinity;
405+
const newLoggedHours = Math.min(task.loggedHours + hours, cap);
406+
407+
const newStatus =
408+
task.status === 'assigned' && newLoggedHours > 0 ? 'in_progress' : task.status;
409+
410+
const updated = await EducationTask.findOneAndUpdate(
411+
{ _id: mongoose.Types.ObjectId(taskId) },
412+
{ $set: { loggedHours: newLoggedHours, status: newStatus } },
413+
{ new: true, runValidators: false },
414+
);
415+
416+
if (!updated) {
417+
return res.status(404).json({ error: 'Task not found during update' });
418+
}
419+
420+
const canMarkDone =
421+
updated.suggestedTotalHours > 0 && updated.loggedHours >= updated.suggestedTotalHours;
422+
423+
return res.status(200).json({
424+
message: 'Hours logged successfully',
425+
loggedHours: updated.loggedHours,
426+
suggestedTotalHours: updated.suggestedTotalHours,
427+
status: updated.status,
428+
canMarkDone,
429+
});
430+
} catch (error) {
431+
console.error('Error logging hours:', error);
432+
return res.status(500).json({ error: 'Internal server error' });
433+
}
434+
};
435+
369436
return {
370437
getStudentTasks,
371438
updateTaskProgress,
372439
uploadFile,
440+
logHours,
373441
};
374442
};
375443

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
const EducationTask = require('../models/educationTask');
2+
const { mockReq, mockRes } = require('../test');
3+
const studentTaskController = require('./studentTaskController');
4+
5+
const VALID_TASK_ID = '507f1f77bcf86cd799439011';
6+
const VALID_STUDENT_ID = '65cf6c3706d8ac105827bb2e';
7+
8+
// Shared helpers to reduce repetition
9+
const makeTask = (overrides = {}) => ({
10+
status: 'assigned',
11+
loggedHours: 0,
12+
suggestedTotalHours: 5,
13+
...overrides,
14+
});
15+
16+
const makeUpdated = (overrides = {}) => ({
17+
loggedHours: 1,
18+
suggestedTotalHours: 5,
19+
status: 'in_progress',
20+
...overrides,
21+
});
22+
23+
const spyFindOne = (result) => jest.spyOn(EducationTask, 'findOne').mockResolvedValueOnce(result);
24+
const spyFindOneAndUpdate = (result) =>
25+
jest.spyOn(EducationTask, 'findOneAndUpdate').mockResolvedValueOnce(result);
26+
27+
describe('studentTaskController - logHours', () => {
28+
let logHours;
29+
30+
beforeEach(() => {
31+
logHours = studentTaskController().logHours;
32+
mockReq.params.taskId = VALID_TASK_ID;
33+
mockReq.body.requestor = { requestorId: VALID_STUDENT_ID };
34+
mockReq.body.hours = 1;
35+
});
36+
37+
afterEach(() => {
38+
jest.clearAllMocks();
39+
});
40+
41+
describe('Input validation', () => {
42+
test.each([
43+
['taskId is missing', { params: { taskId: '' } }, 400, { error: 'Invalid Task ID' }],
44+
[
45+
'taskId is not a valid ObjectId',
46+
{ params: { taskId: 'bad-id' } },
47+
400,
48+
{ error: 'Invalid Task ID' },
49+
],
50+
[
51+
'studentId is missing',
52+
{ body: { requestor: {}, hours: 1 } },
53+
400,
54+
{ error: 'Invalid Student ID' },
55+
],
56+
[
57+
'studentId is not a valid ObjectId',
58+
{ body: { requestor: { requestorId: 'bad' }, hours: 1 } },
59+
400,
60+
{ error: 'Invalid Student ID' },
61+
],
62+
[
63+
'hours is zero',
64+
{ body: { requestor: { requestorId: VALID_STUDENT_ID }, hours: 0 } },
65+
400,
66+
{ error: 'hours must be a positive number' },
67+
],
68+
[
69+
'hours is negative',
70+
{ body: { requestor: { requestorId: VALID_STUDENT_ID }, hours: -1 } },
71+
400,
72+
{ error: 'hours must be a positive number' },
73+
],
74+
[
75+
'hours is not a number',
76+
{ body: { requestor: { requestorId: VALID_STUDENT_ID }, hours: 'abc' } },
77+
400,
78+
{ error: 'hours must be a positive number' },
79+
],
80+
])('Returns %i when %s', async (_, reqOverrides, expectedStatus, expectedBody) => {
81+
Object.assign(mockReq, reqOverrides);
82+
if (reqOverrides.params) Object.assign(mockReq.params, reqOverrides.params);
83+
await logHours(mockReq, mockRes);
84+
expect(mockRes.status).toHaveBeenCalledWith(expectedStatus);
85+
expect(mockRes.json).toHaveBeenCalledWith(expectedBody);
86+
});
87+
});
88+
89+
describe('Database interactions', () => {
90+
test('Returns 404 if task is not found', async () => {
91+
spyFindOne(null);
92+
await logHours(mockReq, mockRes);
93+
expect(mockRes.status).toHaveBeenCalledWith(404);
94+
expect(mockRes.json).toHaveBeenCalledWith({
95+
error: 'Task not found or does not belong to you',
96+
});
97+
});
98+
99+
test.each([
100+
['completed', makeTask({ status: 'completed', loggedHours: 3 })],
101+
['graded', makeTask({ status: 'graded', loggedHours: 5 })],
102+
])('Returns 400 if task status is %s', async (_, task) => {
103+
spyFindOne(task);
104+
await logHours(mockReq, mockRes);
105+
expect(mockRes.status).toHaveBeenCalledWith(400);
106+
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Cannot log hours for a completed task' });
107+
});
108+
109+
test('Returns 200 and transitions assigned -> in_progress on first log', async () => {
110+
spyFindOne(makeTask());
111+
spyFindOneAndUpdate(makeUpdated());
112+
await logHours(mockReq, mockRes);
113+
expect(mockRes.status).toHaveBeenCalledWith(200);
114+
expect(mockRes.json).toHaveBeenCalledWith({
115+
message: 'Hours logged successfully',
116+
loggedHours: 1,
117+
suggestedTotalHours: 5,
118+
status: 'in_progress',
119+
canMarkDone: false,
120+
});
121+
});
122+
123+
test('Returns 200 and caps loggedHours at suggestedTotalHours', async () => {
124+
mockReq.body.hours = 3;
125+
spyFindOne(makeTask({ status: 'in_progress', loggedHours: 4 }));
126+
spyFindOneAndUpdate(makeUpdated({ loggedHours: 5 }));
127+
await logHours(mockReq, mockRes);
128+
expect(mockRes.status).toHaveBeenCalledWith(200);
129+
expect(mockRes.json).toHaveBeenCalledWith({
130+
message: 'Hours logged successfully',
131+
loggedHours: 5,
132+
suggestedTotalHours: 5,
133+
status: 'in_progress',
134+
canMarkDone: true,
135+
});
136+
expect(EducationTask.findOneAndUpdate).toHaveBeenCalledWith(
137+
expect.any(Object),
138+
{ $set: { loggedHours: 5, status: 'in_progress' } },
139+
{ new: true, runValidators: false },
140+
);
141+
});
142+
143+
test('Returns 404 if findOneAndUpdate returns null', async () => {
144+
spyFindOne(makeTask());
145+
spyFindOneAndUpdate(null);
146+
await logHours(mockReq, mockRes);
147+
expect(mockRes.status).toHaveBeenCalledWith(404);
148+
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Task not found during update' });
149+
});
150+
151+
test('Returns 200 with canMarkDone false when suggestedTotalHours is 0', async () => {
152+
spyFindOne(makeTask({ suggestedTotalHours: 0 }));
153+
spyFindOneAndUpdate(makeUpdated({ suggestedTotalHours: 0 }));
154+
await logHours(mockReq, mockRes);
155+
expect(mockRes.status).toHaveBeenCalledWith(200);
156+
expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ canMarkDone: false }));
157+
});
158+
159+
test('Returns 500 if findOne throws an error', async () => {
160+
spyFindOne(Promise.reject(new Error('DB error')));
161+
await logHours(mockReq, mockRes);
162+
expect(mockRes.status).toHaveBeenCalledWith(500);
163+
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Internal server error' });
164+
});
165+
});
166+
});

src/routes/studentTaskRouter.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ const routes = function () {
77

88
studentTaskRouter.route('/student/tasks').get(controller.getStudentTasks);
99

10+
studentTaskRouter.route('/student/tasks/:taskId/log-hours').post(controller.logHours);
11+
1012
studentTaskRouter.route('/student/tasks/:taskId/progress').put(controller.updateTaskProgress);
1113

1214
studentTaskRouter

0 commit comments

Comments
 (0)