Skip to content

Commit 6438995

Browse files
Merge pull request #2093 from OneCommunityGlobal/Aditya-feat/Add-New-Bar-Chart-Card-to-the-Lessons-Learned-Section-on-the-Total-Construction-Summary-Page
Rithika taking over for Aditya-feat: Add New Bar Chart Card to the Lessons Learned Section on the Total Construction Summary Page
2 parents bef6dba + 05704ff commit 6438995

2 files changed

Lines changed: 360 additions & 76 deletions

File tree

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

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const bmNewLessonController = require('../bmNewLessonController');
2+
const logger = require('../../../startup/logger');
23

34
// Mock dependencies
45
const mockBuildingNewLesson = {
@@ -11,6 +12,7 @@ const mockBuildingNewLesson = {
1112
updateMany: jest.fn(),
1213
deleteMany: jest.fn(),
1314
getAllTags: jest.fn(),
15+
aggregate: jest.fn(),
1416
};
1517

1618
const mockBuildingProject = {
@@ -47,6 +49,7 @@ describe('bmNewLessonController', () => {
4749
mockReq = {
4850
body: {},
4951
params: {},
52+
query: {},
5053
};
5154

5255
mockRes = {
@@ -398,6 +401,296 @@ describe('bmNewLessonController', () => {
398401
});
399402
});
400403

404+
describe('getLessonsLearnt', () => {
405+
const VALID_PROJECT_ID = '507f1f77bcf86cd799439011';
406+
const mockProjectObjId = { toString: () => VALID_PROJECT_ID };
407+
408+
const defaultLessonsInRange = [
409+
{ project: 'Project A', projectId: mockProjectObjId, lessonsCount: 5 },
410+
];
411+
const defaultThisMonth = [{ _id: mockProjectObjId, thisMonthCount: 3 }];
412+
const defaultLastMonth = [{ _id: mockProjectObjId, lastMonthCount: 2 }];
413+
414+
beforeEach(() => {
415+
mockBuildingNewLesson.aggregate
416+
.mockResolvedValueOnce(defaultLessonsInRange)
417+
.mockResolvedValueOnce(defaultThisMonth)
418+
.mockResolvedValueOnce(defaultLastMonth);
419+
});
420+
421+
// --- Validation: Issue 1 (invalid projectId) ---
422+
it('should return 400 for an invalid projectId', async () => {
423+
mockReq.query = { projectId: 'not-a-valid-objectid' };
424+
425+
await controller.getLessonsLearnt(mockReq, mockRes);
426+
427+
expect(mockRes.status).toHaveBeenCalledWith(400);
428+
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Invalid projectId' });
429+
expect(mockBuildingNewLesson.aggregate).not.toHaveBeenCalled();
430+
});
431+
432+
it('should not reject projectId=ALL and proceed normally', async () => {
433+
mockReq.query = { projectId: 'ALL' };
434+
435+
await controller.getLessonsLearnt(mockReq, mockRes);
436+
437+
expect(mockRes.status).toHaveBeenCalledWith(200);
438+
expect(mockBuildingNewLesson.aggregate).toHaveBeenCalledTimes(3);
439+
});
440+
441+
it('should not apply a relatedProject filter when projectId=ALL', async () => {
442+
mockReq.query = { projectId: 'ALL' };
443+
444+
await controller.getLessonsLearnt(mockReq, mockRes);
445+
446+
const firstMatchStage = mockBuildingNewLesson.aggregate.mock.calls[0][0][0].$match;
447+
expect(firstMatchStage.relatedProject).toBeUndefined();
448+
});
449+
450+
// --- Validation: Issue 3 (invalid dates) ---
451+
it('should return 400 for an invalid startDate', async () => {
452+
mockReq.query = { startDate: 'not-a-date' };
453+
454+
await controller.getLessonsLearnt(mockReq, mockRes);
455+
456+
expect(mockRes.status).toHaveBeenCalledWith(400);
457+
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Invalid startDate' });
458+
expect(mockBuildingNewLesson.aggregate).not.toHaveBeenCalled();
459+
});
460+
461+
it('should return 400 for an invalid endDate', async () => {
462+
mockReq.query = { endDate: 'not-a-date' };
463+
464+
await controller.getLessonsLearnt(mockReq, mockRes);
465+
466+
expect(mockRes.status).toHaveBeenCalledWith(400);
467+
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Invalid endDate' });
468+
expect(mockBuildingNewLesson.aggregate).not.toHaveBeenCalled();
469+
});
470+
471+
// --- Happy path: no params ---
472+
it('should return 200 with lessons grouped by project when no params given', async () => {
473+
mockReq.query = {};
474+
475+
await controller.getLessonsLearnt(mockReq, mockRes);
476+
477+
expect(mockBuildingNewLesson.aggregate).toHaveBeenCalledTimes(3);
478+
expect(mockRes.status).toHaveBeenCalledWith(200);
479+
expect(mockRes.json).toHaveBeenCalledWith({
480+
data: [
481+
{
482+
project: 'Project A',
483+
projectId: mockProjectObjId,
484+
lessonsCount: 5,
485+
changePercentage: '+50.0%',
486+
},
487+
],
488+
});
489+
});
490+
491+
it('should return empty data array when no lessons exist', async () => {
492+
mockBuildingNewLesson.aggregate.mockReset();
493+
mockBuildingNewLesson.aggregate
494+
.mockResolvedValueOnce([])
495+
.mockResolvedValueOnce([])
496+
.mockResolvedValueOnce([]);
497+
mockReq.query = {};
498+
499+
await controller.getLessonsLearnt(mockReq, mockRes);
500+
501+
expect(mockRes.status).toHaveBeenCalledWith(200);
502+
expect(mockRes.json).toHaveBeenCalledWith({ data: [] });
503+
});
504+
505+
// --- Filter construction: valid projectId ---
506+
it('should apply relatedProject filter when a valid projectId is given', async () => {
507+
mockReq.query = { projectId: VALID_PROJECT_ID };
508+
509+
await controller.getLessonsLearnt(mockReq, mockRes);
510+
511+
expect(mockRes.status).toHaveBeenCalledWith(200);
512+
const firstMatchStage = mockBuildingNewLesson.aggregate.mock.calls[0][0][0].$match;
513+
expect(firstMatchStage.relatedProject).toBeDefined();
514+
});
515+
516+
// --- Filter construction: date range ---
517+
it('should apply $gte and $lte date filters when startDate and endDate are given', async () => {
518+
mockReq.query = { startDate: '2024-01-01', endDate: '2024-12-31' };
519+
520+
await controller.getLessonsLearnt(mockReq, mockRes);
521+
522+
const firstMatchStage = mockBuildingNewLesson.aggregate.mock.calls[0][0][0].$match;
523+
expect(firstMatchStage.date.$gte).toEqual(new Date('2024-01-01'));
524+
expect(firstMatchStage.date.$lte).toEqual(new Date('2024-12-31'));
525+
});
526+
527+
it('should apply only $gte when only startDate is given', async () => {
528+
mockReq.query = { startDate: '2024-01-01' };
529+
530+
await controller.getLessonsLearnt(mockReq, mockRes);
531+
532+
const firstMatchStage = mockBuildingNewLesson.aggregate.mock.calls[0][0][0].$match;
533+
expect(firstMatchStage.date.$gte).toEqual(new Date('2024-01-01'));
534+
expect(firstMatchStage.date.$lte).toBeUndefined();
535+
});
536+
537+
it('should apply only $lte when only endDate is given', async () => {
538+
mockReq.query = { endDate: '2024-12-31' };
539+
540+
await controller.getLessonsLearnt(mockReq, mockRes);
541+
542+
const firstMatchStage = mockBuildingNewLesson.aggregate.mock.calls[0][0][0].$match;
543+
expect(firstMatchStage.date.$lte).toEqual(new Date('2024-12-31'));
544+
expect(firstMatchStage.date.$gte).toBeUndefined();
545+
});
546+
547+
it('should not apply a date filter when neither startDate nor endDate is given', async () => {
548+
mockReq.query = {};
549+
550+
await controller.getLessonsLearnt(mockReq, mockRes);
551+
552+
const firstMatchStage = mockBuildingNewLesson.aggregate.mock.calls[0][0][0].$match;
553+
expect(firstMatchStage.date).toBeUndefined();
554+
});
555+
556+
// --- changePercentage calculation ---
557+
it('should return +100% when lastMonth is 0 and thisMonth is positive', async () => {
558+
mockBuildingNewLesson.aggregate.mockReset();
559+
mockBuildingNewLesson.aggregate
560+
.mockResolvedValueOnce([
561+
{ project: 'Project A', projectId: mockProjectObjId, lessonsCount: 3 },
562+
])
563+
.mockResolvedValueOnce([{ _id: mockProjectObjId, thisMonthCount: 3 }])
564+
.mockResolvedValueOnce([]);
565+
mockReq.query = {};
566+
567+
await controller.getLessonsLearnt(mockReq, mockRes);
568+
569+
expect(mockRes.json).toHaveBeenCalledWith({
570+
data: [expect.objectContaining({ changePercentage: '+100%' })],
571+
});
572+
});
573+
574+
it('should return 0% when both lastMonth and thisMonth are 0', async () => {
575+
mockBuildingNewLesson.aggregate.mockReset();
576+
mockBuildingNewLesson.aggregate
577+
.mockResolvedValueOnce([
578+
{ project: 'Project A', projectId: mockProjectObjId, lessonsCount: 5 },
579+
])
580+
.mockResolvedValueOnce([])
581+
.mockResolvedValueOnce([]);
582+
mockReq.query = {};
583+
584+
await controller.getLessonsLearnt(mockReq, mockRes);
585+
586+
expect(mockRes.json).toHaveBeenCalledWith({
587+
data: [expect.objectContaining({ changePercentage: '0%' })],
588+
});
589+
});
590+
591+
it('should return a positive percentage when thisMonth exceeds lastMonth', async () => {
592+
mockBuildingNewLesson.aggregate.mockReset();
593+
mockBuildingNewLesson.aggregate
594+
.mockResolvedValueOnce([
595+
{ project: 'Project A', projectId: mockProjectObjId, lessonsCount: 6 },
596+
])
597+
.mockResolvedValueOnce([{ _id: mockProjectObjId, thisMonthCount: 6 }])
598+
.mockResolvedValueOnce([{ _id: mockProjectObjId, lastMonthCount: 4 }]);
599+
mockReq.query = {};
600+
601+
await controller.getLessonsLearnt(mockReq, mockRes);
602+
603+
expect(mockRes.json).toHaveBeenCalledWith({
604+
data: [expect.objectContaining({ changePercentage: '+50.0%' })],
605+
});
606+
});
607+
608+
it('should return a negative percentage when thisMonth is less than lastMonth', async () => {
609+
mockBuildingNewLesson.aggregate.mockReset();
610+
mockBuildingNewLesson.aggregate
611+
.mockResolvedValueOnce([
612+
{ project: 'Project A', projectId: mockProjectObjId, lessonsCount: 2 },
613+
])
614+
.mockResolvedValueOnce([{ _id: mockProjectObjId, thisMonthCount: 2 }])
615+
.mockResolvedValueOnce([{ _id: mockProjectObjId, lastMonthCount: 4 }]);
616+
mockReq.query = {};
617+
618+
await controller.getLessonsLearnt(mockReq, mockRes);
619+
620+
expect(mockRes.json).toHaveBeenCalledWith({
621+
data: [expect.objectContaining({ changePercentage: '-50.0%' })],
622+
});
623+
});
624+
625+
it('should return +0.0% when thisMonth equals lastMonth (no change)', async () => {
626+
mockBuildingNewLesson.aggregate.mockReset();
627+
mockBuildingNewLesson.aggregate
628+
.mockResolvedValueOnce([
629+
{ project: 'Project A', projectId: mockProjectObjId, lessonsCount: 4 },
630+
])
631+
.mockResolvedValueOnce([{ _id: mockProjectObjId, thisMonthCount: 4 }])
632+
.mockResolvedValueOnce([{ _id: mockProjectObjId, lastMonthCount: 4 }]);
633+
mockReq.query = {};
634+
635+
await controller.getLessonsLearnt(mockReq, mockRes);
636+
637+
expect(mockRes.json).toHaveBeenCalledWith({
638+
data: [expect.objectContaining({ changePercentage: '+0.0%' })],
639+
});
640+
});
641+
642+
it('should correctly compute changePercentage independently for multiple projects', async () => {
643+
const projectObjId2 = { toString: () => '507f1f77bcf86cd799439012' };
644+
mockBuildingNewLesson.aggregate.mockReset();
645+
mockBuildingNewLesson.aggregate
646+
.mockResolvedValueOnce([
647+
{ project: 'Project A', projectId: mockProjectObjId, lessonsCount: 6 },
648+
{ project: 'Project B', projectId: projectObjId2, lessonsCount: 2 },
649+
])
650+
.mockResolvedValueOnce([
651+
{ _id: mockProjectObjId, thisMonthCount: 6 },
652+
{ _id: projectObjId2, thisMonthCount: 1 },
653+
])
654+
.mockResolvedValueOnce([
655+
{ _id: mockProjectObjId, lastMonthCount: 4 },
656+
{ _id: projectObjId2, lastMonthCount: 2 },
657+
]);
658+
mockReq.query = {};
659+
660+
await controller.getLessonsLearnt(mockReq, mockRes);
661+
662+
const responseData = mockRes.json.mock.calls[0][0].data;
663+
expect(responseData).toHaveLength(2);
664+
expect(responseData[0]).toMatchObject({ project: 'Project A', changePercentage: '+50.0%' });
665+
expect(responseData[1]).toMatchObject({ project: 'Project B', changePercentage: '-50.0%' });
666+
});
667+
668+
// --- Error path: Issue 4 (logger) ---
669+
it('should return 500 and call logger.logException when aggregate throws', async () => {
670+
const dbError = new Error('Database error');
671+
mockBuildingNewLesson.aggregate.mockReset();
672+
mockBuildingNewLesson.aggregate.mockRejectedValue(dbError);
673+
mockReq.query = { projectId: VALID_PROJECT_ID };
674+
675+
// The controller captures `logger` at module-load time (top-level require), so
676+
// jest.mock factory cannot intercept it. jest.spyOn mutates the shared cached
677+
// module object that the controller already holds a reference to.
678+
const logExceptionSpy = jest
679+
.spyOn(logger, 'logException')
680+
.mockReturnValue('mock-tracking-id');
681+
682+
await controller.getLessonsLearnt(mockReq, mockRes);
683+
684+
expect(mockRes.status).toHaveBeenCalledWith(500);
685+
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Internal Server Error' });
686+
expect(logExceptionSpy).toHaveBeenCalledWith(dbError, 'getLessonsLearnt', {
687+
query: { projectId: VALID_PROJECT_ID },
688+
});
689+
690+
logExceptionSpy.mockRestore();
691+
});
692+
});
693+
401694
describe('getLessonTags', () => {
402695
it('should return unique sorted tags', async () => {
403696
const mockLessons = [

0 commit comments

Comments
 (0)