11const bmNewLessonController = require ( '../bmNewLessonController' ) ;
2+ const logger = require ( '../../../startup/logger' ) ;
23
34// Mock dependencies
45const 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
1618const 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