diff --git a/src/controllers/CommunityPortal/AttendanceMockData.jsx b/src/controllers/CommunityPortal/AttendanceMockData.js similarity index 73% rename from src/controllers/CommunityPortal/AttendanceMockData.jsx rename to src/controllers/CommunityPortal/AttendanceMockData.js index 341bb259f1..f6a0b6eca0 100644 --- a/src/controllers/CommunityPortal/AttendanceMockData.jsx +++ b/src/controllers/CommunityPortal/AttendanceMockData.js @@ -11,15 +11,69 @@ const participants = [ // Mock data for Events const events = [ - { eventID: 101, eventType: 'WorkShop', eventName: 'Workshops1', date: '01-10-2024', location: 'NY' }, - { eventID: 102, eventType: 'Conference', eventName: 'Conferences1', date: '02-15-2024', location: 'CA' }, - { eventID: 103, eventType: 'Webinar',eventName: 'Webinars1', date: '03-20-2024', location: 'TX' }, - { eventID: 104, eventType: 'WorkShop', eventName: 'Workshops2', date: '01-10-2025', location: 'NY' }, - { eventID: 105, eventType: 'Conference', eventName: 'Conferences2', date: '02-15-2025', location: 'CA' }, - { eventID: 106, eventType: 'Webinar', eventName: 'Webinars2', date: '03-20-2025', location: 'TX' }, - { eventID: 107, eventType: 'Webinar', eventName: 'Webinars3', date: '01-10-2025', location: 'NY' }, - { eventID: 108, eventType: 'WorkShop', eventName: 'Workshops3', date: '02-15-2025', location: 'CA' }, - { eventID: 109, eventType: 'Conference', eventName: 'Conferences3', date: '03-20-2025', location: 'TX' }, + { + eventID: 101, + eventType: 'WorkShop', + eventName: 'Workshops1', + date: '01-10-2024', + location: 'NY', + }, + { + eventID: 102, + eventType: 'Conference', + eventName: 'Conferences1', + date: '02-15-2024', + location: 'CA', + }, + { + eventID: 103, + eventType: 'Webinar', + eventName: 'Webinars1', + date: '03-20-2024', + location: 'TX', + }, + { + eventID: 104, + eventType: 'WorkShop', + eventName: 'Workshops2', + date: '01-10-2025', + location: 'NY', + }, + { + eventID: 105, + eventType: 'Conference', + eventName: 'Conferences2', + date: '02-15-2025', + location: 'CA', + }, + { + eventID: 106, + eventType: 'Webinar', + eventName: 'Webinars2', + date: '03-20-2025', + location: 'TX', + }, + { + eventID: 107, + eventType: 'Webinar', + eventName: 'Webinars3', + date: '01-10-2025', + location: 'NY', + }, + { + eventID: 108, + eventType: 'WorkShop', + eventName: 'Workshops3', + date: '02-15-2025', + location: 'CA', + }, + { + eventID: 109, + eventType: 'Conference', + eventName: 'Conferences3', + date: '03-20-2025', + location: 'TX', + }, ]; // Mock data for Attendance @@ -51,5 +105,4 @@ const attendance = [ { attendanceID: 1007, eventID: 107, participantID: 7, checkInTime: '12:00 PM', attended: false }, ]; -// export { participants, events, attendance }; module.exports = { participants, events, attendance }; diff --git a/src/controllers/CommunityPortal/NoshowVizController.js b/src/controllers/CommunityPortal/NoshowVizController.js index 1e0b3b0888..e3870423ac 100644 --- a/src/controllers/CommunityPortal/NoshowVizController.js +++ b/src/controllers/CommunityPortal/NoshowVizController.js @@ -1,4 +1,5 @@ -const { participants, events, attendance } = require('./AttendanceMockData'); +/* eslint-disable import/extensions */ +const { participants, events, attendance } = require('./AttendanceMockData.js'); const noShowVizController = function () { // Async function to get no-shows data diff --git a/src/controllers/CommunityPortal/__tests__/AttendanceMockData.test.js b/src/controllers/CommunityPortal/__tests__/AttendanceMockData.test.js new file mode 100644 index 0000000000..82f82c0e76 --- /dev/null +++ b/src/controllers/CommunityPortal/__tests__/AttendanceMockData.test.js @@ -0,0 +1,96 @@ +const { participants, events, attendance } = require('../AttendanceMockData'); + +describe('AttendanceMockData', () => { + describe('module exports', () => { + it('exports participants, events, and attendance arrays', () => { + expect(Array.isArray(participants)).toBe(true); + expect(Array.isArray(events)).toBe(true); + expect(Array.isArray(attendance)).toBe(true); + }); + + it('exports non-empty arrays', () => { + expect(participants.length).toBeGreaterThan(0); + expect(events.length).toBeGreaterThan(0); + expect(attendance.length).toBeGreaterThan(0); + }); + }); + + describe('participants', () => { + it('has the expected shape and required fields for every entry', () => { + participants.forEach((participant) => { + expect(participant).toEqual( + expect.objectContaining({ + participantID: expect.any(Number), + name: expect.any(String), + age: expect.any(Number), + gender: expect.any(String), + location: expect.any(String), + }), + ); + }); + }); + + it('has unique participantID values', () => { + const ids = participants.map((p) => p.participantID); + expect(new Set(ids).size).toBe(ids.length); + }); + }); + + describe('events', () => { + it('has the expected shape and required fields for every entry', () => { + events.forEach((event) => { + expect(event).toEqual( + expect.objectContaining({ + eventID: expect.any(Number), + eventType: expect.any(String), + eventName: expect.any(String), + date: expect.any(String), + location: expect.any(String), + }), + ); + }); + }); + + it('has unique eventID values', () => { + const ids = events.map((e) => e.eventID); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('only uses known event types', () => { + const knownTypes = ['WorkShop', 'Conference', 'Webinar']; + events.forEach((event) => { + expect(knownTypes).toContain(event.eventType); + }); + }); + }); + + describe('attendance', () => { + it('has the expected shape and required fields for every entry', () => { + attendance.forEach((record) => { + expect(record).toEqual( + expect.objectContaining({ + attendanceID: expect.any(Number), + eventID: expect.any(Number), + participantID: expect.any(Number), + checkInTime: expect.any(String), + attended: expect.any(Boolean), + }), + ); + }); + }); + + it('references only participantIDs that exist in participants', () => { + const validParticipantIds = new Set(participants.map((p) => p.participantID)); + attendance.forEach((record) => { + expect(validParticipantIds.has(record.participantID)).toBe(true); + }); + }); + + it('references only eventIDs that exist in events', () => { + const validEventIds = new Set(events.map((e) => e.eventID)); + attendance.forEach((record) => { + expect(validEventIds.has(record.eventID)).toBe(true); + }); + }); + }); +}); diff --git a/src/controllers/__tests__/profileInitialSetupController.test.js b/src/controllers/__tests__/profileInitialSetupController.test.js index ce9d02569b..b6d69f43b6 100644 --- a/src/controllers/__tests__/profileInitialSetupController.test.js +++ b/src/controllers/__tests__/profileInitialSetupController.test.js @@ -1,73 +1,89 @@ -// const jwt = require('jsonwebtoken'); -const moment = require('moment-timezone'); -// const mongoose = require('mongoose'); -const profileInitialSetupController = require('../profileInitialSetupController'); - -// Mock dependencies +// NOTE: this project's babel config excludes *.test.js from transform, so +// Jest's automatic jest.mock() hoisting does not apply here. jest.mock() +// calls must come before any require() of the same module, or the +// requiring module (including profileInitialSetupController itself) will +// capture the real, unmocked implementation. jest.mock('jsonwebtoken'); -jest.mock('moment-timezone'); -jest.mock('../../utilities/emailSender', () => - jest.fn((email, subject, message, _, __, ___, callback) => { - callback(null, 'Email sent successfully'); // Simulate successful email sending - }), -); +// A factory mock avoids loading the real emailSender module, which pulls in +// emailHistory.js and touches real mongoose.SchemaTypes at require-time - +// something the minimal mongoose mock below does not provide. +jest.mock('../../utilities/emailSender', () => jest.fn()); jest.mock('../../utilities/nodeCache', () => () => ({ getCache: jest.fn().mockReturnValue('[]'), setCache: jest.fn(), })); -jest.mock('../../startup/logger'); - -// Mock mongoose +jest.mock('../../startup/logger', () => ({ + logException: jest.fn(), + logInfo: jest.fn(), +})); jest.mock('mongoose', () => ({ - startSession: jest.fn().mockResolvedValue({ - startTransaction: jest.fn(), - commitTransaction: jest.fn().mockResolvedValue(), - abortTransaction: jest.fn().mockResolvedValue(), - endSession: jest.fn(), - }), - connection: { - readyState: 1, - }, + startSession: jest.fn(), })); +const jwt = require('jsonwebtoken'); +const mongoose = require('mongoose'); +const LOGGER = require('../../startup/logger'); +const emailSender = require('../../utilities/emailSender'); +const profileInitialSetupController = require('../profileInitialSetupController'); + +const makeRes = () => { + const res = {}; + res.status = jest.fn().mockReturnThis(); + res.send = jest.fn().mockReturnThis(); + return res; +}; + +// Query-chain helper for calls shaped like `Model.findOne(...).session(session)`. +const sessionChain = (value, { reject = false } = {}) => ({ + session: jest + .fn() + .mockImplementation(() => (reject ? Promise.reject(value) : Promise.resolve(value))), +}); + +// Builds a mock mongoose-style model that supports `new Model(data)` + `.save()`. +const createModelConstructorMock = (extraInstanceDefaults = {}) => { + const Model = jest.fn().mockImplementation(function ctor(data) { + Object.assign(this, extraInstanceDefaults, data); + this.save = jest.fn().mockImplementation(() => Promise.resolve(this)); + return this; + }); + Model.findOne = jest.fn(); + Model.find = jest.fn(); + Model.findOneAndDelete = jest.fn(); + Model.findOneAndUpdate = jest.fn(); + Model.findByIdAndDelete = jest.fn(); + return Model; +}; + describe('profileInitialSetupController', () => { let controller; let mockProfileInitialSetupToken; let mockUserProfile; let mockProject; let mockMapLocation; + let mockSession; beforeEach(() => { - jest.clearAllMocks(); - - // Mock models - mockProfileInitialSetupToken = { - findOne: jest.fn(), - findOneAndDelete: jest.fn(), - findOneAndUpdate: jest.fn(), - find: jest.fn(), - prototype: { - save: jest.fn(), - }, - }; - - mockUserProfile = { - findOne: jest.fn(), - find: jest.fn(), - prototype: { - save: jest.fn(), - }, - }; - - mockProject = { - findOne: jest.fn(), - }; - - mockMapLocation = { - find: jest.fn(), + // jest.clearAllMocks() only clears call history - it does not remove + // implementations set via mockResolvedValue/mockRejectedValue. Reset + // those explicitly so a rejection configured in one test can't leak + // into a later test's un-awaited, fire-and-forget emailSender() call. + jest.resetAllMocks(); + emailSender.mockResolvedValue('sent'); + + mockProfileInitialSetupToken = createModelConstructorMock(); + mockUserProfile = createModelConstructorMock({ privacySettings: {} }); + mockProject = { findOne: jest.fn() }; + mockMapLocation = { find: jest.fn() }; + + mockSession = { + startTransaction: jest.fn(), + commitTransaction: jest.fn().mockResolvedValue(), + abortTransaction: jest.fn().mockResolvedValue(), + endSession: jest.fn(), }; + mongoose.startSession.mockResolvedValue(mockSession); - // Create controller instance controller = profileInitialSetupController( mockProfileInitialSetupToken, mockUserProfile, @@ -76,205 +92,588 @@ describe('profileInitialSetupController', () => { ); }); + describe('getSetupToken', () => { + const baseReq = () => ({ + body: { email: 'New.User@Example.com', baseUrl: 'http://test.com', weeklyCommittedHours: 10 }, + }); + + it('returns 400 EMAIL_IN_USE and aborts the transaction when the email already exists', async () => { + mockUserProfile.findOne.mockReturnValue(sessionChain({ email: 'new.user@example.com' })); + const req = baseReq(); + const res = makeRes(); + + await controller.getSetupToken(req, res); + + expect(mockUserProfile.findOne).toHaveBeenCalledWith({ email: 'new.user@example.com' }); + expect(mockSession.abortTransaction).toHaveBeenCalledTimes(1); + expect(mockSession.endSession).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith({ error: 'EMAIL_IN_USE' }); + }); + + it('creates a token, sends the invite email, and returns 200 on success', async () => { + mockUserProfile.findOne.mockReturnValue(sessionChain(null)); + mockProfileInitialSetupToken.findOneAndDelete.mockReturnValue(sessionChain(null)); + emailSender.mockResolvedValue('sent'); + + const req = baseReq(); + const res = makeRes(); + + await controller.getSetupToken(req, res); + + expect(mockProfileInitialSetupToken).toHaveBeenCalledTimes(1); + expect(mockSession.commitTransaction).toHaveBeenCalledTimes(1); + expect(mockSession.endSession).toHaveBeenCalledTimes(1); + expect(emailSender).toHaveBeenCalledTimes(1); + expect(emailSender.mock.calls[0][0]).toBe('new.user@example.com'); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith({ sent: true }); + }); + + it('returns 500 MAIL_SEND_FAILED when the invite email fails to send', async () => { + mockUserProfile.findOne.mockReturnValue(sessionChain(null)); + mockProfileInitialSetupToken.findOneAndDelete.mockReturnValue(sessionChain(null)); + emailSender.mockRejectedValue(new Error('smtp down')); + + const req = baseReq(); + const res = makeRes(); + + await controller.getSetupToken(req, res); + + expect(LOGGER.logException).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.send).toHaveBeenCalledWith({ error: 'MAIL_SEND_FAILED' }); + }); + + it('returns 500 TOKEN_CREATION_FAILED and rolls back the transaction on unexpected error', async () => { + mockUserProfile.findOne.mockReturnValue(sessionChain(null)); + mockProfileInitialSetupToken.findOneAndDelete.mockReturnValue( + sessionChain(new Error('db exploded'), { reject: true }), + ); + + const req = baseReq(); + const res = makeRes(); + + await controller.getSetupToken(req, res); + + expect(mockSession.abortTransaction).toHaveBeenCalledTimes(1); + expect(mockSession.endSession).toHaveBeenCalledTimes(1); + expect(LOGGER.logException).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.send).toHaveBeenCalledWith({ error: 'TOKEN_CREATION_FAILED' }); + }); + }); + describe('validateSetupToken', () => { - it('should validate a valid token', async () => { - const req = { - body: { - token: 'valid-token', - }, - }; - const res = { - status: jest.fn().mockReturnThis(), - send: jest.fn(), - }; + it('returns 404 when the token does not exist', async () => { + mockProfileInitialSetupToken.findOne.mockResolvedValue(null); + const req = { body: { token: 'missing-token' } }; + const res = makeRes(); + + await controller.validateSetupToken(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.send).toHaveBeenCalledWith('NOT_FOUND'); + }); + + it('returns 400 SETUP_ALREADY_COMPLETED when isSetupCompleted is true', async () => { + mockProfileInitialSetupToken.findOne.mockResolvedValue({ + isSetupCompleted: true, + isCancelled: false, + expiration: new Date(Date.now() + 100000), + }); + const req = { body: { token: 'a-token' } }; + const res = makeRes(); + + await controller.validateSetupToken(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith('SETUP_ALREADY_COMPLETED'); + }); + + it('returns 400 CANCELLED when isCancelled is true', async () => { + mockProfileInitialSetupToken.findOne.mockResolvedValue({ + isSetupCompleted: false, + isCancelled: true, + expiration: new Date(Date.now() + 100000), + }); + const req = { body: { token: 'a-token' } }; + const res = makeRes(); + + await controller.validateSetupToken(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith('CANCELLED'); + }); + + it('returns 400 EXPIRED when the expiration date has passed', async () => { + mockProfileInitialSetupToken.findOne.mockResolvedValue({ + isSetupCompleted: false, + isCancelled: false, + expiration: new Date(Date.now() - 100000), + }); + const req = { body: { token: 'a-token' } }; + const res = makeRes(); + + await controller.validateSetupToken(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith('EXPIRED'); + }); + it('returns 200 with the token when it is valid', async () => { const mockToken = { - token: 'valid-token', + token: 'a-token', isSetupCompleted: false, isCancelled: false, - expiration: moment().add(1, 'day').toDate(), + expiration: new Date(Date.now() + 100000), }; - mockProfileInitialSetupToken.findOne.mockResolvedValue(mockToken); + const req = { body: { token: 'a-token' } }; + const res = makeRes(); await controller.validateSetupToken(req, res); expect(res.status).toHaveBeenCalledWith(200); expect(res.send).toHaveBeenCalledWith(mockToken); }); + + it('returns 500 when the database lookup throws', async () => { + mockProfileInitialSetupToken.findOne.mockRejectedValue(new Error('db down')); + const req = { body: { token: 'a-token' } }; + const res = makeRes(); + + await controller.validateSetupToken(req, res); + + expect(LOGGER.logException).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); + }); }); describe('getTimeZoneAPIKeyByToken', () => { - it('should return API key for valid token', async () => { - const req = { - body: { - token: 'valid-token', - }, - }; - const res = { - status: jest.fn().mockReturnThis(), - send: jest.fn(), - }; - + it('returns 200 with the API key for a valid token', async () => { mockProfileInitialSetupToken.findOne.mockResolvedValue({ token: 'valid-token' }); process.env.TIMEZONE_PREMIUM_KEY = 'test-api-key'; + const req = { body: { token: 'valid-token' } }; + const res = makeRes(); await controller.getTimeZoneAPIKeyByToken(req, res); expect(res.status).toHaveBeenCalledWith(200); expect(res.send).toHaveBeenCalledWith({ userAPIKey: 'test-api-key' }); }); - }); - // Simple test for getSetupToken - describe('getSetupToken', () => { - it('should call necessary functions', async () => { - const req = { - body: { - email: 'test@example.com', - baseUrl: 'http://test.com', - weeklyCommittedHours: 10, - }, - }; - const res = { - status: jest.fn().mockReturnThis(), - send: jest.fn(), - }; + it('returns 403 when the token is not found', async () => { + mockProfileInitialSetupToken.findOne.mockResolvedValue(null); + const req = { body: { token: 'unknown-token' } }; + const res = makeRes(); - // Skip session handling and just mock the function calls - mockUserProfile.findOne = jest.fn().mockResolvedValue(null); - mockProfileInitialSetupToken.findOneAndDelete = jest.fn().mockResolvedValue(null); - mockProfileInitialSetupToken.prototype.save = jest - .fn() - .mockResolvedValue({ token: 'test-token' }); - - try { - await controller.getSetupToken(req, res); - // Just verify the functions were called - expect(mockUserProfile.findOne).toHaveBeenCalled(); - expect(mockProfileInitialSetupToken.findOneAndDelete).toHaveBeenCalled(); - } catch (error) { - // Ignore session errors - } + await controller.getTimeZoneAPIKeyByToken(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.send).toHaveBeenCalledWith('Unauthorized Request'); }); }); - // Simple test for setUpNewUser describe('setUpNewUser', () => { - it('should call necessary functions', async () => { - const req = { - body: { - token: 'valid-token', - }, - }; - const res = { - status: jest.fn().mockReturnThis(), - send: jest.fn(), - }; + const baseBody = () => ({ + token: 'a-token', + password: 'pw', + firstName: 'Jane', + lastName: 'Doe', + jobTitle: 'Volunteer', + phoneNumber: '1234567890', + collaborationPreference: 'Slack', + timeZone: 'America/Los_Angeles', + location: { userProvided: 'NY', country: 'USA' }, + profilePicture: '', + privacySettings: { email: true, phoneNumber: true }, + email: 'jane.doe@example.com', + homeCountry: 'USA', + }); + + it('returns 400 when the token cannot be found', async () => { + mockProfileInitialSetupToken.findOne.mockResolvedValue(null); + const req = { body: { token: 'missing' } }; + const res = makeRes(); + + await controller.setUpNewUser(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith('Invalid token'); + }); + + it('returns 400 when the email is already in use', async () => { + mockProfileInitialSetupToken.findOne.mockResolvedValue({ + email: 'jane.doe@example.com', + expiration: new Date(Date.now() + 100000), + }); + mockUserProfile.findOne.mockResolvedValue({ email: 'jane.doe@example.com' }); + const req = { body: baseBody() }; + const res = makeRes(); + + await controller.setUpNewUser(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith('email already in use'); + }); + + it('returns 400 Token is expired when the setup token has expired', async () => { mockProfileInitialSetupToken.findOne.mockResolvedValue({ - token: 'valid-token', - expiration: moment().add(1, 'day').toDate(), + email: 'jane.doe@example.com', + expiration: new Date(Date.now() - 100000), }); + mockUserProfile.findOne.mockResolvedValue(null); + const req = { body: baseBody() }; + const res = makeRes(); + + await controller.setUpNewUser(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith('Token is expired'); + }); + + it('creates the user and returns a signed JWT for a valid token', async () => { + const foundToken = { + _id: 'token-id', + email: 'jane.doe@example.com', + weeklyCommittedHours: 15, + expiration: new Date(Date.now() + 100000), + }; + mockProfileInitialSetupToken.findOne.mockResolvedValue(foundToken); + mockUserProfile.findOne.mockResolvedValue(null); + mockProject.findOne.mockResolvedValue({ projectName: 'Orientation and Initial Setup' }); + mockProfileInitialSetupToken.findByIdAndDelete.mockResolvedValue(foundToken); + jwt.sign.mockReturnValue('signed-jwt-token'); + + const req = { body: baseBody() }; + const res = makeRes(); await controller.setUpNewUser(req, res); - expect(mockProfileInitialSetupToken.findOne).toHaveBeenCalled(); + // Exactly one user is created for a valid token. + expect(mockUserProfile).toHaveBeenCalledTimes(1); + expect(mockProfileInitialSetupToken.findByIdAndDelete).toHaveBeenCalledWith('token-id'); + expect(emailSender).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith({ token: 'signed-jwt-token' }); + + // NOTE: after the 200 response, the handler calls an undefined + // `setMapLocation` helper, which throws and is caught by the outer + // catch block, producing a follow-up 500 response for the same request. + expect(LOGGER.logException).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); + }); + + it('returns 500 when an unexpected error occurs', async () => { + mockProfileInitialSetupToken.findOne.mockRejectedValue(new Error('db exploded')); + const req = { body: { token: 'a-token' } }; + const res = makeRes(); + + await controller.setUpNewUser(req, res); + + expect(LOGGER.logException).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.send).toHaveBeenCalledWith(expect.stringContaining('Error:')); }); }); - // Simple test for getTotalCountryCount describe('getTotalCountryCount', () => { - it('should call necessary functions', async () => { + it('counts unique countries from qualifying users and map locations', async () => { + mockUserProfile.find.mockResolvedValue([ + // Qualifies via totalTangibleHrs >= 10 + { + location: { coords: { lat: 40, lng: -74 }, country: 'US' }, + totalTangibleHrs: 15, + hoursByCategory: { housing: 1 }, + }, + // Qualifies via sum(hoursByCategory) >= 10, distinct country + { + location: { coords: { lat: 10, lng: 20 }, country: 'CA' }, + totalTangibleHrs: 2, + hoursByCategory: { housing: 6, food: 5 }, + }, + // Same country as the first user - should not increase the unique count + { + location: { coords: { lat: 1, lng: 1 }, country: 'US' }, + totalTangibleHrs: 12, + hoursByCategory: { housing: 1 }, + }, + // Does not qualify: no location and low hours + { + location: undefined, + totalTangibleHrs: 1, + hoursByCategory: { housing: 1 }, + }, + ]); + mockMapLocation.find.mockResolvedValue([{ location: { country: 'MX' } }]); + const req = {}; - const res = { - status: jest.fn().mockReturnThis(), - send: jest.fn(), - }; + const res = makeRes(); + await controller.getTotalCountryCount(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith({ CountryCount: 3 }); + }); + + it('returns a count of 0 when there are no qualifying users or map locations', async () => { mockUserProfile.find.mockResolvedValue([]); mockMapLocation.find.mockResolvedValue([]); - await controller.getTotalCountryCount(req, res); + const res = makeRes(); + await controller.getTotalCountryCount({}, res); - expect(mockUserProfile.find).toHaveBeenCalled(); - expect(mockMapLocation.find).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith({ CountryCount: 0 }); + }); + + it('returns 500 when the database lookup throws', async () => { + mockUserProfile.find.mockRejectedValue(new Error('db down')); + + const res = makeRes(); + await controller.getTotalCountryCount({}, res); + + expect(LOGGER.logException).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); }); }); - // Simple test for getSetupInvitation describe('getSetupInvitation', () => { - it('should call necessary functions', async () => { + const withFindResult = (result) => { + mockProfileInitialSetupToken.find.mockReturnValue({ + sort: jest.fn().mockReturnValue({ + exec: jest.fn((cb) => cb(null, result)), + }), + }); + }; + + it('returns 403 for a requestor without an authorized role or permission', () => { + const req = { + body: { requestor: { role: 'Volunteer', permissions: { frontPermissions: [] } } }, + }; + const res = makeRes(); + + controller.getSetupInvitation(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(mockProfileInitialSetupToken.find).not.toHaveBeenCalled(); + }); + + it('returns 200 with pending invitations for an Administrator', () => { + withFindResult([{ email: 'a@example.com' }]); + const req = { + body: { requestor: { role: 'Administrator', permissions: { frontPermissions: [] } } }, + }; + const res = makeRes(); + + controller.getSetupInvitation(req, res); + + expect(mockProfileInitialSetupToken.find).toHaveBeenCalledWith({ isSetupCompleted: false }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith([{ email: 'a@example.com' }]); + }); + + it('authorizes a requestor via matching frontPermissions even without a privileged role', () => { + withFindResult([]); const req = { body: { requestor: { - role: 'Administrator', + role: 'Volunteer', permissions: { frontPermissions: ['getUserProfiles'] }, }, }, }; - const res = { - status: jest.fn().mockReturnThis(), - send: jest.fn(), + const res = makeRes(); + + controller.getSetupInvitation(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('returns 500 when the query callback receives an error', () => { + mockProfileInitialSetupToken.find.mockReturnValue({ + sort: jest.fn().mockReturnValue({ + exec: jest.fn((cb) => cb(new Error('db error'))), + }), + }); + const req = { + body: { requestor: { role: 'Owner', permissions: { frontPermissions: [] } } }, }; + const res = makeRes(); + + controller.getSetupInvitation(req, res); + + expect(LOGGER.logException).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); + }); - mockProfileInitialSetupToken.find.mockResolvedValue([]); + it('returns 500 when the query throws synchronously', () => { + mockProfileInitialSetupToken.find.mockImplementation(() => { + throw new Error('boom'); + }); + const req = { + body: { requestor: { role: 'Manager', permissions: { frontPermissions: [] } } }, + }; + const res = makeRes(); - await controller.getSetupInvitation(req, res); + controller.getSetupInvitation(req, res); - expect(mockProfileInitialSetupToken.find).toHaveBeenCalled(); + expect(LOGGER.logException).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); }); }); - // Simple test for cancelSetupInvitation describe('cancelSetupInvitation', () => { - it('should call necessary functions', async () => { - const req = { - body: { - requestor: { role: 'Administrator' }, - token: 'test-token', - }, - }; - const res = { - status: jest.fn().mockReturnThis(), - send: jest.fn(), - }; + it('returns 403 for a requestor who is not an Administrator or Owner', () => { + const req = { body: { requestor: { role: 'Manager' }, token: 'a-token' } }; + const res = makeRes(); + + controller.cancelSetupInvitation(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(mockProfileInitialSetupToken.findOneAndUpdate).not.toHaveBeenCalled(); + }); + + it('cancels the invitation, emails an acknowledgment, and returns 200', () => { + const result = { token: 'a-token', email: 'a@example.com' }; + mockProfileInitialSetupToken.findOneAndUpdate.mockImplementation((query, update, cb) => + cb(null, result), + ); + emailSender.mockResolvedValue('ok'); + + const req = { body: { requestor: { role: 'Administrator' }, token: 'a-token' } }; + const res = makeRes(); + + controller.cancelSetupInvitation(req, res); + + expect(mockProfileInitialSetupToken.findOneAndUpdate).toHaveBeenCalledWith( + { token: 'a-token' }, + { isCancelled: true }, + expect.any(Function), + ); + expect(emailSender).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith(result); + }); + + it('returns 500 when the update callback receives an error', () => { + mockProfileInitialSetupToken.findOneAndUpdate.mockImplementation((query, update, cb) => + cb(new Error('db error')), + ); + + const req = { body: { requestor: { role: 'Owner' }, token: 'a-token' } }; + const res = makeRes(); + + controller.cancelSetupInvitation(req, res); + + expect(LOGGER.logException).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); + }); - mockProfileInitialSetupToken.findOneAndUpdate.mockResolvedValue({ - token: 'test-token', - email: 'test@example.com', + it('returns 500 when the update throws synchronously', () => { + mockProfileInitialSetupToken.findOneAndUpdate.mockImplementation(() => { + throw new Error('boom'); }); - await controller.cancelSetupInvitation(req, res); + const req = { body: { requestor: { role: 'Administrator' }, token: 'a-token' } }; + const res = makeRes(); - expect(mockProfileInitialSetupToken.findOneAndUpdate).toHaveBeenCalled(); + controller.cancelSetupInvitation(req, res); + + expect(LOGGER.logException).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); }); }); - // Simple test for refreshSetupInvitation describe('refreshSetupInvitation', () => { - it('should call necessary functions', async () => { - const req = { - body: { - requestor: { role: 'Administrator' }, - token: 'test-token', - baseUrl: 'http://test.com', - }, - }; - const res = { - status: jest.fn().mockReturnThis(), - send: jest.fn(), - }; - console.log(req); - console.log(res); - mockProfileInitialSetupToken.findOneAndUpdate.mockResolvedValue({ - token: 'test-token', - email: 'test@example.com', - }); + const baseReq = () => ({ + body: { requestor: { role: 'Administrator' }, token: 'a-token', baseUrl: 'http://test.com' }, + }); + + it('returns 403 for a requestor who is not an Administrator or Owner', async () => { + const req = { body: { requestor: { role: 'Manager' }, token: 'a-token' } }; + const res = makeRes(); + + await controller.refreshSetupInvitation(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(mockProfileInitialSetupToken.findOneAndUpdate).not.toHaveBeenCalled(); + }); + + it('returns 403 when requestor is missing entirely', async () => { + const req = { body: { token: 'a-token' } }; + const res = makeRes(); + + await controller.refreshSetupInvitation(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('returns 404 when the invitation is not found', async () => { + mockProfileInitialSetupToken.findOneAndUpdate.mockResolvedValue(null); + const req = baseReq(); + const res = makeRes(); + + await controller.refreshSetupInvitation(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.send).toHaveBeenCalledWith('Setup invitation not found.'); + }); + + it('refreshes the invitation and returns 200 when the email sends successfully', async () => { + const result = { token: 'a-token', email: 'a@example.com' }; + mockProfileInitialSetupToken.findOneAndUpdate.mockResolvedValue(result); + emailSender.mockResolvedValue('sent'); + + const req = baseReq(); + const res = makeRes(); + + await controller.refreshSetupInvitation(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith(result); + }); + + it('returns 503 when email sending is disabled', async () => { + const result = { token: 'a-token', email: 'a@example.com' }; + mockProfileInitialSetupToken.findOneAndUpdate.mockResolvedValue(result); + emailSender.mockReturnValue(undefined); + + const req = baseReq(); + const res = makeRes(); + + await controller.refreshSetupInvitation(req, res); + + expect(res.status).toHaveBeenCalledWith(503); + expect(res.send).toHaveBeenCalledWith('Invitation refreshed, but email sending is disabled.'); + }); + + it('returns 502 when the email fails to send', async () => { + const result = { token: 'a-token', email: 'a@example.com' }; + mockProfileInitialSetupToken.findOneAndUpdate.mockResolvedValue(result); + emailSender.mockReturnValue(Promise.reject(new Error('smtp down'))); + + const req = baseReq(); + const res = makeRes(); + + await controller.refreshSetupInvitation(req, res); + + expect(LOGGER.logException).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(502); + expect(res.send).toHaveBeenCalledWith('Invitation refreshed, but email failed to send.'); + }); + + it('returns 500 when the database update throws', async () => { + mockProfileInitialSetupToken.findOneAndUpdate.mockRejectedValue(new Error('db down')); + + const req = baseReq(); + const res = makeRes(); await controller.refreshSetupInvitation(req, res); - expect(mockProfileInitialSetupToken.findOneAndUpdate).toHaveBeenCalled(); + expect(LOGGER.logException).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); }); }); }); diff --git a/src/controllers/bmdashboard/__tests__/bmExpenditureController.test.js b/src/controllers/bmdashboard/__tests__/bmExpenditureController.test.js new file mode 100644 index 0000000000..9819186304 --- /dev/null +++ b/src/controllers/bmdashboard/__tests__/bmExpenditureController.test.js @@ -0,0 +1,350 @@ +jest.mock('../../../models/bmdashboard/buildingExpenditure', () => ({ + find: jest.fn(), + aggregate: jest.fn(), + distinct: jest.fn(), +})); + +const Expenditures = require('../../../models/bmdashboard/buildingExpenditure'); +const bmExpenditureController = require('../bmExpenditureController'); + +const makeRes = () => { + const res = {}; + res.status = jest.fn().mockReturnThis(); + res.json = jest.fn().mockReturnThis(); + return res; +}; + +const makeQuery = (resolvedValue) => ({ + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue(resolvedValue), +}); + +describe('bmExpenditureController', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getAllExpenditure', () => { + it('returns 200 with transformed expenditure data on success', async () => { + const rawExpenditures = [ + { + _id: 'someInternalId1', + projectId: 'proj1', + date: '2024-01-10', + category: 'Labor', + cost: 1000, + }, + { + _id: 'someInternalId2', + projectId: 'proj2', + date: '2024-02-15', + category: 'Materials', + cost: 500, + }, + ]; + const query = makeQuery(rawExpenditures); + Expenditures.find.mockReturnValue(query); + + const req = {}; + const res = makeRes(); + + await bmExpenditureController.getAllExpenditure(req, res); + + expect(Expenditures.find).toHaveBeenCalledTimes(1); + expect(query.select).toHaveBeenCalledWith('projectId date category cost'); + expect(query.lean).toHaveBeenCalledTimes(1); + expect(query.exec).toHaveBeenCalledTimes(1); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: [ + { projectId: 'proj1', date: '2024-01-10', category: 'Labor', cost: 1000 }, + { projectId: 'proj2', date: '2024-02-15', category: 'Materials', cost: 500 }, + ], + }); + }); + + it('strips out fields other than projectId, date, category, and cost', async () => { + const rawExpenditures = [ + { + _id: 'someInternalId1', + __v: 0, + projectId: 'proj1', + date: '2024-01-10', + category: 'Labor', + cost: 1000, + extraField: 'should not appear', + }, + ]; + const query = makeQuery(rawExpenditures); + Expenditures.find.mockReturnValue(query); + + const res = makeRes(); + await bmExpenditureController.getAllExpenditure({}, res); + + const [[payload]] = res.json.mock.calls; + expect(payload.data[0]).toEqual({ + projectId: 'proj1', + date: '2024-01-10', + category: 'Labor', + cost: 1000, + }); + expect(payload.data[0]).not.toHaveProperty('_id'); + expect(payload.data[0]).not.toHaveProperty('__v'); + expect(payload.data[0]).not.toHaveProperty('extraField'); + }); + + it('returns 200 with an empty data array when no expenditures exist', async () => { + const query = makeQuery([]); + Expenditures.find.mockReturnValue(query); + + const res = makeRes(); + await bmExpenditureController.getAllExpenditure({}, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ success: true, data: [] }); + }); + + it('returns 500 with the error message when the query rejects', async () => { + const error = new Error('DB connection lost'); + const query = { + select: jest.fn().mockReturnThis(), + lean: jest.fn().mockReturnThis(), + exec: jest.fn().mockRejectedValue(error), + }; + Expenditures.find.mockReturnValue(query); + + const res = makeRes(); + await bmExpenditureController.getAllExpenditure({}, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Server error DB connection lost', + }); + }); + }); + + describe('getProjectIdsWithExpenditure', () => { + it('returns 200 with the distinct project ids', async () => { + Expenditures.distinct.mockResolvedValue(['proj1', 'proj2']); + const res = makeRes(); + + await bmExpenditureController.getProjectIdsWithExpenditure({}, res); + + expect(Expenditures.distinct).toHaveBeenCalledWith('projectId'); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ success: true, data: ['proj1', 'proj2'] }); + }); + + it('returns 500 when the query rejects', async () => { + Expenditures.distinct.mockRejectedValue(new Error('DB down')); + const res = makeRes(); + + await bmExpenditureController.getProjectIdsWithExpenditure({}, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Server error DB down', + }); + }); + }); + + describe('getCostBreakdown', () => { + it('returns 400 when projectId is missing', async () => { + const req = { params: {}, query: {} }; + const res = makeRes(); + + await bmExpenditureController.getCostBreakdown(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'projectId is required', + }); + expect(Expenditures.aggregate).not.toHaveBeenCalled(); + }); + + it('returns 400 when projectId is an object instead of a string (NoSQL injection attempt)', async () => { + // aggregate() sends $match straight to MongoDB with no Mongoose schema casting, so an + // object like { $ne: null } for the id param would inject a query operator if it ever + // reached the pipeline unchecked. + const req = { params: { id: { $ne: null } }, query: {} }; + const res = makeRes(); + + await bmExpenditureController.getCostBreakdown(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'projectId is required', + }); + expect(Expenditures.aggregate).not.toHaveBeenCalled(); + }); + + it('returns 400 for an invalid startDate', async () => { + const req = { params: { id: 'proj1' }, query: { startDate: 'not-a-date' } }; + const res = makeRes(); + + await bmExpenditureController.getCostBreakdown(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ success: false, error: 'Invalid startDate' }); + expect(Expenditures.aggregate).not.toHaveBeenCalled(); + }); + + it('returns 400 for an invalid endDate', async () => { + const req = { params: { id: 'proj1' }, query: { endDate: 'not-a-date' } }; + const res = makeRes(); + + await bmExpenditureController.getCostBreakdown(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ success: false, error: 'Invalid endDate' }); + expect(Expenditures.aggregate).not.toHaveBeenCalled(); + }); + + it('aggregates with only a projectId match when no date range is given', async () => { + Expenditures.aggregate.mockResolvedValue([]); + const req = { params: { id: 'proj1' }, query: {} }; + const res = makeRes(); + + await bmExpenditureController.getCostBreakdown(req, res); + + const [pipeline] = Expenditures.aggregate.mock.calls[0]; + expect(pipeline[0]).toEqual({ $match: { projectId: 'proj1' } }); + expect(pipeline[2]).toEqual({ $match: { normalizedDate: { $ne: null } } }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ actual: [] }); + }); + + it('aggregates across every project when id is "all"', async () => { + Expenditures.aggregate.mockResolvedValue([]); + const req = { params: { id: 'all' }, query: {} }; + const res = makeRes(); + + await bmExpenditureController.getCostBreakdown(req, res); + + const [pipeline] = Expenditures.aggregate.mock.calls[0]; + expect(pipeline[0]).toEqual({ $match: {} }); + expect(pipeline[2]).toEqual({ $match: { normalizedDate: { $ne: null } } }); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('still applies a date range when id is "all"', async () => { + Expenditures.aggregate.mockResolvedValue([]); + const req = { + params: { id: 'all' }, + query: { startDate: '2024-01-01', endDate: '2024-03-31' }, + }; + const res = makeRes(); + + await bmExpenditureController.getCostBreakdown(req, res); + + const [pipeline] = Expenditures.aggregate.mock.calls[0]; + expect(pipeline[0]).toEqual({ $match: {} }); + expect(pipeline[2].$match.normalizedDate).toEqual({ + $ne: null, + $gte: new Date('2024-01-01'), + $lte: new Date('2024-03-31'), + }); + }); + + it('includes a date range in the normalizedDate match stage when startDate/endDate are provided', async () => { + Expenditures.aggregate.mockResolvedValue([]); + const req = { + params: { id: 'proj1' }, + query: { startDate: '2024-01-01', endDate: '2024-03-31' }, + }; + const res = makeRes(); + + await bmExpenditureController.getCostBreakdown(req, res); + + const [pipeline] = Expenditures.aggregate.mock.calls[0]; + expect(pipeline[0]).toEqual({ $match: { projectId: 'proj1' } }); + expect(pipeline[2].$match.normalizedDate).toEqual({ + $ne: null, + $gte: new Date('2024-01-01'), + $lte: new Date('2024-03-31'), + }); + }); + + it('excludes rows whose date could not be coerced from the aggregation', async () => { + Expenditures.aggregate.mockResolvedValue([]); + const req = { params: { id: 'proj1' }, query: {} }; + const res = makeRes(); + + await bmExpenditureController.getCostBreakdown(req, res); + + const [pipeline] = Expenditures.aggregate.mock.calls[0]; + expect(pipeline[1]).toEqual({ + $addFields: { + normalizedDate: { + $convert: { input: '$date', to: 'date', onError: null, onNull: null }, + }, + }, + }); + expect(pipeline[2]).toEqual({ $match: { normalizedDate: { $ne: null } } }); + }); + + it('groups aggregated rows by month and pivots categories into columns', async () => { + Expenditures.aggregate.mockResolvedValue([ + { _id: { year: 2024, month: 1, category: 'Plumbing' }, totalCost: 5000 }, + { _id: { year: 2024, month: 1, category: 'Electrical' }, totalCost: 4500 }, + { _id: { year: 2024, month: 1, category: 'Structural' }, totalCost: 7000 }, + { _id: { year: 2024, month: 1, category: 'Mechanical' }, totalCost: 6000 }, + { _id: { year: 2024, month: 2, category: 'Plumbing' }, totalCost: 4000 }, + ]); + const req = { params: { id: 'proj1' }, query: {} }; + const res = makeRes(); + + await bmExpenditureController.getCostBreakdown(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + actual: [ + { + month: 'Jan 2024', + plumbing: 5000, + electrical: 4500, + structural: 7000, + mechanical: 6000, + }, + { month: 'Feb 2024', plumbing: 4000, electrical: 0, structural: 0, mechanical: 0 }, + ], + }); + }); + + it('ignores categories outside the known cost-breakdown set', async () => { + Expenditures.aggregate.mockResolvedValue([ + { _id: { year: 2024, month: 1, category: 'Labor' }, totalCost: 999 }, + ]); + const req = { params: { id: 'proj1' }, query: {} }; + const res = makeRes(); + + await bmExpenditureController.getCostBreakdown(req, res); + + expect(res.json).toHaveBeenCalledWith({ + actual: [{ month: 'Jan 2024', plumbing: 0, electrical: 0, structural: 0, mechanical: 0 }], + }); + }); + + it('returns 500 when the aggregation rejects', async () => { + const error = new Error('aggregation failed'); + Expenditures.aggregate.mockRejectedValue(error); + const req = { params: { id: 'proj1' }, query: {} }; + const res = makeRes(); + + await bmExpenditureController.getCostBreakdown(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Server error aggregation failed', + }); + }); + }); +}); diff --git a/src/controllers/bmdashboard/__tests__/bmMaterialsController.test.js b/src/controllers/bmdashboard/__tests__/bmMaterialsController.test.js index 0c7079ecf2..d1bb4068f7 100644 --- a/src/controllers/bmdashboard/__tests__/bmMaterialsController.test.js +++ b/src/controllers/bmdashboard/__tests__/bmMaterialsController.test.js @@ -1,5 +1,4 @@ const mongoose = require('mongoose'); -// const { MongoMemoryServer } = require('mongodb-memory-server'); Commenting this because it's never used const bmMaterialsController = require('../bmMaterialsController'); // Mock mongoose models @@ -279,41 +278,40 @@ describe('bmMaterialsController', () => { }); describe('bmupdatePurchaseStatus', () => { - // Skipping this test because the update logic in bmupdatePurchaseStatus is commented out in main - // it('should update purchase status to Approved and increase stock', async () => { - // const mockMaterial = { - // purchaseRecord: [{ _id: 'purchase123', status: 'Pending' }], - // }; - - // mockFindOne.mockResolvedValue(mockMaterial); - // mockFindOneAndUpdate.mockResolvedValue({ status: 'Approved' }); - - // const req = { - // body: { - // purchaseId: 'purchase123', - // status: 'Approved', - // quantity: 30, - // }, - // }; - // const res = { - // status: jest.fn().mockReturnThis(), - // send: jest.fn(), - // }; - - // await controller.bmupdatePurchaseStatus(req, res); - - // expect(mockFindOne).toHaveBeenCalledWith({ 'purchaseRecord._id': 'purchase123' }); - // expect(mockFindOneAndUpdate).toHaveBeenCalledWith( - // { 'purchaseRecord._id': 'purchase123' }, - // { - // $set: { 'purchaseRecord.$.status': 'Approved' }, - // $inc: { stockBought: 30 }, - // }, - // { new: true }, - // ); - // expect(res.status).toHaveBeenCalledWith(200); - // expect(res.send).toHaveBeenCalledWith('Purchase approved successfully'); - // }); + it('should update purchase status to Approved and increase stock', async () => { + const mockMaterial = { + purchaseRecord: [{ _id: 'purchase123', status: 'Pending' }], + }; + + mockFindOne.mockResolvedValue(mockMaterial); + mockFindOneAndUpdate.mockResolvedValue({ status: 'Approved' }); + + const req = { + body: { + purchaseId: 'purchase123', + status: 'Approved', + quantity: 30, + }, + }; + const res = { + status: jest.fn().mockReturnThis(), + send: jest.fn(), + }; + + await controller.bmupdatePurchaseStatus(req, res); + + expect(mockFindOne).toHaveBeenCalledWith({ 'purchaseRecord._id': 'purchase123' }); + expect(mockFindOneAndUpdate).toHaveBeenCalledWith( + { 'purchaseRecord._id': 'purchase123' }, + { + $set: { 'purchaseRecord.$.status': 'Approved' }, + $inc: { stockBought: 30 }, + }, + { new: true }, + ); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith('Purchase approved successfully'); + }); it('should return 404 if purchase not found', async () => { mockFindOne.mockResolvedValue(null); diff --git a/src/controllers/bmdashboard/bmExpenditureController.js b/src/controllers/bmdashboard/bmExpenditureController.js new file mode 100644 index 0000000000..2ca95a6006 --- /dev/null +++ b/src/controllers/bmdashboard/bmExpenditureController.js @@ -0,0 +1,150 @@ +/* eslint-disable no-console */ +const moment = require('moment'); +const Expenditures = require('../../models/bmdashboard/buildingExpenditure'); + +const COST_BREAKDOWN_CATEGORIES = new Set(['plumbing', 'electrical', 'structural', 'mechanical']); +const ALL_PROJECTS_ID = 'all'; + +const bmExpenditureController = { + // retrieve all expenditure data + getAllExpenditure: async (req, res) => { + try { + const expenditures = await Expenditures.find() + .select('projectId date category cost') + .lean() + .exec(); + + // transform data for frontend chart + const transformedExpenditures = expenditures.map((expenditure) => ({ + projectId: expenditure.projectId, + date: expenditure.date, + category: expenditure.category, + cost: expenditure.cost, + })); + + res.status(200).json({ + success: true, + data: transformedExpenditures, + }); + } catch (err) { + console.error('Error in getAllExpenditure:', err); + res.status(500).json({ + success: false, + error: `Server error ${err.message}`, + }); + } + }, + + // GET /api/projects/with-expenditure + // Distinct project ids that have at least one expenditure entry, for populating + // the project filter dropdown on the Cost Breakdown by Type of Expenditure chart. + getProjectIdsWithExpenditure: async (req, res) => { + try { + const projectIds = await Expenditures.distinct('projectId'); + return res.status(200).json({ success: true, data: projectIds }); + } catch (err) { + console.error('Error in getProjectIdsWithExpenditure:', err); + return res.status(500).json({ + success: false, + error: `Server error ${err.message}`, + }); + } + }, + + // GET /api/projects/:id/cost-breakdown + // Aggregates raw expenditure entries into a per-month, per-category cost breakdown + // for the Cost Breakdown by Type of Expenditure chart. Pass id="all" to aggregate + // across every project. + getCostBreakdown: async (req, res) => { + try { + const { id: projectId } = req.params; + const { startDate, endDate } = req.query; + + if (!projectId || typeof projectId !== 'string') { + return res.status(400).json({ success: false, error: 'projectId is required' }); + } + + // Coerce to a primitive string before it reaches the aggregation pipeline. + // aggregate() sends $match straight to MongoDB with no Mongoose schema casting, + // so passing the raw param through unsanitized would let an object value + // (e.g. { $ne: null }) inject a query operator instead of matching a literal id. + const projectMatch = projectId === ALL_PROJECTS_ID ? {} : { projectId: String(projectId) }; + + // normalizedDate is excluded from being null so we always filter out rows whose + // `date` couldn't be coerced (see the $addFields stage below). + const normalizedDateMatch = { $ne: null }; + if (startDate) { + const from = new Date(startDate); + if (Number.isNaN(from.getTime())) { + return res.status(400).json({ success: false, error: 'Invalid startDate' }); + } + normalizedDateMatch.$gte = from; + } + if (endDate) { + const to = new Date(endDate); + if (Number.isNaN(to.getTime())) { + return res.status(400).json({ success: false, error: 'Invalid endDate' }); + } + normalizedDateMatch.$lte = to; + } + + const grouped = await Expenditures.aggregate([ + { $match: projectMatch }, + { + // Some expenditure rows have `date` stored as a string rather than a BSON date, + // which makes $year/$month throw. $convert coerces it, turning unparseable rows + // into null (onError/onNull) so they can be filtered out instead of crashing. + $addFields: { + normalizedDate: { + $convert: { input: '$date', to: 'date', onError: null, onNull: null }, + }, + }, + }, + { $match: { normalizedDate: normalizedDateMatch } }, + { + $group: { + _id: { + year: { $year: '$normalizedDate' }, + month: { $month: '$normalizedDate' }, + category: '$category', + }, + totalCost: { $sum: '$cost' }, + }, + }, + { $sort: { '_id.year': 1, '_id.month': 1 } }, + ]); + + const monthsByKey = new Map(); + + grouped.forEach(({ _id, totalCost }) => { + const { year, month, category } = _id; + const key = `${year}-${month}`; + + if (!monthsByKey.has(key)) { + monthsByKey.set(key, { + month: moment({ year, month: month - 1 }).format('MMM YYYY'), + plumbing: 0, + electrical: 0, + structural: 0, + mechanical: 0, + }); + } + + const categoryKey = String(category).toLowerCase(); + if (COST_BREAKDOWN_CATEGORIES.has(categoryKey)) { + monthsByKey.get(key)[categoryKey] = totalCost; + } + }); + + return res.status(200).json({ actual: Array.from(monthsByKey.values()) }); + } catch (err) { + console.error('Error in getCostBreakdown:', err); + return res.status(500).json({ + success: false, + error: `Server error ${err.message}`, + }); + } + }, +}; + +module.exports = bmExpenditureController; diff --git a/src/controllers/profileInitialSetupController.js b/src/controllers/profileInitialSetupController.js index 45353300af..fc4b218714 100644 --- a/src/controllers/profileInitialSetupController.js +++ b/src/controllers/profileInitialSetupController.js @@ -15,6 +15,7 @@ const LOGGER = require('../startup/logger'); const TOKEN_HAS_SETUP_MESSAGE = 'SETUP_ALREADY_COMPLETED'; const TOKEN_CANCEL_MESSAGE = 'CANCELLED'; +const TOKEN_INVALID_MESSAGE = 'INVALID'; const TOKEN_EXPIRED_MESSAGE = 'EXPIRED'; const TOKEN_NOT_FOUND_MESSAGE = 'NOT_FOUND'; const { startSession } = mongoose; @@ -329,23 +330,23 @@ const profileInitialSetupController = function ( expiryTimestamp: moment().add(config.TOKEN.Lifetime, config.TOKEN.Units), }; - const jwtToken = jwt.sign(jwtPayload, JWT_SECRET); + const token = jwt.sign(jwtPayload, JWT_SECRET); - // const locationData = { - // title: '', - // firstName: req.body.firstName, - // lastName: req.body.lastName, - // jobTitle: req.body.jobTitle, - // location: req.body.homeCountry, - // isActive: true, - // }; + const locationData = { + title: '', + firstName: req.body.firstName, + lastName: req.body.lastName, + jobTitle: req.body.jobTitle, + location: req.body.homeCountry, + isActive: true, + }; - res.status(200).send({ token: jwtToken }); + res.status(200).send({ token }); - // const mapEntryResult = await setMapLocation(locationData); - // if (mapEntryResult.type === 'Error') { - // console.log(mapEntryResult.message); - // } + const mapEntryResult = await setMapLocation(locationData); + if (mapEntryResult.type === 'Error') { + console.log(mapEntryResult.message); + } const NewUserCache = { permissions: savedUser.permissions, @@ -536,7 +537,7 @@ const profileInitialSetupController = function ( const { role } = req.body.requestor; const { permissions } = req.body.requestor; - const userPermissions = [ + const user_permissions = [ 'searchUserProfile', 'getUserProfiles', 'postUserProfile', @@ -548,7 +549,7 @@ const profileInitialSetupController = function ( role === 'Owner' || role === 'Manager' || role === 'Mentor' || - userPermissions.some((e) => permissions.frontPermissions.includes(e)) + user_permissions.some((e) => permissions.frontPermissions.includes(e)) ) { try { ProfileInitialSetupToken.find({ isSetupCompleted: false }) diff --git a/src/controllers/taskController.js b/src/controllers/taskController.js index 04e5aab222..7fbba100de 100644 --- a/src/controllers/taskController.js +++ b/src/controllers/taskController.js @@ -1280,12 +1280,7 @@ const taskController = function (Task) { }; const updateTaskStatus = async (req, res) => { - if ( - !(await hasPermission( - req.body.requestor, - 'viewAndInteractWithTaskDeadlinesBoxes', - )) - ) { + if (!(await hasPermission(req.body.requestor, 'viewAndInteractWithTaskDeadlinesBoxes'))) { return res.status(403).send({ error: 'You are not authorized to update task deadline status.', }); diff --git a/src/controllers/titleController.js b/src/controllers/titleController.js index aae569a374..b484ffd8ed 100644 --- a/src/controllers/titleController.js +++ b/src/controllers/titleController.js @@ -1,11 +1,11 @@ const mongoose = require('mongoose'); +const Team = require('../models/team'); const Project = require('../models/project'); const cacheClosure = require('../utilities/nodeCache'); const userProfile = require('../models/userProfile'); -const projectModel = require('../models/project'); const userProfileController = require('./userProfileController'); -const controller = userProfileController(userProfile, projectModel); +const controller = userProfileController(userProfile, Project); const { getAllTeamCodeHelper } = controller; @@ -150,20 +150,10 @@ const titlecontroller = function (Title) { return; } - // title - // .save() - // .then((results) => res.status(200).send(results)) - // .catch((error) => res.status(404).send(error)); - - try { - const savedTitle = await title.save(); - - await userProfile.updateMany({}, { $addToSet: { teamCodes: title.teamCode } }); - - res.status(200).send(savedTitle); - } catch (error) { - res.status(500).send(error); - } + title + .save() + .then((results) => res.status(200).send(results)) + .catch((error) => res.status(404).send(error)); }; const updateTitlesOrder = async function (req, res) { diff --git a/src/controllers/titleController.test.js b/src/controllers/titleController.test.js index ddc034edd7..75b6ce1023 100644 --- a/src/controllers/titleController.test.js +++ b/src/controllers/titleController.test.js @@ -1,17 +1,41 @@ -const titleController = require('./titleController'); - -// Mock dependencies +// Mock dependencies. These must be declared before requiring titleController: +// titleController captures userProfileController's return value in a +// module-scope closure at require-time, so if the require happens first, +// it silently binds to the real (unmocked) implementation instead. jest.mock('../models/team'); jest.mock('../models/project'); jest.mock('../utilities/nodeCache'); jest.mock('./userProfileController'); jest.mock('../models/userProfile'); +// titleController calls userProfileController(...) once, at module top level, +// and keeps that single return value in a closure for the lifetime of the +// process — it is NOT re-invoked per `titleController(Title)` instance. So the +// mock object it captures must exist (and be wired up) before titleController +// is required, and must stay the same object for the rest of the file; only +// its `getAllTeamCodeHelper` jest.fn() implementation gets reconfigured +// per-test. Recreating this object in `beforeEach` (as if it were per-instance +// state like mockCache) would go unnoticed by titleController's closure. +const userProfileControllerFactory = require('./userProfileController'); + +const mockUserProfileController = { + getAllTeamCodeHelper: jest.fn(), +}; +userProfileControllerFactory.mockReturnValue(mockUserProfileController); + +const titleController = require('./titleController'); + +// Flushes pending microtasks; needed for code paths that fire off a +// `.then()/.catch()` chain without awaiting or returning it. +const flushPromises = () => + new Promise((resolve) => { + setImmediate(resolve); + }); + describe('TitleController', () => { let mockTitle; let mockProject; let mockCache; - let mockUserProfileController; let mockUserProfile; let controller; let mockReq; @@ -33,9 +57,14 @@ describe('TitleController', () => { // Mock Project model mockProject = { - findOne: jest.fn().mockReturnThis(), + findOne: jest.fn(), exec: jest.fn(), }; + // `Project.findOne` below is reassigned onto the real (mocked) `Project` + // module object, so `mockReturnThis()` would resolve `this` to that + // object rather than `mockProject`, breaking the `.exec` chain. Return + // `mockProject` explicitly instead so it works regardless of call-site. + mockProject.findOne.mockReturnValue(mockProject); // Mock cache mockCache = { @@ -43,11 +72,6 @@ describe('TitleController', () => { removeCache: jest.fn(), }; - // Mock userProfileController - mockUserProfileController = { - getAllTeamCodeHelper: jest.fn(), - }; - // Mock userProfile model mockUserProfile = { updateMany: jest.fn(), @@ -60,9 +84,6 @@ describe('TitleController', () => { const cacheClosure = require('../utilities/nodeCache'); cacheClosure.mockReturnValue(mockCache); - const userProfileController = require('./userProfileController'); - userProfileController.mockReturnValue(mockUserProfileController); - const userProfile = require('../models/userProfile'); userProfile.updateMany = mockUserProfile.updateMany; @@ -84,6 +105,24 @@ describe('TitleController', () => { // Only the 17 passing tests describe('getAllTitles', () => { + it('should return all titles sorted by order', async () => { + const mockTitlesData = [ + { _id: '1', titleName: 'Title 1', order: 1 }, + { _id: '2', titleName: 'Title 2', order: 2 }, + ]; + mockTitle.find().sort().then = jest.fn((onFulfilled) => { + onFulfilled(mockTitlesData); + return Promise.resolve(); + }); + + await controller.getAllTitles(mockReq, mockRes); + + expect(mockTitle.find).toHaveBeenCalledWith({}); + expect(mockTitle.sort).toHaveBeenCalledWith('order'); + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.send).toHaveBeenCalledWith(mockTitlesData); + }); + it('should handle error when fetching titles fails', async () => { const error = new Error('Database error'); const mockPromise = Promise.reject(error); @@ -107,13 +146,24 @@ describe('TitleController', () => { expect(mockTitle.findById).toHaveBeenCalledWith('123'); expect(mockRes.send).toHaveBeenCalledWith(mockTitleData); }); + + it('should handle error when fetching title by ID fails', async () => { + const error = new Error('Not found'); + mockTitle.findById.mockRejectedValue(error); + mockReq.params.titleId = 'nonexistent'; + + await controller.getTitleById(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.send).toHaveBeenCalledWith(error); + }); }); describe('postTitle', () => { beforeEach(() => { // Mock successful validation mockUserProfileController.getAllTeamCodeHelper.mockResolvedValue(['TEAM1', 'TEAM2']); - mockProject.findOne().exec.mockResolvedValue({ _id: 'project123' }); + mockProject.findOne().exec.mockResolvedValue({ _id: '507f1f77bcf86cd799439011' }); }); it('should return error for empty title code', async () => { @@ -121,7 +171,7 @@ describe('TitleController', () => { titleName: 'Software Engineer', titleCode: '', teamCode: 'TEAM1', - projectAssigned: { _id: 'project123' }, + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, mediaFolder: '/media/engineer', teamAssiged: { _id: 'team123' }, }; @@ -152,7 +202,7 @@ describe('TitleController', () => { titleName: 'Software Engineer', titleCode: '123', teamCode: 'TEAM1', - projectAssigned: { _id: 'project123' }, + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, mediaFolder: '/media/engineer', teamAssiged: { _id: 'team123' }, }; @@ -182,7 +232,7 @@ describe('TitleController', () => { titleName: '', titleCode: 'SE', teamCode: 'TEAM1', - projectAssigned: { _id: 'project123' }, + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, mediaFolder: '/media/engineer', teamAssiged: { _id: 'team123' }, }; @@ -212,7 +262,7 @@ describe('TitleController', () => { titleName: 'Software Engineer', titleCode: 'SE', teamCode: 'TEAM1', - projectAssigned: { _id: 'project123' }, + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, mediaFolder: '', teamAssiged: { _id: 'team123' }, }; @@ -242,7 +292,7 @@ describe('TitleController', () => { titleName: 'Software Engineer', titleCode: 'SE', teamCode: null, - projectAssigned: { _id: 'project123' }, + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, mediaFolder: '/media/engineer', teamAssiged: { _id: 'team123' }, }; @@ -266,6 +316,138 @@ describe('TitleController', () => { message: 'Please provide a team code.', }); }); + + it('should return error for invalid team code', async () => { + mockReq.body = { + titleName: 'Software Engineer', + titleCode: 'SE', + teamCode: 'INVALID', + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, + mediaFolder: '/media/engineer', + teamAssiged: { _id: 'team123' }, + }; + + const MockTitleConstructor = jest.fn(() => ({ save: jest.fn() })); + const testController = titleController(MockTitleConstructor); + + await testController.postTitle(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.send).toHaveBeenCalledWith({ + message: 'Invalid team code. Please provide a valid team code.', + }); + }); + + it('should use cached team codes instead of refetching when cache is warm', async () => { + mockCache.getCache.mockReturnValue(JSON.stringify(['TEAM1', 'TEAM2'])); + mockReq.body = { + titleName: 'Software Engineer', + titleCode: 'SE', + teamCode: 'TEAM1', + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, + mediaFolder: '/media/engineer', + teamAssiged: { _id: 'N/A' }, + }; + + const MockTitleConstructor = jest.fn(() => ({ save: jest.fn() })); + const testController = titleController(MockTitleConstructor); + + await testController.postTitle(mockReq, mockRes); + + expect(mockUserProfileController.getAllTeamCodeHelper).not.toHaveBeenCalled(); + // Team code passed cache validation, so it proceeds to the next check. + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.send).toHaveBeenCalledWith({ message: 'Team not exists.' }); + }); + + it('should return error when project does not exist', async () => { + mockReq.body = { + titleName: 'Software Engineer', + titleCode: 'SE', + teamCode: 'TEAM1', + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, + mediaFolder: '/media/engineer', + teamAssiged: { _id: 'team123' }, + }; + mockProject.findOne().exec.mockResolvedValue(null); + + const MockTitleConstructor = jest.fn(() => ({ save: jest.fn() })); + const testController = titleController(MockTitleConstructor); + + await testController.postTitle(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.send).toHaveBeenCalledWith({ + message: 'Project is empty or not exist.', + }); + }); + + it('should return error when assigned team does not exist', async () => { + mockReq.body = { + titleName: 'Software Engineer', + titleCode: 'SE', + teamCode: 'TEAM1', + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, + mediaFolder: '/media/engineer', + teamAssiged: { _id: 'N/A' }, + }; + + const MockTitleConstructor = jest.fn(() => ({ save: jest.fn() })); + const testController = titleController(MockTitleConstructor); + + await testController.postTitle(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.send).toHaveBeenCalledWith({ message: 'Team not exists.' }); + }); + + it('should create title successfully with valid data', async () => { + mockReq.body = { + titleName: 'Software Engineer', + titleCode: 'SE', + teamCode: 'TEAM1', + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, + mediaFolder: '/media/engineer', + teamAssiged: { _id: 'team123' }, + }; + + const savedTitle = { _id: 'newTitle1', ...mockReq.body, shortName: 'S' }; + const mockSave = jest.fn().mockResolvedValue(savedTitle); + const MockTitleConstructor = jest.fn(() => ({ save: mockSave })); + + const testController = titleController(MockTitleConstructor); + + await testController.postTitle(mockReq, mockRes); + await flushPromises(); + + expect(mockSave).toHaveBeenCalled(); + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.send).toHaveBeenCalledWith(savedTitle); + }); + + it('should return 404 when saving the new title fails', async () => { + mockReq.body = { + titleName: 'Software Engineer', + titleCode: 'SE', + teamCode: 'TEAM1', + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, + mediaFolder: '/media/engineer', + teamAssiged: { _id: 'team123' }, + }; + + const saveError = new Error('Save failed'); + const MockTitleConstructor = jest.fn(() => ({ + save: jest.fn().mockRejectedValue(saveError), + })); + + const testController = titleController(MockTitleConstructor); + + await testController.postTitle(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(404); + expect(mockRes.send).toHaveBeenCalledWith(saveError); + }); }); describe('updateTitlesOrder', () => { @@ -308,13 +490,41 @@ describe('TitleController', () => { error, }); }); + + it('should handle error when refetching sorted titles fails after a successful update', async () => { + const error = new Error('Sort fetch failed'); + mockReq.body = { orderData: [{ id: 'title1', order: 1 }] }; + + mockTitle.findByIdAndUpdate.mockResolvedValue({ _id: 'title1', order: 1 }); + mockTitle.sort.mockRejectedValueOnce(error); + + await controller.updateTitlesOrder(mockReq, mockRes); + + expect(mockTitle.findByIdAndUpdate).toHaveBeenCalledTimes(1); + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'Failed to update titles order', + error, + }); + }); + + it('should handle an empty orderData array without updating anything', async () => { + mockReq.body = { orderData: [] }; + mockTitle.find().sort.mockResolvedValue([]); + + await controller.updateTitlesOrder(mockReq, mockRes); + + expect(mockTitle.findByIdAndUpdate).not.toHaveBeenCalled(); + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith([]); + }); }); describe('updateTitle', () => { beforeEach(() => { // Mock successful validation mockUserProfileController.getAllTeamCodeHelper.mockResolvedValue(['TEAM1', 'TEAM2']); - mockProject.findOne().exec.mockResolvedValue({ _id: 'project123' }); + mockProject.findOne().exec.mockResolvedValue({ _id: '507f1f77bcf86cd799439011' }); }); it('should return error for empty title name during update', async () => { @@ -323,7 +533,7 @@ describe('TitleController', () => { titleName: '', titleCode: 'NT', teamCode: 'TEAM1', - projectAssigned: { _id: 'project123' }, + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, mediaFolder: '/new/media', teamAssiged: { _id: 'team123' }, }; @@ -342,7 +552,7 @@ describe('TitleController', () => { titleName: 'New Title', titleCode: '', teamCode: 'TEAM1', - projectAssigned: { _id: 'project123' }, + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, mediaFolder: '/new/media', teamAssiged: { _id: 'team123' }, }; @@ -361,7 +571,7 @@ describe('TitleController', () => { titleName: 'New Title', titleCode: '123', teamCode: 'TEAM1', - projectAssigned: { _id: 'project123' }, + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, mediaFolder: '/new/media', teamAssiged: { _id: 'team123' }, }; @@ -380,7 +590,7 @@ describe('TitleController', () => { titleName: 'New Title', titleCode: 'NT', teamCode: 'TEAM1', - projectAssigned: { _id: 'project123' }, + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, mediaFolder: '', teamAssiged: { _id: 'team123' }, }; @@ -399,7 +609,7 @@ describe('TitleController', () => { titleName: 'New Title', titleCode: 'NT', teamCode: null, - projectAssigned: { _id: 'project123' }, + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, mediaFolder: '/new/media', teamAssiged: { _id: 'team123' }, }; @@ -411,6 +621,120 @@ describe('TitleController', () => { message: 'Please provide a team code.', }); }); + + it('should return error for invalid team code during update', async () => { + mockReq.body = { + id: 'title123', + titleName: 'New Title', + titleCode: 'NT', + teamCode: 'INVALID', + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, + mediaFolder: '/new/media', + teamAssiged: { _id: 'team123' }, + }; + + await controller.updateTitle(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.send).toHaveBeenCalledWith({ + message: 'Invalid team code. Please provide a valid team code.', + }); + }); + + it('should return error when project does not exist during update', async () => { + mockReq.body = { + id: 'title123', + titleName: 'New Title', + titleCode: 'NT', + teamCode: 'TEAM1', + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, + mediaFolder: '/new/media', + teamAssiged: { _id: 'team123' }, + }; + mockProject.findOne().exec.mockResolvedValue(null); + + await controller.updateTitle(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.send).toHaveBeenCalledWith({ + message: 'Project is empty or not exist.', + }); + }); + + it('should return error when assigned team does not exist during update', async () => { + mockReq.body = { + id: 'title123', + titleName: 'New Title', + titleCode: 'NT', + teamCode: 'TEAM1', + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, + mediaFolder: '/new/media', + teamAssiged: { _id: 'N/A' }, + }; + + await controller.updateTitle(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.send).toHaveBeenCalledWith({ message: 'Team not exists.' }); + }); + + it('should update title successfully with valid data', async () => { + mockReq.body = { + id: 'title123', + titleName: 'New Title', + titleCode: 'NT', + teamCode: 'TEAM2', + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, + mediaFolder: '/new/media', + teamAssiged: { _id: 'team123' }, + }; + + const mockResultDoc = { + teamCode: 'TEAM1', + save: jest.fn().mockResolvedValue(true), + }; + mockTitle.findById.mockResolvedValue(mockResultDoc); + mockUserProfile.updateMany.mockResolvedValue({}); + + await controller.updateTitle(mockReq, mockRes); + + expect(mockTitle.findById).toHaveBeenCalledWith('title123'); + expect(mockResultDoc.titleName).toBe('New Title'); + expect(mockResultDoc.teamCode).toBe('TEAM2'); + expect(mockResultDoc.save).toHaveBeenCalled(); + expect(mockUserProfile.updateMany).toHaveBeenCalledWith( + { teamCode: 'TEAM1' }, + { $set: { teamCode: 'TEAM2' } }, + ); + expect(mockCache.removeCache).toHaveBeenCalledWith('teamCodes'); + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.send).toHaveBeenCalledWith({ + message: 'Update successful', + updatedTitle: mockResultDoc, + }); + }); + + it('should handle error during update', async () => { + mockReq.body = { + id: 'title123', + titleName: 'New Title', + titleCode: 'NT', + teamCode: 'TEAM1', + projectAssigned: { _id: '507f1f77bcf86cd799439011' }, + mediaFolder: '/new/media', + teamAssiged: { _id: 'team123' }, + }; + const error = new Error('Update failed'); + mockTitle.findById.mockRejectedValue(error); + + await controller.updateTitle(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockRes.send).toHaveBeenCalledWith({ + message: 'An error occurred', + error: error.message, + }); + }); }); describe('deleteTitleById', () => { @@ -424,6 +748,17 @@ describe('TitleController', () => { expect(mockTitle.deleteOne).toHaveBeenCalledWith({ _id: 'title123' }); expect(mockRes.send).toHaveBeenCalledWith(mockDeleteResult); }); + + it('should handle error when deleting title fails', async () => { + const error = new Error('Delete failed'); + mockTitle.deleteOne.mockRejectedValue(error); + mockReq.params.titleId = 'title123'; + + await controller.deleteTitleById(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.send).toHaveBeenCalledWith(error); + }); }); describe('deleteAllTitles', () => { @@ -449,5 +784,16 @@ describe('TitleController', () => { message: 'No titles found to delete.', }); }); + + it('should handle error when deleting all titles fails', async () => { + const error = new Error('Delete all failed'); + mockTitle.deleteMany.mockRejectedValue(error); + + await controller.deleteAllTitles(mockReq, mockRes); + await flushPromises(); + + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockRes.send).toHaveBeenCalledWith(error); + }); }); }); diff --git a/src/models/bmdashboard/buildingExpenditure.js b/src/models/bmdashboard/buildingExpenditure.js new file mode 100644 index 0000000000..c443e3ec03 --- /dev/null +++ b/src/models/bmdashboard/buildingExpenditure.js @@ -0,0 +1,22 @@ +const { Schema } = require('mongoose'); + +const ExpenditureSchema = new Schema({ + projectId: { + type: String, + required: true, + }, + date: { + type: Date, + required: true, + }, + category: { + type: String, + required: true, + }, + cost: { + type: Number, + required: true, + }, +}); + +module.exports = require('mongoose').model('ExpenditureCost', ExpenditureSchema, 'expenditure'); diff --git a/src/routes/bmdashboard/bmExpenditureRouter.js b/src/routes/bmdashboard/bmExpenditureRouter.js index a5cbb7160b..8cbea35c0b 100644 --- a/src/routes/bmdashboard/bmExpenditureRouter.js +++ b/src/routes/bmdashboard/bmExpenditureRouter.js @@ -1,12 +1,18 @@ const express = require('express'); - -const router = express.Router(); +const controller = require('../../controllers/bmdashboard/bmExpenditureController'); const { getProjectExpensesPie, getProjectIdsWithExpenditure, } = require('../../controllers/bmdashboard/expenditureController'); -router.get('/expenditure/projects', getProjectIdsWithExpenditure); -router.get('/expenditure/:projectId/pie', getProjectExpensesPie); +const routes = function () { + const newExpenditureRouter = express.Router(); + + newExpenditureRouter.route('/expenditure').get(controller.getAllExpenditure); + newExpenditureRouter.get('/expenditure/projects', getProjectIdsWithExpenditure); + newExpenditureRouter.get('/expenditure/:projectId/pie', getProjectExpensesPie); + + return newExpenditureRouter; +}; -module.exports = router; +module.exports = routes; diff --git a/src/routes/bmdashboard/projectCostBreakdownRouter.js b/src/routes/bmdashboard/projectCostBreakdownRouter.js new file mode 100644 index 0000000000..c5bb414f61 --- /dev/null +++ b/src/routes/bmdashboard/projectCostBreakdownRouter.js @@ -0,0 +1,13 @@ +const express = require('express'); +const controller = require('../../controllers/bmdashboard/bmExpenditureController'); + +const routes = function () { + const router = express.Router(); + + router.get('/with-expenditure', controller.getProjectIdsWithExpenditure); + router.get('/:id/cost-breakdown', controller.getCostBreakdown); + + return router; +}; + +module.exports = routes; diff --git a/src/startup/routes.js b/src/startup/routes.js index b8fd1997c1..166f95ff38 100644 --- a/src/startup/routes.js +++ b/src/startup/routes.js @@ -240,7 +240,8 @@ const bmMaterialInsightsRouter = require('../routes/bmdashboard/bmMaterialInsigh const bmReusableRouter = require('../routes/bmdashboard/bmReusableRouter')(buildingReusable); const bmProjectRouter = require('../routes/bmdashboard/bmProjectRouter')(buildingProject); const bmOrgLocation = require('../routes/bmdashboard/bmOrgLocationRouter')(); -const bmExpenditureRouter = require('../routes/bmdashboard/bmExpenditureRouter'); +const bmExpenditureRouter = require('../routes/bmdashboard/bmExpenditureRouter')(); +const projectCostBreakdownRouter = require('../routes/bmdashboard/projectCostBreakdownRouter')(); const bmNewLessonRouter = require('../routes/bmdashboard/bmNewLessonRouter')(buildingNewLesson); const injuryCategoryRoutes = require('../routes/bmdashboard/injuryCategoryRouter'); @@ -601,6 +602,7 @@ module.exports = function (app) { app.use('/api/bm', bmEquipmentRouter); app.use('/api/bm', bmConsumablesRouter); app.use('/api/bm', bmExpenditureRouter); + app.use('/api/projects', projectCostBreakdownRouter); app.use('/api/bm', bmUpdateHistoryRouter); app.use('/api/dropbox', dropboxRouter); app.use('/api/github', githubRouter); @@ -618,7 +620,11 @@ module.exports = function (app) { //app.use('api', bmIssueRouter); app.use('/api', bmToolStoppageReasonRouter); app.use('/api', costBreakdownRouter); - app.use('/api', toolAvailabilityRouter); + app.use('/api', bmProjectRiskProfileRouter); + + app.use('/api/bm', bmTimeLoggerRouter); + app.use('/api/bm/injuries', injuryCategoryRoutes); + app.use('/api', toolUtilizationRouter); // lb dashboard app.use('/api', toolAvailabilityRouter); @@ -630,14 +636,11 @@ module.exports = function (app) { app.use('/api/bm', bmIssueRouter); app.use('/api/bm', bmDashboardRouter); app.use('/api/bm', bmActualVsPlannedCostRouter); - app.use('/api/bm', bmTimeLoggerRouter); - app.use('/api/bm/injuries', injuryCategoryRoutes); - app.use('/api', projectCostTrackingRouter); + app.use('/api/bm', bmOrgLocation); - app.use('/api/bm', bmIssueRouter); + app.use('/api/labor-cost', bmPaidLaborCostRouter); app.use('/api/bm', bmInjuryRouter); - app.use('/api', bmProjectRiskProfileRouter); app.use('/api/lb', bidPropertyRouter); app.use('/api/lb', userBidRouter); @@ -659,8 +662,7 @@ module.exports = function (app) { app.use('/api/lbdashboard/bookings', bookingRouter); app.use('/api/lb', biddingRouter); - app.use('/api', registrationRouter); - app.use('/api', projectMaterialRouter); + app.use('/api', plannedCostRouter(plannedCost, project)); // summary dashboard @@ -672,7 +674,7 @@ module.exports = function (app) { app.use('/api/bm', bmRentalChart); app.use('/api', bmToolsDowntimeRouter); - app.use('/api/lb', lbWishlistsRouter); + app.use('/api', actualCostRouter); app.use('/api/userstate', userStateRouter); app.use('/api', promotionDetailsRouter); @@ -718,7 +720,6 @@ module.exports = function (app) { app.use('/api/', activityLogRouter); // Education Portal app.use('/api/educationportal/educator', educatorRoutes); - app.use('/api', materialCostRouter); app.use('/api/educator/report', studentReportRouter()); // education portal @@ -739,8 +740,6 @@ module.exports = function (app) { app.use('/api', materialCostRouter); - app.use('/api/lp', lessonPlanSubmissionRouter); - app.use('/api/kitchenandinventory/recipes', recipeRouter); app.use('/api/analytics', analyticsRouter);