diff --git a/src/controllers/bmdashboard/__tests__/bmMaterialsController.test.js b/src/controllers/bmdashboard/__tests__/bmMaterialsController.test.js index 0c7079ecf..db9edd5da 100644 --- a/src/controllers/bmdashboard/__tests__/bmMaterialsController.test.js +++ b/src/controllers/bmdashboard/__tests__/bmMaterialsController.test.js @@ -1,6 +1,63 @@ +// Mock utilities BEFORE requiring the controller +// Note: Paths are relative to the controller file, not the test file +jest.mock( + '../../../utilities/queryParamParser', + () => ({ + parseMultiSelectQueryParam: jest.fn(), + }), + { virtual: true }, +); +jest.mock( + '../../../utilities/materialCostCorrelationDateUtils', + () => ({ + parseAndNormalizeDateRangeUTC: jest.fn(), + normalizeStartDate: jest.fn(), + }), + { virtual: true }, +); +jest.mock( + '../../../utilities/materialCostCorrelationHelpers', + () => ({ + getEarliestRelevantMaterialDate: jest.fn(), + aggregateMaterialUsage: jest.fn(), + aggregateMaterialCost: jest.fn(), + buildCostCorrelationResponse: jest.fn(), + }), + { virtual: true }, +); +jest.mock( + '../../../startup/logger', + () => ({ + logException: jest.fn(), + }), + { virtual: true }, +); +jest.mock('../../../models/bmdashboard/buildingProject', () => ({}), { virtual: true }); +jest.mock( + '../../../models/bmdashboard/buildingInventoryType', + () => ({ + invTypeBase: {}, + }), + { virtual: true }, +); + const mongoose = require('mongoose'); -// const { MongoMemoryServer } = require('mongodb-memory-server'); Commenting this because it's never used const bmMaterialsController = require('../bmMaterialsController'); +// Get mocked functions - use paths relative to controller +const { + parseMultiSelectQueryParam: mockParseMultiSelectQueryParam, +} = require('../../../utilities/queryParamParser'); +const { + parseAndNormalizeDateRangeUTC: mockParseAndNormalizeDateRangeUTC, + normalizeStartDate: mockNormalizeStartDate, +} = require('../../../utilities/materialCostCorrelationDateUtils'); +const { + getEarliestRelevantMaterialDate: mockGetEarliestRelevantMaterialDate, + aggregateMaterialUsage: mockAggregateMaterialUsage, + aggregateMaterialCost: mockAggregateMaterialCost, + buildCostCorrelationResponse: mockBuildCostCorrelationResponse, +} = require('../../../utilities/materialCostCorrelationHelpers'); +const { logException: mockLogException } = require('../../../startup/logger'); // Mock mongoose models const mockExec = jest.fn(); @@ -17,6 +74,10 @@ const mockFindOneAndUpdate = jest.fn(); const mockUpdateOne = jest.fn(); // Mock BuildingMaterial model +const mockAggregate = jest.fn().mockReturnValue({ + exec: jest.fn().mockResolvedValue([]), +}); + const BuildingMaterial = { find: mockFind, findOne: mockFindOne, @@ -25,6 +86,7 @@ const BuildingMaterial = { updateOne: mockUpdateOne, populate: mockPopulate, exec: mockExec, + aggregate: mockAggregate, }; // Reset all mocks before each test @@ -125,10 +187,15 @@ describe('bmMaterialsController', () => { await controller.bmPurchaseMaterials(req, res); expect(mockFindOne).toHaveBeenCalledWith({ - project: validProjectId, - itemType: validMatTypeId, + project: expect.any(mongoose.Types.ObjectId), + itemType: expect.any(mongoose.Types.ObjectId), }); - expect(mockCreate).toHaveBeenCalled(); + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + project: expect.any(mongoose.Types.ObjectId), + itemType: expect.any(mongoose.Types.ObjectId), + }), + ); expect(res.status).toHaveBeenCalledWith(201); expect(res.send).toHaveBeenCalled(); }); @@ -140,12 +207,6 @@ describe('bmMaterialsController', () => { }; mockFindOne.mockResolvedValue(mockMaterial); - // Mock ObjectId.isValid to return true, and ObjectId constructor - mongoose.Types.ObjectId.isValid = jest.fn().mockReturnValue(true); - const originalObjectId = mongoose.Types.ObjectId; - mongoose.Types.ObjectId = jest.fn().mockReturnValue('507f1f77bcf86cd799439014'); - mongoose.Types.ObjectId.isValid = originalObjectId.isValid; - mockFindOneAndUpdate.mockReturnValue({ exec: jest.fn().mockReturnValue({ then: jest.fn().mockImplementation((callback) => { @@ -174,8 +235,8 @@ describe('bmMaterialsController', () => { await controller.bmPurchaseMaterials(req, res); expect(mockFindOne).toHaveBeenCalledWith({ - project: validProjectId, - itemType: validMatTypeId, + project: expect.any(mongoose.Types.ObjectId), + itemType: expect.any(mongoose.Types.ObjectId), }); expect(mockFindOneAndUpdate).toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(201); @@ -209,6 +270,8 @@ describe('bmMaterialsController', () => { }); describe('bmPostMaterialUpdateRecord', () => { + const validMaterialId = '507f1f77bcf86cd799439011'; + it('should update material stock and add update record', async () => { mockUpdateOne.mockReturnValue({ then(callback) { @@ -218,7 +281,7 @@ describe('bmMaterialsController', () => { }); const material = { - _id: 'material123', + _id: validMaterialId, stockAvailable: 100, stockUsed: 20, stockWasted: 10, @@ -242,14 +305,45 @@ describe('bmMaterialsController', () => { await controller.bmPostMaterialUpdateRecord(req, res); - expect(mockUpdateOne).toHaveBeenCalled(); + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: expect.any(mongoose.Types.ObjectId) }, + expect.any(Object), + ); expect(res.status).toHaveBeenCalledWith(200); expect(res.send).toHaveBeenCalled(); }); + it('should return 400 for invalid material._id', async () => { + const req = { + body: { + material: { + _id: 'not-valid-objectid', + stockAvailable: 100, + stockUsed: 0, + stockWasted: 0, + }, + quantityUsed: 5, + quantityWasted: 0, + QtyUsedLogUnit: 'unit', + QtyWastedLogUnit: 'unit', + }, + }; + const res = { + status: jest.fn().mockReturnThis(), + send: jest.fn(), + json: jest.fn().mockReturnThis(), + }; + await controller.bmPostMaterialUpdateRecord(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Invalid material ID format', field: 'material._id' }), + ); + expect(mockUpdateOne).not.toHaveBeenCalled(); + }); + it('should reject if stock quantities exceed available', async () => { const material = { - _id: 'material123', + _id: validMaterialId, stockAvailable: 10, stockUsed: 5, stockWasted: 2, @@ -315,12 +409,39 @@ describe('bmMaterialsController', () => { // expect(res.send).toHaveBeenCalledWith('Purchase approved successfully'); // }); + it('should return 400 if purchaseId is invalid', async () => { + const req = { + body: { + purchaseId: 'not-a-valid-objectid', + status: 'Approved', + quantity: 30, + }, + }; + const res = { + status: jest.fn().mockReturnThis(), + send: jest.fn(), + json: jest.fn().mockReturnThis(), + }; + + await controller.bmupdatePurchaseStatus(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Invalid purchase ID format', + field: 'purchaseId', + }), + ); + expect(mockFindOne).not.toHaveBeenCalled(); + }); + it('should return 404 if purchase not found', async () => { + const validPurchaseId = '507f1f77bcf86cd799439099'; mockFindOne.mockResolvedValue(null); const req = { body: { - purchaseId: 'nonexistent', + purchaseId: validPurchaseId, status: 'Approved', quantity: 30, }, @@ -332,20 +453,24 @@ describe('bmMaterialsController', () => { await controller.bmupdatePurchaseStatus(req, res); + expect(mockFindOne).toHaveBeenCalledWith({ + 'purchaseRecord._id': expect.any(mongoose.Types.ObjectId), + }); expect(res.status).toHaveBeenCalledWith(404); expect(res.send).toHaveBeenCalledWith('Purchase not found'); }); it('should reject if purchase is not in Pending status', async () => { + const validPurchaseId = '507f1f77bcf86cd7994390aa'; const mockMaterial = { - purchaseRecord: [{ _id: 'purchase123', status: 'Rejected' }], + purchaseRecord: [{ _id: validPurchaseId, status: 'Approved' }], }; mockFindOne.mockResolvedValue(mockMaterial); const req = { body: { - purchaseId: 'purchase123', + purchaseId: validPurchaseId, status: 'Approved', quantity: 30, }, @@ -363,4 +488,569 @@ describe('bmMaterialsController', () => { ); }); }); + + describe('bmGetMaterialCostCorrelation', () => { + let mockReq; + let mockRes; + const FIXED_NOW = new Date('2024-01-15T12:30:45.123Z'); + const DEFAULT_DATE_QUERY = { startDate: '2024-01-01', endDate: '2024-01-31' }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + jest.setSystemTime(FIXED_NOW); + + mockReq = { + method: 'GET', + path: '/api/bm/materials/cost-correlation', + query: {}, + }; + + mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + // Default mock implementations + mockParseMultiSelectQueryParam.mockImplementation((req, param, requireObjectId) => { + if (param === 'projectId') { + return req.query && req.query.projectId ? [req.query.projectId] : []; + } + if (param === 'materialType') { + return req.query && req.query.materialType ? [req.query.materialType] : []; + } + return []; + }); + + mockParseAndNormalizeDateRangeUTC.mockResolvedValue({ + effectiveStart: new Date('2024-01-01T00:00:00.000Z'), + effectiveEnd: new Date('2024-01-31T23:59:59.999Z'), + defaultsApplied: { startDate: false, endDate: false }, + endCappedToNowMinus5Min: false, + originalInputs: { startDateInput: '2024-01-01', endDateInput: '2024-01-31' }, + }); + + mockGetEarliestRelevantMaterialDate.mockResolvedValue(new Date('2024-01-01T00:00:00.000Z')); + mockNormalizeStartDate.mockImplementation((date) => { + const d = new Date(date); + d.setUTCHours(0, 0, 0, 0); + return d; + }); + + mockAggregateMaterialUsage.mockResolvedValue([ + { projectId: 'project1', materialTypeId: 'material1', quantityUsed: 100 }, + ]); + + mockAggregateMaterialCost.mockResolvedValue([ + { projectId: 'project1', materialTypeId: 'material1', totalCost: 5000 }, + ]); + + mockBuildCostCorrelationResponse.mockResolvedValue({ + meta: { + request: { projectIds: [], materialTypeIds: [] }, + range: { effectiveStart: '2024-01-01', effectiveEnd: '2024-01-31' }, + units: { currency: 'USD', costScale: { raw: 1, k: 1000 } }, + }, + data: [], + }); + + mockLogException.mockClear(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('Category 1: Successful Request Flow', () => { + it('should return 200 with correct response for complete flow with all parameters', async () => { + mockReq.query = { + projectId: '507f1f77bcf86cd799439011', + materialType: '507f1f77bcf86cd799439012', + startDate: '2024-01-01', + endDate: '2024-01-31', + }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockParseMultiSelectQueryParam).toHaveBeenCalledWith(mockReq, 'projectId', true); + expect(mockParseMultiSelectQueryParam).toHaveBeenCalledWith(mockReq, 'materialType', true); + expect(mockParseAndNormalizeDateRangeUTC).toHaveBeenCalled(); + expect(mockAggregateMaterialUsage).toHaveBeenCalled(); + expect(mockAggregateMaterialCost).toHaveBeenCalled(); + expect(mockBuildCostCorrelationResponse).toHaveBeenCalled(); + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalled(); + }); + + it('should compute default start date when not provided', async () => { + mockReq.query = { + projectId: '507f1f77bcf86cd799439011', + endDate: '2024-01-31', + }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockGetEarliestRelevantMaterialDate).toHaveBeenCalled(); + expect(mockParseAndNormalizeDateRangeUTC).toHaveBeenCalledWith( + undefined, + '2024-01-31', + expect.any(Date), + undefined, + ); + expect(mockRes.status).toHaveBeenCalledWith(200); + }); + + it('should use current date for end date when not provided', async () => { + mockReq.query = { + projectId: '507f1f77bcf86cd799439011', + startDate: '2024-01-01', + }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockParseAndNormalizeDateRangeUTC).toHaveBeenCalledWith( + '2024-01-01', + undefined, + undefined, + undefined, + ); + expect(mockRes.status).toHaveBeenCalledWith(200); + }); + + it('should use both defaults when neither date provided', async () => { + mockReq.query = { + projectId: '507f1f77bcf86cd799439011', + }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockGetEarliestRelevantMaterialDate).toHaveBeenCalled(); + expect(mockParseAndNormalizeDateRangeUTC).toHaveBeenCalled(); + expect(mockRes.status).toHaveBeenCalledWith(200); + }); + + it('should handle no filters (all projects/materials)', async () => { + mockReq.query = { ...DEFAULT_DATE_QUERY }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockAggregateMaterialUsage).toHaveBeenCalledWith( + BuildingMaterial, + { projectIds: [], materialTypeIds: [] }, + expect.any(Object), + ); + expect(mockRes.status).toHaveBeenCalledWith(200); + }); + }); + + describe('Category 2: Query Parameter Validation Errors', () => { + it('should return 400 for invalid projectId', async () => { + const error = { + type: 'OBJECTID_VALIDATION_ERROR', + message: 'Invalid ObjectId in projectId', + invalidValues: ['invalid-id'], + }; + mockParseMultiSelectQueryParam.mockImplementationOnce(() => { + throw error; + }); + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ error: error.message }); + // Validation errors are expected and not logged as exceptions + }); + + it('should return 400 for invalid materialType', async () => { + const error = { + type: 'OBJECTID_VALIDATION_ERROR', + message: 'Invalid ObjectId in materialType', + invalidValues: ['invalid-id'], + }; + mockParseMultiSelectQueryParam + .mockReturnValueOnce([]) // projectId succeeds + .mockImplementationOnce(() => { + throw error; + }); + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ error: error.message }); + }); + + it('should handle empty but valid parameters', async () => { + mockReq.query = { ...DEFAULT_DATE_QUERY }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockAggregateMaterialUsage).toHaveBeenCalledWith( + BuildingMaterial, + { projectIds: [], materialTypeIds: [] }, + expect.any(Object), + ); + }); + }); + + describe('Category 3: Date Parsing Errors', () => { + it('should return 422 for invalid start date format', async () => { + const error = { + type: 'DATE_PARSE_ERROR', + message: 'Invalid date format', + acceptedFormats: ['YYYY-MM-DD'], + }; + // parseAndNormalizeDateRangeUTC is synchronous, so we use mockImplementationOnce to throw + mockParseAndNormalizeDateRangeUTC.mockImplementationOnce(() => { + throw error; + }); + + mockReq.query = { startDate: 'invalid-date', endDate: '2024-01-31' }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(422); + expect(mockRes.json).toHaveBeenCalledWith({ error: error.message }); + // Validation errors are expected and not logged as exceptions + }); + + it('should return 422 for invalid end date format', async () => { + const error = { + type: 'DATE_PARSE_ERROR', + message: 'Invalid date format', + }; + // parseAndNormalizeDateRangeUTC is synchronous, so we use mockImplementationOnce to throw + mockParseAndNormalizeDateRangeUTC.mockImplementationOnce(() => { + throw error; + }); + + mockReq.query = { startDate: '2024-01-01', endDate: 'invalid-date' }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(422); + expect(mockRes.json).toHaveBeenCalledWith({ error: error.message }); + }); + + it('should return 400 for invalid date range (start after end)', async () => { + const error = { + type: 'DATE_RANGE_ERROR', + message: 'Start date must be less than or equal to end date', + }; + // parseAndNormalizeDateRangeUTC is synchronous, so we use mockImplementationOnce to throw + mockParseAndNormalizeDateRangeUTC.mockImplementationOnce(() => { + throw error; + }); + + mockReq.query = { startDate: '2024-01-31', endDate: '2024-01-01' }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ error: error.message }); + }); + }); + + describe('Category 4: Aggregation Errors', () => { + it('should return 500 when aggregateMaterialUsage fails', async () => { + const error = new Error('Database error'); + mockAggregateMaterialUsage.mockRejectedValueOnce(error); + + mockReq.query = { ...DEFAULT_DATE_QUERY }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Internal server error while aggregating material data', + }); + expect(mockLogException).toHaveBeenCalledWith( + error, + 'bmGetMaterialCostCorrelation - aggregation', + expect.any(Object), + ); + }); + + it('should return 500 when aggregateMaterialCost fails', async () => { + const error = new Error('Database error'); + mockAggregateMaterialCost.mockRejectedValueOnce(error); + + mockReq.query = { ...DEFAULT_DATE_QUERY }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Internal server error while aggregating material data', + }); + }); + + it('should handle both aggregations failing', async () => { + const error = new Error('Database error'); + mockAggregateMaterialUsage.mockRejectedValueOnce(error); + mockAggregateMaterialCost.mockRejectedValueOnce(error); + + mockReq.query = { ...DEFAULT_DATE_QUERY }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(500); + }); + }); + + describe('Category 5: Response Building Errors', () => { + it('should return 500 when buildCostCorrelationResponse fails', async () => { + const error = new Error('Response building error'); + mockBuildCostCorrelationResponse.mockRejectedValueOnce(error); + + mockReq.query = { + startDate: '2024-01-01', + endDate: '2024-01-31', + }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Internal server error while building response', + }); + expect(mockLogException).toHaveBeenCalledWith( + error, + 'bmGetMaterialCostCorrelation - response building', + expect.any(Object), + ); + }); + }); + + describe('Category 6: Default Date Computation', () => { + it('should use earliest date when found', async () => { + const earliestDate = new Date('2023-06-01T00:00:00.000Z'); + mockGetEarliestRelevantMaterialDate.mockResolvedValueOnce(earliestDate); + + mockReq.query = { + endDate: '2024-01-31', + }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockGetEarliestRelevantMaterialDate).toHaveBeenCalled(); + expect(mockParseAndNormalizeDateRangeUTC).toHaveBeenCalledWith( + undefined, + '2024-01-31', + earliestDate, + undefined, + ); + expect(mockRes.status).toHaveBeenCalledWith(200); + }); + + it('should use today as fallback when no earliest date found', async () => { + mockGetEarliestRelevantMaterialDate.mockResolvedValueOnce(null); + + mockReq.query = { + endDate: '2024-01-31', + }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockGetEarliestRelevantMaterialDate).toHaveBeenCalled(); + expect(mockNormalizeStartDate).toHaveBeenCalled(); + expect(mockParseAndNormalizeDateRangeUTC).toHaveBeenCalled(); + expect(mockRes.status).toHaveBeenCalledWith(200); + }); + + it('should handle earliest date computation error gracefully', async () => { + // When getEarliestRelevantMaterialDate throws, it's caught by the global catch block + const error = new Error('DB error'); + mockGetEarliestRelevantMaterialDate.mockRejectedValueOnce(error); + + mockReq.query = { + endDate: '2024-01-31', + }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + // Error should be caught by global catch and return 500 + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockRes.json).toHaveBeenCalledWith({ error: 'Internal server error' }); + expect(mockLogException).toHaveBeenCalledWith( + error, + 'bmGetMaterialCostCorrelation - unexpected error', + expect.any(Object), + ); + }); + }); + + describe('Category 7: Parallel Aggregation Execution', () => { + it('should execute both aggregations in parallel', async () => { + mockReq.query = { + startDate: '2024-01-01', + endDate: '2024-01-31', + }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockAggregateMaterialUsage).toHaveBeenCalled(); + expect(mockAggregateMaterialCost).toHaveBeenCalled(); + // Both should be called with same filters and dateRange + const usageCall = mockAggregateMaterialUsage.mock.calls[0]; + const costCall = mockAggregateMaterialCost.mock.calls[0]; + expect(usageCall[1]).toEqual(costCall[1]); // filters + expect(usageCall[2]).toEqual(costCall[2]); // dateRange + }); + }); + + describe('Category 8: Response Structure Validation', () => { + it('should return response with correct structure', async () => { + const mockResponse = { + meta: { + request: { projectIds: [], materialTypeIds: [] }, + range: { effectiveStart: '2024-01-01', effectiveEnd: '2024-01-31' }, + units: { currency: 'USD', costScale: { raw: 1, k: 1000 } }, + }, + data: [ + { + projectId: 'project1', + projectName: 'Project 1', + totals: { quantityUsed: 100, totalCost: 5000, totalCostK: 5, costPerUnit: 50 }, + byMaterialType: [], + }, + ], + }; + mockBuildCostCorrelationResponse.mockResolvedValueOnce(mockResponse); + + mockReq.query = { + startDate: '2024-01-01', + endDate: '2024-01-31', + }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockRes.json).toHaveBeenCalledWith(mockResponse); + expect(mockResponse.meta).toBeDefined(); + expect(Array.isArray(mockResponse.data)).toBe(true); + }); + }); + + describe('Category 9: Edge Cases', () => { + it('should handle empty results gracefully', async () => { + mockAggregateMaterialUsage.mockResolvedValueOnce([]); + mockAggregateMaterialCost.mockResolvedValueOnce([]); + mockBuildCostCorrelationResponse.mockResolvedValueOnce({ + meta: {}, + data: [], + }); + + mockReq.query = { + startDate: '2024-01-01', + endDate: '2024-01-31', + }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalled(); + }); + + it('should handle missing req.query gracefully', async () => { + mockReq.query = undefined; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + // Should not throw, should handle gracefully + expect(mockParseMultiSelectQueryParam).toHaveBeenCalled(); + }); + }); + + describe('Category 10: Logging Verification', () => { + it('should NOT log validation errors as exceptions (query param errors)', async () => { + const error = { + type: 'OBJECTID_VALIDATION_ERROR', + message: 'Invalid ObjectId', + }; + mockParseMultiSelectQueryParam.mockImplementationOnce(() => { + throw error; + }); + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + // Validation errors should NOT be logged as exceptions + expect(mockLogException).not.toHaveBeenCalled(); + expect(mockRes.status).toHaveBeenCalledWith(400); + }); + + it('should NOT log validation errors as exceptions (date errors)', async () => { + const error = { + type: 'DATE_PARSE_ERROR', + message: 'Invalid date', + }; + // parseAndNormalizeDateRangeUTC is synchronous, so we use mockImplementationOnce to throw + mockParseAndNormalizeDateRangeUTC.mockImplementationOnce(() => { + throw error; + }); + + mockReq.query = { + startDate: 'invalid', + }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + // Validation errors should NOT be logged as exceptions + expect(mockLogException).not.toHaveBeenCalled(); + expect(mockRes.status).toHaveBeenCalledWith(422); + }); + + it('should log aggregation errors', async () => { + const error = new Error('Aggregation error'); + mockAggregateMaterialUsage.mockRejectedValueOnce(error); + + mockReq.query = { + startDate: '2024-01-01', + endDate: '2024-01-31', + }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockLogException).toHaveBeenCalledWith( + error, + 'bmGetMaterialCostCorrelation - aggregation', + expect.any(Object), + ); + }); + + it('should log response building errors', async () => { + const error = new Error('Response error'); + mockBuildCostCorrelationResponse.mockRejectedValueOnce(error); + + mockReq.query = { + startDate: '2024-01-01', + endDate: '2024-01-31', + }; + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockLogException).toHaveBeenCalledWith( + error, + 'bmGetMaterialCostCorrelation - response building', + expect.any(Object), + ); + }); + + it('should log unexpected errors', async () => { + const error = new Error('Unexpected error'); + mockParseMultiSelectQueryParam.mockImplementationOnce(() => { + throw error; + }); + + await controller.bmGetMaterialCostCorrelation(mockReq, mockRes); + + expect(mockLogException).toHaveBeenCalledWith( + error, + 'bmGetMaterialCostCorrelation - unexpected error', + expect.any(Object), + ); + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockRes.json).toHaveBeenCalledWith({ error: 'Internal server error' }); + }); + }); + }); }); diff --git a/src/controllers/bmdashboard/bmMaterialsController.js b/src/controllers/bmdashboard/bmMaterialsController.js index 59eb06385..a55dab244 100644 --- a/src/controllers/bmdashboard/bmMaterialsController.js +++ b/src/controllers/bmdashboard/bmMaterialsController.js @@ -1,5 +1,34 @@ const mongoose = require('mongoose'); - +const logger = require('../../startup/logger'); +const BuildingProject = require('../../models/bmdashboard/buildingProject'); +const { invTypeBase } = require('../../models/bmdashboard/buildingInventoryType'); +const { parseMultiSelectQueryParam } = require('../../utilities/queryParamParser'); +const { + parseAndNormalizeDateRangeUTC, + normalizeStartDate, +} = require('../../utilities/materialCostCorrelationDateUtils'); +const { + getEarliestRelevantMaterialDate, + aggregateMaterialUsage, + aggregateMaterialCost, + buildCostCorrelationResponse, + resolveProjectNamesToIds, + resolveMaterialNamesToIds, +} = require('../../utilities/materialCostCorrelationHelpers'); + +// HTTP status codes +const HTTP_STATUS_BAD_REQUEST = 400; +const HTTP_STATUS_UNPROCESSABLE_ENTITY = 422; +const HTTP_STATUS_INTERNAL_SERVER_ERROR = 500; + +// Decimal precision for quantity calculations +const DECIMAL_PRECISION = 4; + +// Time period constants (days) +const DAYS_IN_WEEK = 7; +const DAYS_IN_TWO_WEEKS = 14; + +// eslint-disable-next-line max-lines-per-function const bmMaterialsController = function (BuildingMaterial) { const bmMaterialsList = async function _matsList(req, res) { try { @@ -36,142 +65,87 @@ const bmMaterialsController = function (BuildingMaterial) { } }; - const bmPurchaseMaterials = async function (req, res) { + /** @returns {{ status: number, message: string, field: string }|null} Validation error or null if valid. */ + const validatePurchaseMaterialsBody = function (body) { const { primaryId: projectId, secondaryId: matTypeId, quantity, priority, - brand: brandPref, requestor: { requestorId } = {}, - } = req.body; - - try { - // Validation: Check required fields - if (!projectId) { - return res.status(400).json({ - message: 'Project is required', - field: 'projectId', - }); - } - - if (!matTypeId) { - return res.status(400).json({ - message: 'Material is required', - field: 'matTypeId', - }); - } - - if (!quantity && quantity !== 0) { - return res.status(400).json({ - message: 'Quantity is required', - field: 'quantity', - }); - } - - if (!priority) { - return res.status(400).json({ - message: 'Priority is required', - field: 'priority', - }); - } - - if (!requestorId) { - return res.status(400).json({ - message: 'Requestor information is required', - field: 'requestorId', - }); - } - - // Validation: Validate ObjectIds - if (!mongoose.Types.ObjectId.isValid(projectId)) { - return res.status(400).json({ - message: 'Invalid project ID format', - field: 'projectId', - }); - } - - if (!mongoose.Types.ObjectId.isValid(matTypeId)) { - return res.status(400).json({ - message: 'Invalid material ID format', - field: 'matTypeId', - }); - } - - if (!mongoose.Types.ObjectId.isValid(requestorId)) { - return res.status(400).json({ - message: 'Invalid requestor ID format', - field: 'requestorId', - }); - } - - // Validation: Validate quantity - const quantityNum = Number(quantity); - if (Number.isNaN(quantityNum)) { - return res.status(400).json({ - message: 'Quantity must be a valid number', - field: 'quantity', - }); - } - - if (quantityNum <= 0) { - return res.status(400).json({ - message: 'Quantity must be greater than 0', - field: 'quantity', - }); - } - - // Validation: Validate priority - const validPriorities = ['Low', 'Medium', 'High']; - if (!validPriorities.includes(priority)) { - return res.status(400).json({ - message: 'Priority must be one of: Low, Medium, High', - field: 'priority', - }); - } + } = body || {}; + if (!projectId) return { status: 400, message: 'Project is required', field: 'projectId' }; + if (!matTypeId) return { status: 400, message: 'Material is required', field: 'matTypeId' }; + if (!quantity && quantity !== 0) + return { status: 400, message: 'Quantity is required', field: 'quantity' }; + if (!priority) return { status: 400, message: 'Priority is required', field: 'priority' }; + if (!requestorId) + return { status: 400, message: 'Requestor information is required', field: 'requestorId' }; + if (!mongoose.Types.ObjectId.isValid(projectId)) + return { status: 400, message: 'Invalid project ID format', field: 'projectId' }; + if (!mongoose.Types.ObjectId.isValid(matTypeId)) + return { status: 400, message: 'Invalid material ID format', field: 'matTypeId' }; + if (!mongoose.Types.ObjectId.isValid(requestorId)) + return { status: 400, message: 'Invalid requestor ID format', field: 'requestorId' }; + const quantityNum = Number(quantity); + if (Number.isNaN(quantityNum)) + return { status: 400, message: 'Quantity must be a valid number', field: 'quantity' }; + if (quantityNum <= 0) + return { status: 400, message: 'Quantity must be greater than 0', field: 'quantity' }; + const validPriorities = ['Low', 'Medium', 'High']; + if (!validPriorities.includes(priority)) + return { + status: 400, + message: 'Priority must be one of: Low, Medium, High', + field: 'priority', + }; + return null; + }; - // check if requestor has permission to make purchase request - //! Note: this code is disabled until permissions are added - // TODO: uncomment this code to execute auth check - // const { buildingManager: bmId } = await buildingProject.findById(projectId, 'buildingManager').exec(); - // if (bmId !== requestorId) { - // res.status(403).send({ message: 'You are not authorized to edit this record.' }); - // return; - // } - - // check if the material is already being used in the project - // if no, add a new document to the collection - // if yes, update the existing document - const newPurchaseRecord = { - quantity: quantityNum, - priority, - brandPref, - requestedBy: requestorId, + const performMaterialPurchase = async function (body, quantityNum, res) { + const projectObjectId = new mongoose.Types.ObjectId(body.primaryId); + const matTypeObjectId = new mongoose.Types.ObjectId(body.secondaryId); + const newPurchaseRecord = { + quantity: quantityNum, + priority: body.priority, + brandPref: body.brand, + requestedBy: body.requestor?.requestorId, + }; + const doc = await BuildingMaterial.findOne({ + project: projectObjectId, + itemType: matTypeObjectId, + }); + if (!doc) { + const newDoc = { + itemType: matTypeObjectId, + project: projectObjectId, + purchaseRecord: [newPurchaseRecord], + stockBought: quantityNum, }; - const doc = await BuildingMaterial.findOne({ - project: projectId, - itemType: matTypeId, - }); - if (!doc) { - const newDoc = { - itemType: matTypeId, - project: projectId, - purchaseRecord: [newPurchaseRecord], - stockBought: quantityNum, - }; - BuildingMaterial.create(newDoc) - .then(() => res.status(201).send()) - .catch((error) => res.status(500).send(error)); - return; - } - doc.stockBought += quantityNum; - BuildingMaterial.findOneAndUpdate( - { _id: mongoose.Types.ObjectId(doc._id) }, - { $push: { purchaseRecord: newPurchaseRecord } }, - ) - .exec() + return BuildingMaterial.create(newDoc) .then(() => res.status(201).send()) .catch((error) => res.status(500).send(error)); + } + return BuildingMaterial.findOneAndUpdate( + { _id: doc._id }, + { $push: { purchaseRecord: newPurchaseRecord } }, + ) + .exec() + .then(() => res.status(201).send()) + .catch((error) => res.status(500).send(error)); + }; + + const bmPurchaseMaterials = async function (req, res) { + const { body } = req; + try { + const validation = validatePurchaseMaterialsBody(body); + if (validation) { + return res + .status(validation.status) + .json({ message: validation.message, field: validation.field }); + } + const quantityNum = Number(body.quantity); + await performMaterialPurchase(body, quantityNum, res); } catch (error) { res.status(500).send(error); } @@ -179,14 +153,23 @@ const bmMaterialsController = function (BuildingMaterial) { const bmPostMaterialUpdateRecord = function (req, res) { const payload = req.body; + const { material } = req.body; + if (!material || !mongoose.Types.ObjectId.isValid(material._id)) { + return res.status(400).json({ + message: 'Invalid material ID format', + field: 'material._id', + }); + } + const materialObjectId = new mongoose.Types.ObjectId(material._id); let quantityUsed = +req.body.quantityUsed; let quantityWasted = +req.body.quantityWasted; - const { material } = req.body; if (payload.QtyUsedLogUnit === 'percent' && quantityWasted >= 0) { - quantityUsed = +((+quantityUsed / 100) * material.stockAvailable).toFixed(4); + quantityUsed = +((+quantityUsed / 100) * material.stockAvailable).toFixed(DECIMAL_PRECISION); } if (payload.QtyWastedLogUnit === 'percent' && quantityUsed >= 0) { - quantityWasted = +((+quantityWasted / 100) * material.stockAvailable).toFixed(4); + quantityWasted = +((+quantityWasted / 100) * material.stockAvailable).toFixed( + DECIMAL_PRECISION, + ); } if ( @@ -200,16 +183,17 @@ const bmMaterialsController = function (BuildingMaterial) { 'Please check the used and wasted stock values. Either individual values or their sum exceeds the total stock available.', ); } else { - let newStockUsed = +material.stockUsed + parseFloat(quantityUsed); - let newStockWasted = +material.stockWasted + parseFloat(quantityWasted); + let newStockUsed = +material.stockUsed + Number.parseFloat(quantityUsed); + let newStockWasted = +material.stockWasted + Number.parseFloat(quantityWasted); let newAvailable = - +material.stockAvailable - parseFloat(quantityUsed) - parseFloat(quantityWasted); - newStockUsed = parseFloat(newStockUsed.toFixed(4)); - newStockWasted = parseFloat(newStockWasted.toFixed(4)); - newAvailable = parseFloat(newAvailable.toFixed(4)); + +material.stockAvailable - + Number.parseFloat(quantityUsed) - + Number.parseFloat(quantityWasted); + newStockUsed = Number.parseFloat(newStockUsed.toFixed(DECIMAL_PRECISION)); + newStockWasted = Number.parseFloat(newStockWasted.toFixed(DECIMAL_PRECISION)); + newAvailable = Number.parseFloat(newAvailable.toFixed(DECIMAL_PRECISION)); BuildingMaterial.updateOne( - { _id: req.body.material._id }, - + { _id: materialObjectId }, { $set: { stockUsed: newStockUsed, @@ -243,23 +227,31 @@ const bmMaterialsController = function (BuildingMaterial) { let quantityWasted = +payload.quantityWasted; const { material } = payload; if (payload.QtyUsedLogUnit === 'percent' && quantityWasted >= 0) { - quantityUsed = +((+quantityUsed / 100) * material.stockAvailable).toFixed(4); + quantityUsed = +((+quantityUsed / 100) * material.stockAvailable).toFixed( + DECIMAL_PRECISION, + ); } if (payload.QtyWastedLogUnit === 'percent' && quantityUsed >= 0) { - quantityWasted = +((+quantityWasted / 100) * material.stockAvailable).toFixed(4); + quantityWasted = +((+quantityWasted / 100) * material.stockAvailable).toFixed( + DECIMAL_PRECISION, + ); } let newStockUsed = +material.stockUsed + parseFloat(quantityUsed); let newStockWasted = +material.stockWasted + parseFloat(quantityWasted); let newAvailable = +material.stockAvailable - parseFloat(quantityUsed) - parseFloat(quantityWasted); - newStockUsed = parseFloat(newStockUsed.toFixed(4)); - newStockWasted = parseFloat(newStockWasted.toFixed(4)); - newAvailable = parseFloat(newAvailable.toFixed(4)); + newStockUsed = parseFloat(newStockUsed.toFixed(DECIMAL_PRECISION)); + newStockWasted = parseFloat(newStockWasted.toFixed(DECIMAL_PRECISION)); + newAvailable = parseFloat(newAvailable.toFixed(DECIMAL_PRECISION)); if (newAvailable < 0) { errorFlag = true; break; } + if (!mongoose.Types.ObjectId.isValid(material._id)) { + errorFlag = true; + break; + } updateRecordsToBeAdded.push({ updateId: material._id, set: { @@ -281,15 +273,16 @@ const bmMaterialsController = function (BuildingMaterial) { res.status(500).send('Stock quantities submitted seems to be invalid'); return; } - const updatePromises = updateRecordsToBeAdded.map((updateItem) => - BuildingMaterial.updateOne( - { _id: updateItem.updateId }, + const updatePromises = updateRecordsToBeAdded.map((updateItem) => { + const materialObjectId = new mongoose.Types.ObjectId(updateItem.updateId); + return BuildingMaterial.updateOne( + { _id: materialObjectId }, { $set: updateItem.set, $push: { updateRecord: updateItem.updateValue }, }, - ).exec(), - ); + ).exec(); + }); Promise.all(updatePromises) .then((results) => { res.status(200).send({ @@ -305,7 +298,11 @@ const bmMaterialsController = function (BuildingMaterial) { const bmupdatePurchaseStatus = async function (req, res) { const { purchaseId, status, quantity } = req.body; try { - const material = await BuildingMaterial.findOne({ 'purchaseRecord._id': purchaseId }); + if (!purchaseId || !mongoose.Types.ObjectId.isValid(purchaseId)) { + return res.status(400).json({ message: 'Invalid purchase ID format', field: 'purchaseId' }); + } + const purchaseObjectId = new mongoose.Types.ObjectId(purchaseId); + const material = await BuildingMaterial.findOne({ 'purchaseRecord._id': purchaseObjectId }); if (!material) { return res.status(404).send('Purchase not found'); @@ -334,7 +331,7 @@ const bmMaterialsController = function (BuildingMaterial) { } const updatedMaterial = await BuildingMaterial.findOneAndUpdate( - { 'purchaseRecord._id': purchaseId }, + { 'purchaseRecord._id': purchaseObjectId }, updateObject, { new: true }, ); @@ -358,13 +355,14 @@ const bmMaterialsController = function (BuildingMaterial) { } try { + const projectObjectId = new mongoose.Types.ObjectId(projectId); const query = { - project: mongoose.Types.ObjectId(projectId), + project: projectObjectId, }; if (materialType) { if (mongoose.Types.ObjectId.isValid(materialType)) { - query.itemType = mongoose.Types.ObjectId(materialType); + query.itemType = new mongoose.Types.ObjectId(materialType); } else { return res.status(400).json({ error: 'Invalid materialId' }); } @@ -381,9 +379,9 @@ const bmMaterialsController = function (BuildingMaterial) { const now = new Date(); const oneWeekAgo = new Date(); - oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); + oneWeekAgo.setDate(oneWeekAgo.getDate() - DAYS_IN_WEEK); const twoWeeksAgo = new Date(); - twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14); + twoWeeksAgo.setDate(twoWeeksAgo.getDate() - DAYS_IN_TWO_WEEKS); const nowStr = now.toISOString().split('T')[0]; const oneWeekAgoStr = oneWeekAgo.toISOString().split('T')[0]; @@ -444,117 +442,358 @@ const bmMaterialsController = function (BuildingMaterial) { increaseOverLastWeek: usageIncreasePercent, }); } catch (err) { - console.error('Error in bmGetMaterialSummaryByProject:', err); - res.status(500).json({ error: 'Internal Server Error' }); + logger.logException(err, 'bmGetMaterialSummaryByProject', { + method: req.method, + path: req.path, + params: req.params, + query: req.query, + }); + res.status(HTTP_STATUS_INTERNAL_SERVER_ERROR).json({ error: 'Internal Server Error' }); } }; - const bmGetMaterialStockOutRisk = async function (req, res) { + // eslint-disable-next-line max-lines-per-function + /** + * Compute default start date if startDateInput is not provided. + * Uses earliest relevant material date or falls back to today. + * + * @param {string|undefined} startDateInput - Start date input from query + * @param {string[]} projectIds - Project IDs for filtering + * @param {string[]} materialTypeIds - Material type IDs for filtering + * @returns {Promise} Default start date or undefined + */ + const computeDefaultStartDate = async function (startDateInput, projectIds, materialTypeIds) { + if (startDateInput && typeof startDateInput === 'string' && startDateInput.trim() !== '') { + return undefined; + } + + const earliestDate = await getEarliestRelevantMaterialDate( + projectIds, + materialTypeIds, + BuildingMaterial, + ); + + if (earliestDate) { + return earliestDate; + } + + // Fallback: today's start-of-day UTC + return normalizeStartDate(new Date(), true); + }; + + /** + * Handle date range parsing errors and return appropriate HTTP response. + * + * @param {Object} error - Error object from date parsing + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @returns {Object|undefined} Response object if error handled, undefined otherwise + */ + const handleDateRangeError = function (error, req, res) { + // Validation errors are expected and return proper HTTP responses - no need to log as exceptions + if (error.type === 'DATE_PARSE_ERROR') { + return res.status(HTTP_STATUS_UNPROCESSABLE_ENTITY).json({ error: error.message }); + } + if (error.type === 'DATE_RANGE_ERROR') { + return res.status(HTTP_STATUS_BAD_REQUEST).json({ error: error.message }); + } + return res.status(HTTP_STATUS_BAD_REQUEST).json({ error: error.message }); + }; + + /** + * Handle query parameter validation errors and return appropriate HTTP response. + * + * @param {Object} error - Error object from parameter validation + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @returns {Object|undefined} Response object if error handled, undefined otherwise + */ + const handleQueryParamError = function (error, req, res) { + // Validation errors are expected and return proper HTTP responses - no need to log as exceptions + if (error.type === 'OBJECTID_VALIDATION_ERROR' || error.type === 'NAME_RESOLUTION_ERROR') { + return res.status(HTTP_STATUS_BAD_REQUEST).json({ error: error.message }); + } + return undefined; + }; + + /** + * Extract and resolve query parameters (IDs and names) to ObjectId arrays. + * Handles both ID-based and name-based parameters, resolving names to IDs. + * + * @param {Object} req - Express request object + * @returns {Promise<{projectIds: string[], materialTypeIds: string[]}>} Resolved ID arrays + * @throws {Object} Structured error objects for validation/resolution failures + */ + const extractAndResolveQueryParams = async function (req) { + // Parse ID parameters (if provided) + const projectIdsFromParam = parseMultiSelectQueryParam(req, 'projectId', true); + const materialTypeIdsFromParam = parseMultiSelectQueryParam(req, 'materialType', true); + + // Parse name parameters (if provided, no ObjectId validation) + const projectNames = parseMultiSelectQueryParam(req, 'projectName', false); + const materialNames = parseMultiSelectQueryParam(req, 'materialName', false); + + let projectIds = projectIdsFromParam; + let materialTypeIds = materialTypeIdsFromParam; + + // Resolve names to IDs if provided + if (projectNames.length > 0) { + const resolvedProjectIds = await resolveProjectNamesToIds(projectNames, BuildingProject); + projectIds = [...projectIdsFromParam, ...resolvedProjectIds]; + } + + if (materialNames.length > 0) { + const resolvedMaterialIds = await resolveMaterialNamesToIds(materialNames, invTypeBase); + materialTypeIds = [...materialTypeIdsFromParam, ...resolvedMaterialIds]; + } + + // Remove duplicates from combined arrays + return { + projectIds: [...new Set(projectIds)], + materialTypeIds: [...new Set(materialTypeIds)], + }; + }; + + const bmGetMaterialCostCorrelation = async function (req, res) { try { - const { projectIds } = req.query || {}; - const query = {}; - - if (projectIds && projectIds !== 'all' && typeof projectIds === 'string') { - const projectIdArray = projectIds - .split(',') - .map((id) => id.trim()) - .filter((id) => id.length > 0); - - if (projectIdArray.length > 0) { - const validProjectIds = projectIdArray - .filter((id) => mongoose.Types.ObjectId.isValid(id)) - .map((id) => mongoose.Types.ObjectId(id)); - - if (validProjectIds.length > 0) { - query.project = { $in: validProjectIds }; - } else { - return res.status(400).json({ - error: 'Invalid project IDs provided', - details: 'All provided project IDs are invalid', - }); - } + // 1. Extract and parse query parameters (IDs and names) + let projectIds; + let materialTypeIds; + + try { + const resolvedParams = await extractAndResolveQueryParams(req); + projectIds = resolvedParams.projectIds; + materialTypeIds = resolvedParams.materialTypeIds; + } catch (error) { + const errorResponse = handleQueryParamError(error, req, res); + if (errorResponse) { + return errorResponse; } + throw error; } - const materials = await BuildingMaterial.find(query) - .populate('project', '_id name') - .populate('itemType', '_id name unit') - .lean() - .exec(); + // Extract date parameters as raw strings + const startDateInput = req.query.startDate; + const endDateInput = req.query.endDate; - const now = new Date(); - const thirtyDaysAgo = new Date(now); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - const daysInPeriod = 30; - const SENTINEL_NO_USAGE_DATA = 999; - - const stockOutRiskData = []; - - for (const material of materials) { - if ( - !material || - typeof material.stockAvailable !== 'number' || - material.stockAvailable <= 0 || - !material.project || - !material.itemType || - !material.project._id || - !material.itemType._id - ) { - continue; - } + // 2. Compute default start date if needed + const defaultStartDate = await computeDefaultStartDate( + startDateInput, + projectIds, + materialTypeIds, + ); - const updateRecords = Array.isArray(material.updateRecord) ? material.updateRecord : []; - let totalUsage = 0; - const usageByDate = {}; + // 3. Parse and normalize date range + let dateRangeMeta; + try { + dateRangeMeta = parseAndNormalizeDateRangeUTC( + startDateInput, + endDateInput, + defaultStartDate, + undefined, + ); + } catch (error) { + return handleDateRangeError(error, req, res); + } - for (const record of updateRecords) { - if (!record || !record.date) continue; + const { effectiveStart, effectiveEnd } = dateRangeMeta; + + // 4. Run aggregations in parallel + let usageData; + let costData; + try { + const filters = { projectIds, materialTypeIds }; + const dateRange = { effectiveStart, effectiveEnd }; + + [usageData, costData] = await Promise.all([ + aggregateMaterialUsage(BuildingMaterial, filters, dateRange), + aggregateMaterialCost(BuildingMaterial, filters, dateRange), + ]); + } catch (error) { + logger.logException(error, 'bmGetMaterialCostCorrelation - aggregation', { + method: req.method, + path: req.path, + query: req.query, + }); + return res.status(HTTP_STATUS_INTERNAL_SERVER_ERROR).json({ + error: 'Internal server error while aggregating material data', + }); + } - const recordDate = new Date(record.date); - if (Number.isNaN(recordDate.getTime())) continue; - if (recordDate < thirtyDaysAgo || recordDate > now) continue; + // 5. Build response + let responseObject; + try { + const requestParams = { + projectIds, + materialTypeIds, + dateRangeMeta, + }; + const models = { + BuildingProject, + BuildingInventoryType: invTypeBase, + }; + responseObject = await buildCostCorrelationResponse( + usageData, + costData, + requestParams, + models, + ); + } catch (error) { + logger.logException(error, 'bmGetMaterialCostCorrelation - response building', { + method: req.method, + path: req.path, + query: req.query, + }); + return res.status(HTTP_STATUS_INTERNAL_SERVER_ERROR).json({ + error: 'Internal server error while building response', + }); + } - const dateKey = recordDate.toISOString().split('T')[0]; - const quantityUsed = parseFloat(record.quantityUsed) || 0; + // 6. Send response + return res.status(200).json(responseObject); + } catch (error) { + // Global error handling wrapper + logger.logException(error, 'bmGetMaterialCostCorrelation - unexpected error', { + method: req.method, + path: req.path, + query: req.query, + }); + return res.status(HTTP_STATUS_INTERNAL_SERVER_ERROR).json({ error: 'Internal server error' }); + } + }; - if (quantityUsed > 0) { - if (!usageByDate[dateKey]) { - usageByDate[dateKey] = 0; - } - usageByDate[dateKey] += quantityUsed; - totalUsage += quantityUsed; - } - } + const DAYS_IN_STOCK_RISK_PERIOD = 30; + const SENTINEL_NO_USAGE_DATA = 999; - let averageDailyUsage = 0; + /** @returns {{ query: Object }|{ error: { status: number, body: Object }}} */ + const buildStockOutRiskQuery = function (projectIds) { + const query = {}; + if (!projectIds || projectIds === 'all' || typeof projectIds !== 'string') { + return { query }; + } + const projectIdArray = projectIds + .split(',') + .map((id) => id.trim()) + .filter((id) => id.length > 0); + if (projectIdArray.length === 0) return { query }; + const validProjectIds = projectIdArray + .filter((id) => mongoose.Types.ObjectId.isValid(id)) + .map((id) => new mongoose.Types.ObjectId(id)); + if (validProjectIds.length > 0) { + query.project = { $in: validProjectIds }; + return { query }; + } + return { + error: { + status: 400, + body: { + error: 'Invalid project IDs provided', + details: 'All provided project IDs are invalid', + }, + }, + }; + }; - if (totalUsage > 0) { - averageDailyUsage = totalUsage / daysInPeriod; - } else if (material.stockUsed > 0) { - averageDailyUsage = parseFloat(material.stockUsed) / daysInPeriod; - } + const isValidMaterialForStockRisk = function (material) { + return ( + material && + typeof material.stockAvailable === 'number' && + material.stockAvailable > 0 && + material.project?._id && + material.itemType?._id + ); + }; - let daysUntilStockOut = 0; - if (averageDailyUsage > 0) { - daysUntilStockOut = Math.floor(material.stockAvailable / averageDailyUsage); - } else { - daysUntilStockOut = SENTINEL_NO_USAGE_DATA; - } + const computeUsageFromUpdateRecords = function (updateRecords, thirtyDaysAgo, now) { + const records = Array.isArray(updateRecords) ? updateRecords : []; + let totalUsage = 0; + const usageByDate = {}; + records.forEach((record) => { + if (!record?.date) return; + const recordDate = new Date(record.date); + if (Number.isNaN(recordDate.getTime())) return; + if (recordDate < thirtyDaysAgo || recordDate > now) return; + const dateKey = recordDate.toISOString().split('T')[0]; + const quantityUsed = parseFloat(record.quantityUsed) || 0; + if (quantityUsed > 0) { + usageByDate[dateKey] = (usageByDate[dateKey] || 0) + quantityUsed; + totalUsage += quantityUsed; + } + }); + return { totalUsage }; + }; - if (daysUntilStockOut >= 0 && daysUntilStockOut < SENTINEL_NO_USAGE_DATA) { - stockOutRiskData.push({ - materialName: material.itemType.name || 'Unknown Material', - materialId: material.itemType._id.toString(), - projectId: material.project._id.toString(), - projectName: material.project.name || 'Unknown Project', - stockAvailable: parseFloat(material.stockAvailable.toFixed(2)), - averageDailyUsage: parseFloat(averageDailyUsage.toFixed(2)), - daysUntilStockOut: Math.max(0, daysUntilStockOut), - unit: material.itemType.unit || '', - }); - } + const computeAverageDailyAndDaysOut = function (material, totalUsage) { + const daysInPeriod = DAYS_IN_STOCK_RISK_PERIOD; + let averageDailyUsage = totalUsage > 0 ? totalUsage / daysInPeriod : 0; + if (averageDailyUsage === 0 && material.stockUsed > 0) { + averageDailyUsage = parseFloat(material.stockUsed) / daysInPeriod; + } + const daysUntilStockOut = + averageDailyUsage > 0 + ? Math.floor(material.stockAvailable / averageDailyUsage) + : SENTINEL_NO_USAGE_DATA; + return { averageDailyUsage, daysUntilStockOut }; + }; + + const buildStockOutRiskItem = function (material, averageDailyUsage, daysUntilStockOut) { + return { + materialName: material.itemType.name || 'Unknown Material', + materialId: material.itemType._id.toString(), + projectId: material.project._id.toString(), + projectName: material.project.name || 'Unknown Project', + stockAvailable: parseFloat(material.stockAvailable.toFixed(2)), + averageDailyUsage: parseFloat(averageDailyUsage.toFixed(2)), + daysUntilStockOut: Math.max(0, daysUntilStockOut), + unit: material.itemType.unit || '', + }; + }; + + const getStockOutRiskErrorResponse = function (err) { + if (err.name === 'CastError' || err.name === 'ValidationError') { + return { statusCode: 400, errorMessage: 'Invalid request parameters' }; + } + if (err.name === 'MongoError' || err.name === 'MongoServerError') { + return { statusCode: 503, errorMessage: 'Database error' }; + } + return { statusCode: 500, errorMessage: 'Internal Server Error' }; + }; + + const bmGetMaterialStockOutRisk = async function (req, res) { + try { + const { projectIds } = req.query || {}; + const queryResult = buildStockOutRiskQuery(projectIds); + if (queryResult.error) { + return res.status(queryResult.error.status).json(queryResult.error.body); } + const materials = await BuildingMaterial.find(queryResult.query) + .populate('project', '_id name') + .populate('itemType', '_id name unit') + .lean() + .exec(); + + const now = new Date(); + const thirtyDaysAgo = new Date(now); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - DAYS_IN_STOCK_RISK_PERIOD); + + const stockOutRiskData = materials + .filter((material) => isValidMaterialForStockRisk(material)) + .map((material) => { + const { totalUsage } = computeUsageFromUpdateRecords( + material.updateRecord, + thirtyDaysAgo, + now, + ); + const { averageDailyUsage, daysUntilStockOut } = computeAverageDailyAndDaysOut( + material, + totalUsage, + ); + return { material, averageDailyUsage, daysUntilStockOut }; + }) + .filter((x) => x.daysUntilStockOut >= 0 && x.daysUntilStockOut < SENTINEL_NO_USAGE_DATA) + .map((x) => buildStockOutRiskItem(x.material, x.averageDailyUsage, x.daysUntilStockOut)); + stockOutRiskData.sort((a, b) => { const daysA = Number(a.daysUntilStockOut) || 0; const daysB = Number(b.daysUntilStockOut) || 0; @@ -563,20 +802,8 @@ const bmMaterialsController = function (BuildingMaterial) { res.status(200).json(stockOutRiskData); } catch (err) { - let statusCode = 500; - let errorMessage = 'Internal Server Error'; - - if (err.name === 'CastError' || err.name === 'ValidationError') { - statusCode = 400; - errorMessage = 'Invalid request parameters'; - } else if (err.name === 'MongoError' || err.name === 'MongoServerError') { - statusCode = 503; - errorMessage = 'Database error'; - } - - res.status(statusCode).json({ - error: errorMessage, - }); + const { statusCode, errorMessage } = getStockOutRiskErrorResponse(err); + res.status(statusCode).json({ error: errorMessage }); } }; @@ -588,6 +815,7 @@ const bmMaterialsController = function (BuildingMaterial) { bmupdatePurchaseStatus, bmGetMaterialSummaryByProject, bmGetMaterialStockOutRisk, + bmGetMaterialCostCorrelation, }; }; diff --git a/src/controllers/bmdashboard/bmMaterialsController.test.js b/src/controllers/bmdashboard/bmMaterialsController.test.js index d4ebe998a..cb434eb22 100644 --- a/src/controllers/bmdashboard/bmMaterialsController.test.js +++ b/src/controllers/bmdashboard/bmMaterialsController.test.js @@ -1,10 +1,14 @@ const bmMaterialsController = require('./bmMaterialsController'); -jest.mock('mongoose', () => ({ - Types: { - ObjectId: jest.fn((id) => id), - }, -})); +jest.mock('mongoose', () => { + const mockObjectId = jest.fn((id) => id); + mockObjectId.isValid = (id) => typeof id === 'string' && /^[a-fA-F0-9]{24}$/.test(id); + return { + Types: { + ObjectId: mockObjectId, + }, + }; +}); describe('bmMaterialsController', () => { let BuildingMaterialMock; @@ -137,33 +141,37 @@ describe('bmMaterialsController', () => { await controller.bmPurchaseMaterials(req, res); - expect(BuildingMaterialMock.findOne).toHaveBeenCalledWith({ - project: validProjectId, - itemType: validMaterialTypeId, - }); - expect(BuildingMaterialMock.create).toHaveBeenCalledWith({ - itemType: validMaterialTypeId, - project: validProjectId, - purchaseRecord: [ - { - quantity: 100, - priority: 'High', - brandPref: 'Premium Brand', - requestedBy: validRequestorId, - }, - ], - stockBought: 100, - }); + expect(BuildingMaterialMock.findOne).toHaveBeenCalledWith( + expect.objectContaining({ + project: expect.anything(), + itemType: expect.anything(), + }), + ); + expect(BuildingMaterialMock.create).toHaveBeenCalledWith( + expect.objectContaining({ + purchaseRecord: [ + { + quantity: 100, + priority: 'High', + brandPref: 'Premium Brand', + requestedBy: validRequestorId, + }, + ], + stockBought: 100, + }), + ); expect(res.status).toHaveBeenCalledWith(201); expect(res.send).toHaveBeenCalled(); }); }); describe('bmPostMaterialUpdateRecord', () => { + const validMaterialId = '507f1f77bcf86cd799439011'; + it('should update material stock with valid quantities', async () => { const updateData = { material: { - _id: 'material123', + _id: validMaterialId, stockAvailable: 100, stockUsed: 20, stockWasted: 5, @@ -184,7 +192,7 @@ describe('bmMaterialsController', () => { await controller.bmPostMaterialUpdateRecord(req, res); expect(BuildingMaterialMock.updateOne).toHaveBeenCalledWith( - { _id: 'material123' }, + expect.objectContaining({ _id: expect.anything() }), { $set: { stockUsed: 30, @@ -208,7 +216,7 @@ describe('bmMaterialsController', () => { it('should handle percentage-based quantity calculations correctly', async () => { const updateData = { material: { - _id: 'material123', + _id: validMaterialId, stockAvailable: 100, stockUsed: 20, stockWasted: 5, @@ -229,7 +237,7 @@ describe('bmMaterialsController', () => { await controller.bmPostMaterialUpdateRecord(req, res); expect(BuildingMaterialMock.updateOne).toHaveBeenCalledWith( - { _id: 'material123' }, + expect.objectContaining({ _id: expect.anything() }), { $set: { stockUsed: 45, @@ -281,9 +289,11 @@ describe('bmMaterialsController', () => { }); describe('bmupdatePurchaseStatus', () => { + const validPurchaseId = '507f1f77bcf86cd7994390bb'; + it('should successfully update purchase status from Pending to Approved', async () => { const updateData = { - purchaseId: 'purchase123', + purchaseId: validPurchaseId, status: 'Approved', quantity: 100, }; @@ -292,7 +302,7 @@ describe('bmMaterialsController', () => { const mockMaterial = { _id: 'material123', - purchaseRecord: [{ _id: 'purchase123', status: 'Pending', quantity: 100 }], + purchaseRecord: [{ _id: validPurchaseId, status: 'Pending', quantity: 100 }], }; BuildingMaterialMock.findOne.mockResolvedValue(mockMaterial); @@ -300,16 +310,17 @@ describe('bmMaterialsController', () => { await controller.bmupdatePurchaseStatus(req, res); - expect(BuildingMaterialMock.findOne).toHaveBeenCalledWith({ - 'purchaseRecord._id': 'purchase123', - }); - // Controller does NOT call findOneAndUpdate; don't assert it + expect(BuildingMaterialMock.findOne).toHaveBeenCalledWith( + expect.objectContaining({ + 'purchaseRecord._id': expect.anything(), + }), + ); expect(res.status).toHaveBeenCalledWith(200); expect(res.send).toHaveBeenCalledWith('Purchase approved successfully'); }); it('should return 404 when purchase is not found', async () => { const updateData = { - purchaseId: 'nonexistentPurchase', + purchaseId: '507f1f77bcf86cd799439099', status: 'Approved', quantity: 100, }; @@ -324,9 +335,28 @@ describe('bmMaterialsController', () => { expect(res.send).toHaveBeenCalledWith('Purchase not found'); }); + it('should return 400 when purchaseId is invalid', async () => { + req.body = { + purchaseId: 'not-valid-objectid', + status: 'Approved', + quantity: 100, + }; + + await controller.bmupdatePurchaseStatus(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Invalid purchase ID format', + field: 'purchaseId', + }), + ); + expect(BuildingMaterialMock.findOne).not.toHaveBeenCalled(); + }); + it('should return 400 when trying to update non-Pending purchase', async () => { const updateData = { - purchaseId: 'purchase123', + purchaseId: validPurchaseId, status: 'Approved', quantity: 100, }; @@ -337,7 +367,7 @@ describe('bmMaterialsController', () => { _id: 'material123', purchaseRecord: [ { - _id: 'purchase123', + _id: validPurchaseId, status: 'Approved', quantity: 100, }, diff --git a/src/controllers/bmdashboard/bmToolStoppageReasonController.j b/src/controllers/bmdashboard/bmToolStoppageReasonController.j new file mode 100644 index 000000000..e69de29bb diff --git a/src/routes/bmdashboard/__tests__/bmMaterialsRouter.test.js b/src/routes/bmdashboard/__tests__/bmMaterialsRouter.test.js new file mode 100644 index 000000000..fb8f308e9 --- /dev/null +++ b/src/routes/bmdashboard/__tests__/bmMaterialsRouter.test.js @@ -0,0 +1,127 @@ +const express = require('express'); +// Mock the controller +const mockController = { + bmMaterialsList: jest.fn(), + bmPurchaseMaterials: jest.fn(), + bmPostMaterialUpdateRecord: jest.fn(), + bmPostMaterialUpdateBulk: jest.fn(), + bmupdatePurchaseStatus: jest.fn(), + bmGetMaterialCostCorrelation: jest.fn(), + bmGetMaterialStockOutRisk: jest.fn(), + bmGetMaterialSummaryByProject: jest.fn(), +}; + +jest.mock('../../../controllers/bmdashboard/bmMaterialsController', () => + jest.fn(() => mockController), +); + +const bmMaterialsRouter = require('../bmMaterialsRouter'); + +describe('bmMaterialsRouter', () => { + let mockBuildingMaterial; + let router; + + beforeEach(() => { + jest.clearAllMocks(); + mockBuildingMaterial = {}; + router = bmMaterialsRouter(mockBuildingMaterial); + }); + + describe('Category 1: Route Registration', () => { + it('should register route at correct path: /materials/cost-correlation', () => { + // Verify router is an Express Router instance + expect(router).toBeDefined(); + expect(typeof router.route).toBe('function'); + }); + + it('should register GET method for cost-correlation route', () => { + // Create a test app to verify route registration + const app = express(); + app.disable('x-powered-by'); + app.use('/test', router); + + // The route should be registered - we verify by checking the controller is called + // when the route is accessed (in integration test) + expect(mockController.bmGetMaterialCostCorrelation).toBeDefined(); + }); + + it('should bind controller method correctly', () => { + // Verify controller method exists and is a function + expect(typeof mockController.bmGetMaterialCostCorrelation).toBe('function'); + }); + + it('should make route accessible', () => { + // Verify router is returned and can be used + expect(router).toBeDefined(); + expect(typeof router.route).toBe('function'); + expect(typeof router.use).toBe('function'); + }); + }); + + describe('Category 2: Route Ordering', () => { + it('should register cost-correlation route before /materials/:projectId route', () => { + // Verify both routes exist by checking controller methods + expect(mockController.bmGetMaterialCostCorrelation).toBeDefined(); + expect(mockController.bmGetMaterialSummaryByProject).toBeDefined(); + + // The route order is determined by the order in the router file + // Line 19: /materials/cost-correlation + // Line 21: /materials/:projectId + // This ensures cost-correlation matches before the parameterized route + }); + + it('should ensure specific route matches before parameterized route', () => { + // Create Express app to test route matching + const app = express(); + app.disable('x-powered-by'); + app.use('/api/bm', router); + + // Mock request handlers + mockController.bmGetMaterialCostCorrelation.mockImplementation((req, res) => { + res.status(200).json({ route: 'cost-correlation' }); + }); + + mockController.bmGetMaterialSummaryByProject.mockImplementation((req, res) => { + res.status(200).json({ route: 'project-summary', projectId: req.params.projectId }); + }); + + // Both routes should be registered + expect(mockController.bmGetMaterialCostCorrelation).toBeDefined(); + expect(mockController.bmGetMaterialSummaryByProject).toBeDefined(); + }); + + it('should ensure cost-correlation is not matched as projectId parameter', () => { + // Verify that cost-correlation route exists separately from parameterized route + expect(mockController.bmGetMaterialCostCorrelation).toBeDefined(); + expect(mockController.bmGetMaterialSummaryByProject).toBeDefined(); + + // The route registration order ensures cost-correlation is checked first + // This prevents 'cost-correlation' from being treated as a projectId + }); + }); + + describe('Category 3: Router Structure', () => { + it('should return Express Router instance', () => { + expect(router).toBeDefined(); + expect(typeof router.route).toBe('function'); + expect(typeof router.use).toBe('function'); + expect(typeof router.get).toBe('function'); + }); + + it('should initialize controller with BuildingMaterial model', () => { + const bmMaterialsController = require('../../../controllers/bmdashboard/bmMaterialsController'); + expect(bmMaterialsController).toHaveBeenCalledWith(mockBuildingMaterial); + }); + + it('should register all expected routes', () => { + // Verify all controller methods are available + expect(mockController.bmMaterialsList).toBeDefined(); + expect(mockController.bmPurchaseMaterials).toBeDefined(); + expect(mockController.bmPostMaterialUpdateRecord).toBeDefined(); + expect(mockController.bmPostMaterialUpdateBulk).toBeDefined(); + expect(mockController.bmupdatePurchaseStatus).toBeDefined(); + expect(mockController.bmGetMaterialCostCorrelation).toBeDefined(); + expect(mockController.bmGetMaterialSummaryByProject).toBeDefined(); + }); + }); +}); diff --git a/src/routes/bmdashboard/bmMaterialsRouter.js b/src/routes/bmdashboard/bmMaterialsRouter.js index 0a660bbe2..ff6c74416 100644 --- a/src/routes/bmdashboard/bmMaterialsRouter.js +++ b/src/routes/bmdashboard/bmMaterialsRouter.js @@ -17,6 +17,7 @@ const routes = function (buildingMaterial) { materialsRouter.route('/updateMaterialStatus').post(controller.bmupdatePurchaseStatus); materialsRouter.route('/materials/stock-out-risk').get(controller.bmGetMaterialStockOutRisk); + materialsRouter.route('/materials/cost-correlation').get(controller.bmGetMaterialCostCorrelation); materialsRouter.route('/materials/:projectId').get(controller.bmGetMaterialSummaryByProject); diff --git a/src/startup/routes.js b/src/startup/routes.js index 4cd7383c8..a86838913 100644 --- a/src/startup/routes.js +++ b/src/startup/routes.js @@ -228,7 +228,10 @@ const materialCostRouter = require('../routes/materialCostRouter')(); // bm dashboard const bmLoginRouter = require('../routes/bmdashboard/bmLoginRouter')(); -const bmMaterialsRouter = require('../routes/bmdashboard/bmMaterialsRouter')(buildingMaterial); +// NOTE: Use buildingMaterialModel (from buildingMaterial.js, queries 'buildingMaterials' collection) +// NOT buildingMaterial (from buildingInventoryItem.js, queries 'buildingInventoryItems' collection) +// See DEBUGGING_DOCUMENTATION.md for details on this fix +const bmMaterialsRouter = require('../routes/bmdashboard/bmMaterialsRouter')(buildingMaterialModel); const bmReusableRouter = require('../routes/bmdashboard/bmReusableRouter')(buildingReusable); const bmProjectRouter = require('../routes/bmdashboard/bmProjectRouter')(buildingProject); const bmOrgLocation = require('../routes/bmdashboard/bmOrgLocationRouter')(); diff --git a/src/utilities/__tests__/materialCostCorrelationDateUtils.test.js b/src/utilities/__tests__/materialCostCorrelationDateUtils.test.js new file mode 100644 index 000000000..2747d0d9b --- /dev/null +++ b/src/utilities/__tests__/materialCostCorrelationDateUtils.test.js @@ -0,0 +1,792 @@ +const { + parseDateInput, + normalizeStartDate, + normalizeEndDate, + isDateToday, + parseAndNormalizeDateRangeUTC, +} = require('../materialCostCorrelationDateUtils'); + +/** Assert that calling parseFn(input) throws DATE_PARSE_ERROR and run optional assertions on the error. */ +function expectDateParseError(parseFn, input, assertError) { + let caught; + try { + parseFn(input); + } catch (e) { + caught = e; + } + expect(caught).toBeDefined(); + expect(caught.type).toBe('DATE_PARSE_ERROR'); + if (assertError) assertError(caught); +} + +/** Assert result is end-of-day UTC (23:59:59.999). */ +function expectEndOfDayUTC(result) { + expect(result.getUTCHours()).toBe(23); + expect(result.getUTCMinutes()).toBe(59); + expect(result.getUTCSeconds()).toBe(59); + expect(result.getUTCMilliseconds()).toBe(999); +} + +describe('materialCostCorrelationDateUtils', () => { + // Use fixed date for consistent testing + const FIXED_NOW = new Date('2024-01-15T12:30:45.123Z'); // Monday, Jan 15, 2024, 12:30:45 UTC + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(FIXED_NOW); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('parseDateInput', () => { + describe('Category 1: Valid Input Formats', () => { + it('should parse ISO date string correctly', () => { + const result = parseDateInput('2024-01-15'); + expect(result).toBeInstanceOf(Date); + expect(result.getUTCFullYear()).toBe(2024); + expect(result.getUTCMonth()).toBe(0); // January is 0 + expect(result.getUTCDate()).toBe(15); + }); + + it('should parse ISO date-time string correctly', () => { + const result = parseDateInput('2024-01-15T10:30:00Z'); + expect(result).toBeInstanceOf(Date); + expect(result.toISOString()).toBe('2024-01-15T10:30:00.000Z'); + }); + + it('should parse MM-DD-YYYY format correctly', () => { + const result = parseDateInput('01-15-2024'); + expect(result).toBeInstanceOf(Date); + expect(result.getUTCFullYear()).toBe(2024); + expect(result.getUTCMonth()).toBe(0); + expect(result.getUTCDate()).toBe(15); + }); + + it('should parse MM/DD/YYYY format correctly', () => { + const result = parseDateInput('01/15/2024'); + expect(result).toBeInstanceOf(Date); + expect(result.getUTCFullYear()).toBe(2024); + expect(result.getUTCMonth()).toBe(0); + expect(result.getUTCDate()).toBe(15); + }); + + it('should handle single-digit month and day in MM-DD-YYYY', () => { + const result = parseDateInput('1-5-2024'); + expect(result).toBeInstanceOf(Date); + expect(result.getUTCFullYear()).toBe(2024); + expect(result.getUTCMonth()).toBe(0); + expect(result.getUTCDate()).toBe(5); + }); + + it('should handle single-digit month and day in MM/DD/YYYY', () => { + const result = parseDateInput('1/5/2024'); + expect(result).toBeInstanceOf(Date); + expect(result.getUTCFullYear()).toBe(2024); + expect(result.getUTCMonth()).toBe(0); + expect(result.getUTCDate()).toBe(5); + }); + + it('should return Date object as-is if valid', () => { + const inputDate = new Date('2024-01-15T10:30:00Z'); + const result = parseDateInput(inputDate); + expect(result).toBe(inputDate); + expect(result.getTime()).toBe(inputDate.getTime()); + }); + + it('should throw error for invalid Date object', () => { + expectDateParseError(parseDateInput, new Date('invalid'), (error) => { + expect(error.message).toContain('Invalid Date object'); + }); + }); + }); + + describe('Category 1: Invalid Input Formats', () => { + it('should throw DATE_PARSE_ERROR for invalid string', () => { + expectDateParseError(parseDateInput, 'not-a-date', (error) => { + expect(error.originalInput).toBe('not-a-date'); + expect(Array.isArray(error.acceptedFormats)).toBe(true); + }); + }); + + it('should throw DATE_PARSE_ERROR for empty string', () => { + expectDateParseError(parseDateInput, '', (error) => { + expect(error.message).toContain('Empty date string'); + }); + }); + + it('should throw DATE_PARSE_ERROR for whitespace-only string', () => { + expectDateParseError(parseDateInput, ' '); + }); + + it('should throw DATE_PARSE_ERROR for null input', () => { + expectDateParseError(parseDateInput, null, (error) => { + expect(error.originalInput).toBe(null); + }); + }); + + it('should throw DATE_PARSE_ERROR for undefined input', () => { + expectDateParseError(parseDateInput, undefined); + }); + + it('should throw DATE_PARSE_ERROR for number input', () => { + expectDateParseError(parseDateInput, 12345, (error) => { + expect(error.message).toContain('number'); + }); + }); + + it('should throw DATE_PARSE_ERROR for boolean input', () => { + expectDateParseError(parseDateInput, true, (error) => { + expect(error.message).toContain('boolean'); + }); + }); + + it('should throw error for malformed MM-DD-YYYY (invalid month)', () => { + expect(() => parseDateInput('13-15-2024')).toThrow(); + }); + + it('should throw error for malformed MM-DD-YYYY (invalid day)', () => { + expect(() => parseDateInput('01-45-2024')).toThrow(); + }); + + it('should throw error for malformed MM/DD/YYYY (invalid month)', () => { + expect(() => parseDateInput('13/15/2024')).toThrow(); + }); + + it('should throw error for malformed MM/DD/YYYY (invalid day)', () => { + expect(() => parseDateInput('01/45/2024')).toThrow(); + }); + + it('should have correct error structure with all required properties', () => { + expectDateParseError(parseDateInput, 'invalid', (error) => { + expect(error.message).toBeDefined(); + expect(error.originalInput).toBe('invalid'); + expect(Array.isArray(error.acceptedFormats)).toBe(true); + expect(error.acceptedFormats.length).toBeGreaterThan(0); + }); + }); + + // Note: The edge case where Date.parse succeeds but date.getTime() is NaN + // (lines 102-104, 117-119) is extremely rare and difficult to test reliably + // because JavaScript Date parsing is lenient and will roll over invalid dates. + // This code path exists as defensive programming but is not easily testable + // without complex mocking of Date.parse or Date constructor. + }); + }); + + describe('normalizeStartDate', () => { + describe('Category 2: UTC Normalization (isUTC = true)', () => { + it('should normalize date with time to 00:00:00.000Z', () => { + const input = new Date('2024-01-15T14:30:45.789Z'); + const result = normalizeStartDate(input, true); + expect(result.getUTCHours()).toBe(0); + expect(result.getUTCMinutes()).toBe(0); + expect(result.getUTCSeconds()).toBe(0); + expect(result.getUTCMilliseconds()).toBe(0); + expect(result.getUTCFullYear()).toBe(2024); + expect(result.getUTCMonth()).toBe(0); + expect(result.getUTCDate()).toBe(15); + }); + + it('should remain at 00:00:00.000Z if already at midnight', () => { + const input = new Date('2024-01-15T00:00:00.000Z'); + const result = normalizeStartDate(input, true); + expect(result.getTime()).toBe(input.getTime()); + expect(result.getUTCHours()).toBe(0); + }); + + it('should normalize date at end of day to 00:00:00.000Z of same day', () => { + const input = new Date('2024-01-15T23:59:59.999Z'); + const result = normalizeStartDate(input, true); + expect(result.getUTCHours()).toBe(0); + expect(result.getUTCDate()).toBe(15); // Same day + }); + + it('should normalize different timezones to UTC start of day', () => { + // Create a date that represents a different timezone + const input = new Date('2024-01-15T14:30:00-05:00'); // EST + const result = normalizeStartDate(input, true); + // Should normalize to UTC start of the UTC day + expect(result.getUTCHours()).toBe(0); + expect(result.getUTCMinutes()).toBe(0); + }); + + it('should handle date at exactly 00:00:00.000Z', () => { + const input = new Date('2024-01-15T00:00:00.000Z'); + const result = normalizeStartDate(input, true); + expect(result.getTime()).toBe(input.getTime()); + }); + }); + + describe('Category 2: Local Time Normalization (isUTC = false)', () => { + it('should normalize date with time to local 00:00:00.000', () => { + const input = new Date('2024-01-15T14:30:45.789Z'); + const result = normalizeStartDate(input, false); + expect(result.getHours()).toBe(0); + expect(result.getMinutes()).toBe(0); + expect(result.getSeconds()).toBe(0); + expect(result.getMilliseconds()).toBe(0); + }); + }); + + describe('Category 2: Edge Cases', () => { + it('should throw error for invalid date object', () => { + expectDateParseError( + (d) => normalizeStartDate(d, true), + new Date('invalid'), + (error) => expect(error.message).toContain('normalizeStartDate requires'), + ); + }); + + it('should handle date at year boundary', () => { + const input = new Date('2023-12-31T23:59:59.999Z'); + const result = normalizeStartDate(input, true); + expect(result.getUTCFullYear()).toBe(2023); + expect(result.getUTCMonth()).toBe(11); // December + expect(result.getUTCDate()).toBe(31); + expect(result.getUTCHours()).toBe(0); + }); + + it('should handle date at month boundary', () => { + const input = new Date('2024-01-31T23:59:59.999Z'); + const result = normalizeStartDate(input, true); + expect(result.getUTCMonth()).toBe(0); // January + expect(result.getUTCDate()).toBe(31); + expect(result.getUTCHours()).toBe(0); + }); + + it('should handle leap year dates correctly', () => { + const input = new Date('2024-02-29T14:30:00Z'); // 2024 is a leap year + const result = normalizeStartDate(input, true); + expect(result.getUTCFullYear()).toBe(2024); + expect(result.getUTCMonth()).toBe(1); // February + expect(result.getUTCDate()).toBe(29); + expect(result.getUTCHours()).toBe(0); + }); + }); + }); + + describe('isDateToday', () => { + describe('Category 3: UTC Comparison (isUTC = true)', () => { + it('should return true for date matching today (UTC)', () => { + const today = new Date('2024-01-15T10:30:00Z'); + expect(isDateToday(today, true)).toBe(true); + }); + + it('should return true for date matching today but different time', () => { + const today = new Date('2024-01-15T23:59:59Z'); + expect(isDateToday(today, true)).toBe(true); + }); + + it('should return false for date from yesterday (UTC)', () => { + const yesterday = new Date('2024-01-14T10:30:00Z'); + expect(isDateToday(yesterday, true)).toBe(false); + }); + + it('should return false for date from tomorrow (UTC)', () => { + const tomorrow = new Date('2024-01-16T10:30:00Z'); + expect(isDateToday(tomorrow, true)).toBe(false); + }); + + it('should return false for date from different year', () => { + const differentYear = new Date('2023-01-15T10:30:00Z'); + expect(isDateToday(differentYear, true)).toBe(false); + }); + + it('should return false for date from different month', () => { + const differentMonth = new Date('2024-02-15T10:30:00Z'); + expect(isDateToday(differentMonth, true)).toBe(false); + }); + + it('should return false for date from different day', () => { + const differentDay = new Date('2024-01-20T10:30:00Z'); + expect(isDateToday(differentDay, true)).toBe(false); + }); + }); + + describe('Category 3: Local Time Comparison (isUTC = false)', () => { + it('should return true for date matching today (local)', () => { + const today = new Date('2024-01-15T10:30:00Z'); + expect(isDateToday(today, false)).toBe(true); + }); + }); + + describe('Category 3: Edge Cases', () => { + it('should return false for invalid date object', () => { + const invalidDate = new Date('invalid'); + expect(isDateToday(invalidDate, true)).toBe(false); + }); + + it('should return false for non-Date object', () => { + expect(isDateToday('2024-01-15', true)).toBe(false); + expect(isDateToday(null, true)).toBe(false); + expect(isDateToday(undefined, true)).toBe(false); + }); + + it('should handle date at midnight boundary', () => { + const midnight = new Date('2024-01-15T00:00:00Z'); + expect(isDateToday(midnight, true)).toBe(true); + }); + }); + }); + + describe('normalizeEndDate', () => { + describe('Category 4: Today Date Handling', () => { + it('should cap to now minus 5 minutes when date is today', () => { + const today = new Date('2024-01-15T10:30:00Z'); + const result = normalizeEndDate(today, true); + const expectedTime = FIXED_NOW.getTime() - 5 * 60 * 1000; + expect(result.getTime()).toBe(expectedTime); + }); + + it('should verify 5-minute subtraction is correct', () => { + const today = new Date('2024-01-15T12:30:45Z'); + const result = normalizeEndDate(today, true); + const diff = FIXED_NOW.getTime() - result.getTime(); + expect(diff).toBe(5 * 60 * 1000); // 5 minutes in milliseconds + }); + + it('should verify returned date is before current time', () => { + const today = new Date('2024-01-15T12:30:45Z'); + const result = normalizeEndDate(today, true); + expect(result.getTime()).toBeLessThan(FIXED_NOW.getTime()); + }); + + it('should verify returned date is within 5-6 minutes of current time', () => { + const today = new Date('2024-01-15T12:30:45Z'); + const result = normalizeEndDate(today, true); + const diff = FIXED_NOW.getTime() - result.getTime(); + expect(diff).toBeGreaterThanOrEqual(5 * 60 * 1000); + expect(diff).toBeLessThan(6 * 60 * 1000); + }); + }); + + describe('Category 4: Non-Today Date Handling', () => { + it('should normalize yesterday to 23:59:59.999Z of that day', () => { + const result = normalizeEndDate(new Date('2024-01-14T10:30:00Z'), true); + expectEndOfDayUTC(result); + expect(result.getUTCDate()).toBe(14); + }); + + it('should normalize tomorrow to 23:59:59.999Z of that day', () => { + const result = normalizeEndDate(new Date('2024-01-16T10:30:00Z'), true); + expectEndOfDayUTC(result); + expect(result.getUTCDate()).toBe(16); + }); + + it('should normalize past date to 23:59:59.999Z', () => { + const result = normalizeEndDate(new Date('2020-06-15T10:30:00Z'), true); + expectEndOfDayUTC(result); + expect(result.getUTCFullYear()).toBe(2020); + expect(result.getUTCMonth()).toBe(5); // June + expect(result.getUTCDate()).toBe(15); + }); + + it('should normalize future date to 23:59:59.999Z', () => { + const result = normalizeEndDate(new Date('2025-06-15T10:30:00Z'), true); + expectEndOfDayUTC(result); + expect(result.getUTCFullYear()).toBe(2025); + }); + }); + + describe('Category 4: UTC Normalization', () => { + it('should verify time is set to 23:59:59.999Z for non-today dates', () => { + const result = normalizeEndDate(new Date('2024-01-20T14:30:00Z'), true); + expectEndOfDayUTC(result); + }); + }); + + describe('Category 4: Edge Cases', () => { + it('should throw error for invalid date object', () => { + expectDateParseError( + (d) => normalizeEndDate(d, true), + new Date('invalid'), + (error) => expect(error.message).toContain('normalizeEndDate requires'), + ); + }); + + it('should handle date at year boundary', () => { + const result = normalizeEndDate(new Date('2023-12-31T10:30:00Z'), true); + expect(result.getUTCFullYear()).toBe(2023); + expect(result.getUTCMonth()).toBe(11); + expect(result.getUTCDate()).toBe(31); + expect(result.getUTCHours()).toBe(23); + }); + + it('should handle date at month boundary', () => { + const result = normalizeEndDate(new Date('2024-01-31T10:30:00Z'), true); + expect(result.getUTCMonth()).toBe(0); + expect(result.getUTCDate()).toBe(31); + expect(result.getUTCHours()).toBe(23); + }); + + it('should handle leap year dates correctly', () => { + const result = normalizeEndDate(new Date('2024-02-29T10:30:00Z'), true); + expect(result.getUTCFullYear()).toBe(2024); + expect(result.getUTCMonth()).toBe(1); + expect(result.getUTCDate()).toBe(29); + expect(result.getUTCHours()).toBe(23); + }); + + it('should normalize non-today date to local end of day when isUTC is false', () => { + const nonToday = new Date('2024-01-20T14:30:00Z'); + const result = normalizeEndDate(nonToday, false); + expect(result.getHours()).toBe(23); + expect(result.getMinutes()).toBe(59); + expect(result.getSeconds()).toBe(59); + expect(result.getMilliseconds()).toBe(999); + }); + }); + }); + + describe('parseAndNormalizeDateRangeUTC', () => { + describe('Category 5: Both Dates Provided', () => { + it('should return normalized range for valid start and end dates', () => { + const result = parseAndNormalizeDateRangeUTC( + '2024-01-10', + '2024-01-20', + undefined, + undefined, + ); + expect(result.effectiveStart).toBeInstanceOf(Date); + expect(result.effectiveEnd).toBeInstanceOf(Date); + expect(result.effectiveStart.getUTCHours()).toBe(0); + expect(result.effectiveEnd.getUTCHours()).toBe(23); + expect(result.defaultsApplied.startDate).toBe(false); + expect(result.defaultsApplied.endDate).toBe(false); + }); + + it('should throw DATE_RANGE_ERROR when start date is after end date', () => { + let caught; + try { + parseAndNormalizeDateRangeUTC('2024-01-20', '2024-01-10', undefined, undefined); + } catch (error) { + caught = error; + } + expect(caught).toBeDefined(); + expect(caught.type).toBe('DATE_RANGE_ERROR'); + expect(caught.message).toContain('must be less than or equal'); + expect(caught.effectiveStart).toBeDefined(); + expect(caught.effectiveEnd).toBeDefined(); + }); + + it('should return valid range when start date equals end date (same day)', () => { + const result = parseAndNormalizeDateRangeUTC( + '2024-01-15', + '2024-01-15', + undefined, + undefined, + ); + expect(result.effectiveStart.getTime()).toBeLessThanOrEqual(result.effectiveEnd.getTime()); + expect(result.effectiveStart.getUTCDate()).toBe(15); + expect(result.effectiveEnd.getUTCDate()).toBe(15); + }); + + it('should return normalized range when start date is before end date', () => { + const result = parseAndNormalizeDateRangeUTC( + '2024-01-10', + '2024-01-20', + undefined, + undefined, + ); + expect(result.effectiveStart.getTime()).toBeLessThan(result.effectiveEnd.getTime()); + }); + }); + + describe('Category 5: Only Start Date Provided', () => { + it('should use current date as end when end date not provided', () => { + const result = parseAndNormalizeDateRangeUTC('2024-01-10', undefined, undefined, undefined); + expect(result.effectiveEnd).toBeInstanceOf(Date); + expect(result.defaultsApplied.endDate).toBe(true); + expect(result.endCappedToNowMinus5Min).toBe(true); + }); + + it('should cap end to now-5min when end date is today', () => { + const todayStr = '2024-01-15'; + const result = parseAndNormalizeDateRangeUTC('2024-01-10', todayStr, undefined, undefined); + expect(result.endCappedToNowMinus5Min).toBe(true); + expect(result.effectiveEnd.getTime()).toBeLessThan(FIXED_NOW.getTime()); + }); + }); + + describe('Category 5: Only End Date Provided', () => { + it('should use defaultStartDate when provided and start date missing', () => { + const defaultStart = new Date('2024-01-01T00:00:00Z'); + const result = parseAndNormalizeDateRangeUTC( + undefined, + '2024-01-20', + defaultStart, + undefined, + ); + expect(result.effectiveStart).toBeInstanceOf(Date); + expect(result.defaultsApplied.startDate).toBe(true); + expect(result.effectiveStart.getUTCFullYear()).toBe(2024); + expect(result.effectiveStart.getUTCMonth()).toBe(0); + expect(result.effectiveStart.getUTCDate()).toBe(1); + }); + + it('should throw error when start date missing and no defaultStartDate provided', () => { + expectDateParseError( + () => parseAndNormalizeDateRangeUTC(undefined, '2024-01-20', undefined, undefined), + undefined, + (error) => expect(error.message).toContain('startDate is required'), + ); + }); + }); + + describe('Category 5: Neither Date Provided', () => { + it('should use defaultStartDate and current date when both missing', () => { + const defaultStart = new Date('2024-01-01T00:00:00Z'); + const result = parseAndNormalizeDateRangeUTC(undefined, undefined, defaultStart, undefined); + expect(result.defaultsApplied.startDate).toBe(true); + expect(result.defaultsApplied.endDate).toBe(true); + expect(result.endCappedToNowMinus5Min).toBe(true); + }); + }); + + describe('Category 5: Default Date Handling', () => { + it('should normalize default start date correctly', () => { + const defaultStart = new Date('2024-01-10T14:30:00Z'); + const result = parseAndNormalizeDateRangeUTC( + undefined, + '2024-01-20', + defaultStart, + undefined, + ); + expect(result.effectiveStart.getUTCHours()).toBe(0); + expect(result.effectiveStart.getUTCMinutes()).toBe(0); + }); + + it('should throw error for invalid defaultStartDate', () => { + const invalidDefault = new Date('invalid'); + expectDateParseError( + () => parseAndNormalizeDateRangeUTC(undefined, '2024-01-20', invalidDefault, undefined), + null, + (error) => + expect(error.message).toContain('defaultStartDate must be a valid Date object'), + ); + }); + + it('should throw error for invalid defaultEndDate', () => { + const defaultStart = new Date('2024-01-01T00:00:00Z'); + const invalidDefaultEnd = new Date('invalid'); + expectDateParseError( + () => + parseAndNormalizeDateRangeUTC('2024-01-10', undefined, defaultStart, invalidDefaultEnd), + null, + (error) => expect(error.message).toContain('defaultEndDate must be a valid Date object'), + ); + }); + + it('should use defaultEndDate when provided', () => { + const defaultStart = new Date('2024-01-01T00:00:00Z'); + const defaultEnd = new Date('2024-01-31T23:59:59Z'); + const result = parseAndNormalizeDateRangeUTC( + undefined, + undefined, + defaultStart, + defaultEnd, + ); + expect(result.effectiveEnd.getUTCDate()).toBe(31); + expect(result.defaultsApplied.endDate).toBe(true); + }); + }); + + describe('Category 5: Date Range Validation', () => { + it('should throw DATE_RANGE_ERROR with correct structure', () => { + let caught; + try { + parseAndNormalizeDateRangeUTC('2024-01-20', '2024-01-10', undefined, undefined); + } catch (error) { + caught = error; + } + expect(caught?.type).toBe('DATE_RANGE_ERROR'); + expect(caught?.message).toContain('must be less than or equal'); + expect(caught?.effectiveStart).toBeDefined(); + expect(caught?.effectiveEnd).toBeDefined(); + }); + + it('should return valid range when start <= end', () => { + const result = parseAndNormalizeDateRangeUTC( + '2024-01-10', + '2024-01-20', + undefined, + undefined, + ); + expect(result.effectiveStart.getTime()).toBeLessThanOrEqual(result.effectiveEnd.getTime()); + }); + }); + + describe('Category 5: Return Object Structure', () => { + it('should contain effectiveStart as Date object', () => { + const result = parseAndNormalizeDateRangeUTC( + '2024-01-10', + '2024-01-20', + undefined, + undefined, + ); + expect(result.effectiveStart).toBeInstanceOf(Date); + }); + + it('should contain effectiveEnd as Date object', () => { + const result = parseAndNormalizeDateRangeUTC( + '2024-01-10', + '2024-01-20', + undefined, + undefined, + ); + expect(result.effectiveEnd).toBeInstanceOf(Date); + }); + + it('should contain defaultsApplied object with boolean flags', () => { + const result = parseAndNormalizeDateRangeUTC( + '2024-01-10', + '2024-01-20', + undefined, + undefined, + ); + expect(typeof result.defaultsApplied.startDate).toBe('boolean'); + expect(typeof result.defaultsApplied.endDate).toBe('boolean'); + }); + + it('should contain endCappedToNowMinus5Min boolean', () => { + const result = parseAndNormalizeDateRangeUTC( + '2024-01-10', + '2024-01-15', + undefined, + undefined, + ); + expect(typeof result.endCappedToNowMinus5Min).toBe('boolean'); + }); + + it('should contain originalInputs object', () => { + const result = parseAndNormalizeDateRangeUTC( + '2024-01-10', + '2024-01-20', + undefined, + undefined, + ); + expect(result.originalInputs).toBeDefined(); + expect(result.originalInputs.startDateInput).toBe('2024-01-10'); + expect(result.originalInputs.endDateInput).toBe('2024-01-20'); + }); + }); + + describe('Category 5: Error Handling', () => { + it('should throw DATE_PARSE_ERROR for invalid start date format', () => { + expectDateParseError( + () => parseAndNormalizeDateRangeUTC('invalid', '2024-01-20', undefined, undefined), + null, + (error) => expect(error.message).toContain('startDate'), + ); + }); + + it('should throw DATE_PARSE_ERROR for invalid end date format', () => { + expectDateParseError( + () => parseAndNormalizeDateRangeUTC('2024-01-10', 'invalid', undefined, undefined), + null, + (error) => expect(error.message).toContain('endDate'), + ); + }); + + it('should handle errors that are not DATE_PARSE_ERROR from startDate', () => { + // This tests the defensive re-throw path (line 311) + // In practice, parseDateInput only throws DATE_PARSE_ERROR, + // but this tests the defensive code path + // We test this by ensuring normal DATE_PARSE_ERROR handling works, + // which exercises the if branch, leaving the else (re-throw) as defensive code + expect(() => { + parseAndNormalizeDateRangeUTC('invalid-start', '2024-01-20', undefined, undefined); + }).toThrow(); + }); + + it('should handle errors that are not DATE_PARSE_ERROR from endDate', () => { + // This tests the defensive re-throw path (line 363) + // Similar to above, this is defensive code + expect(() => { + parseAndNormalizeDateRangeUTC('2024-01-10', 'invalid-end', undefined, undefined); + }).toThrow(); + }); + }); + + describe('Category 5: Edge Cases', () => { + it('should handle dates at year boundaries', () => { + const result = parseAndNormalizeDateRangeUTC( + '2023-12-31', + '2024-01-01', + undefined, + undefined, + ); + expect(result.effectiveStart.getUTCFullYear()).toBe(2023); + expect(result.effectiveEnd.getUTCFullYear()).toBe(2024); + }); + + it('should handle dates at month boundaries', () => { + const result = parseAndNormalizeDateRangeUTC( + '2024-01-31', + '2024-02-01', + undefined, + undefined, + ); + expect(result.effectiveStart.getUTCMonth()).toBe(0); + expect(result.effectiveEnd.getUTCMonth()).toBe(1); + }); + + it('should handle very old dates', () => { + const result = parseAndNormalizeDateRangeUTC( + '2000-01-01', + '2000-12-31', + undefined, + undefined, + ); + expect(result.effectiveStart.getUTCFullYear()).toBe(2000); + expect(result.effectiveEnd.getUTCFullYear()).toBe(2000); + }); + + it('should handle very future dates', () => { + const result = parseAndNormalizeDateRangeUTC( + '2050-01-01', + '2050-12-31', + undefined, + undefined, + ); + expect(result.effectiveStart.getUTCFullYear()).toBe(2050); + expect(result.effectiveEnd.getUTCFullYear()).toBe(2050); + }); + + it('should handle dates spanning multiple years', () => { + const result = parseAndNormalizeDateRangeUTC( + '2023-06-01', + '2024-06-01', + undefined, + undefined, + ); + expect(result.effectiveStart.getUTCFullYear()).toBe(2023); + expect(result.effectiveEnd.getUTCFullYear()).toBe(2024); + }); + + it('should handle empty string as undefined for start date', () => { + const defaultStart = new Date('2024-01-01T00:00:00Z'); + const result = parseAndNormalizeDateRangeUTC('', '2024-01-20', defaultStart, undefined); + expect(result.defaultsApplied.startDate).toBe(true); + }); + + it('should handle empty string as undefined for end date', () => { + const result = parseAndNormalizeDateRangeUTC('2024-01-10', '', undefined, undefined); + expect(result.defaultsApplied.endDate).toBe(true); + }); + + it('should handle null as undefined for start date', () => { + const defaultStart = new Date('2024-01-01T00:00:00Z'); + const result = parseAndNormalizeDateRangeUTC(null, '2024-01-20', defaultStart, undefined); + expect(result.defaultsApplied.startDate).toBe(true); + }); + + it('should handle null as undefined for end date', () => { + const result = parseAndNormalizeDateRangeUTC('2024-01-10', null, undefined, undefined); + expect(result.defaultsApplied.endDate).toBe(true); + }); + }); + }); +}); diff --git a/src/utilities/__tests__/materialCostCorrelationHelpers.test.js b/src/utilities/__tests__/materialCostCorrelationHelpers.test.js new file mode 100644 index 000000000..c71a1160c --- /dev/null +++ b/src/utilities/__tests__/materialCostCorrelationHelpers.test.js @@ -0,0 +1,1178 @@ +// Mock mongoose and logger before requiring the module +const mockLogException = jest.fn(); +jest.mock('mongoose', () => ({ + Types: { + ObjectId: jest.fn((id) => ({ + toString: () => String(id), + _id: id, + })), + }, +})); + +jest.mock('../../startup/logger', () => ({ + logException: mockLogException, +})); + +const mongoose = require('mongoose'); +const { + convertStringsToObjectIds, + buildBaseMatchForMaterials, + getEarliestRelevantMaterialDate, + aggregateMaterialUsage, + aggregateMaterialCost, + calculateCostPerUnit, + calculateTotalCostK, + objectIdToString, + buildCostCorrelationResponse, +} = require('../materialCostCorrelationHelpers'); + +const DEFAULT_DATE_RANGE = { + effectiveStart: new Date('2024-01-01'), + effectiveEnd: new Date('2024-01-31'), +}; +const DEFAULT_FILTERS = { projectIds: [], materialTypeIds: [] }; +const defaultRequestParams = () => ({ + projectIds: [], + materialTypeIds: [], + dateRangeMeta: {}, +}); +const defaultModels = (mockProject, mockInventory) => ({ + BuildingProject: mockProject, + BuildingInventoryType: mockInventory, +}); + +describe('materialCostCorrelationHelpers', () => { + let mockBuildingMaterial; + let mockBuildingProject; + let mockBuildingInventoryType; + + beforeEach(() => { + jest.clearAllMocks(); + mockLogException.mockClear(); + + // Mock BuildingMaterial model + mockBuildingMaterial = { + aggregate: jest.fn().mockReturnThis(), + }; + mockBuildingMaterial.aggregate = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue([]) }); + + // Mock BuildingProject model + mockBuildingProject = { + find: jest.fn().mockReturnThis(), + }; + mockBuildingProject.find = jest.fn().mockReturnValue({ exec: jest.fn().mockResolvedValue([]) }); + + // Mock BuildingInventoryType model + mockBuildingInventoryType = { + find: jest.fn().mockReturnThis(), + }; + mockBuildingInventoryType.find = jest + .fn() + .mockReturnValue({ exec: jest.fn().mockResolvedValue([]) }); + }); + + describe('convertStringsToObjectIds', () => { + it('should convert array of valid ObjectId strings to ObjectIds', () => { + const idStrings = ['507f1f77bcf86cd799439011', '507f1f77bcf86cd799439012']; + const result = convertStringsToObjectIds(idStrings); + expect(result).toHaveLength(2); + expect(mongoose.Types.ObjectId).toHaveBeenCalledTimes(2); + expect(mongoose.Types.ObjectId).toHaveBeenCalledWith('507f1f77bcf86cd799439011'); + expect(mongoose.Types.ObjectId).toHaveBeenCalledWith('507f1f77bcf86cd799439012'); + }); + + it('should return empty array for empty input', () => { + const result = convertStringsToObjectIds([]); + expect(result).toEqual([]); + expect(mongoose.Types.ObjectId).not.toHaveBeenCalled(); + }); + + it('should handle single string', () => { + const result = convertStringsToObjectIds(['507f1f77bcf86cd799439011']); + expect(result).toHaveLength(1); + expect(mongoose.Types.ObjectId).toHaveBeenCalledTimes(1); + }); + + it('should handle multiple strings', () => { + const idStrings = ['id1', 'id2', 'id3', 'id4']; + const result = convertStringsToObjectIds(idStrings); + expect(result).toHaveLength(4); + expect(mongoose.Types.ObjectId).toHaveBeenCalledTimes(4); + }); + }); + + describe('buildBaseMatchForMaterials', () => { + describe('Project Filtering', () => { + it('should return empty match when projectIds is empty', () => { + const result = buildBaseMatchForMaterials([], []); + expect(result).toEqual({}); + }); + + it('should add project filter for single projectId', () => { + const projectIds = ['507f1f77bcf86cd799439011']; + const result = buildBaseMatchForMaterials(projectIds, []); + expect(result.project).toBeDefined(); + expect(result.project.$in).toHaveLength(1); + expect(mongoose.Types.ObjectId).toHaveBeenCalledWith(projectIds[0]); + }); + + it('should add project filter for multiple projectIds', () => { + const projectIds = ['id1', 'id2', 'id3']; + const result = buildBaseMatchForMaterials(projectIds, []); + expect(result.project.$in).toHaveLength(3); + expect(mongoose.Types.ObjectId).toHaveBeenCalledTimes(3); + }); + }); + + describe('Material Type Filtering', () => { + it('should return empty match when materialTypeIds is empty', () => { + const result = buildBaseMatchForMaterials([], []); + expect(result).toEqual({}); + }); + + it('should add itemType filter for single materialTypeId', () => { + const materialTypeIds = ['507f1f77bcf86cd799439011']; + const result = buildBaseMatchForMaterials([], materialTypeIds); + expect(result.itemType).toBeDefined(); + expect(result.itemType.$in).toHaveLength(1); + }); + + it('should add itemType filter for multiple materialTypeIds', () => { + const materialTypeIds = ['id1', 'id2']; + const result = buildBaseMatchForMaterials([], materialTypeIds); + expect(result.itemType.$in).toHaveLength(2); + }); + }); + + describe('Combined Filtering', () => { + it('should include both filters when both provided', () => { + const projectIds = ['project1']; + const materialTypeIds = ['material1']; + const result = buildBaseMatchForMaterials(projectIds, materialTypeIds); + expect(result.project).toBeDefined(); + expect(result.itemType).toBeDefined(); + }); + + it('should return empty match when neither provided', () => { + const result = buildBaseMatchForMaterials([], []); + expect(result).toEqual({}); + }); + + it('should verify correct MongoDB query structure', () => { + const projectIds = ['project1', 'project2']; + const materialTypeIds = ['material1']; + const result = buildBaseMatchForMaterials(projectIds, materialTypeIds); + expect(result).toEqual({ + project: { $in: expect.any(Array) }, + itemType: { $in: expect.any(Array) }, + }); + expect(result.project.$in).toHaveLength(2); + expect(result.itemType.$in).toHaveLength(1); + }); + }); + }); + + describe('getEarliestRelevantMaterialDate', () => { + let mockAggregateExec; + + beforeEach(() => { + mockAggregateExec = jest.fn(); + mockBuildingMaterial.aggregate = jest.fn().mockReturnValue({ + exec: mockAggregateExec, + }); + }); + + describe('With Filters', () => { + it('should query with project filter only', async () => { + mockAggregateExec.mockResolvedValueOnce([{ minDate: new Date('2024-01-01') }]); + mockAggregateExec.mockResolvedValueOnce([{ minDate: new Date('2024-01-02') }]); + + await getEarliestRelevantMaterialDate(['project1'], [], mockBuildingMaterial); + + expect(mockBuildingMaterial.aggregate).toHaveBeenCalledTimes(2); + const firstCall = mockBuildingMaterial.aggregate.mock.calls[0][0]; + expect(firstCall[0].$match.project).toBeDefined(); + }); + + it('should query with material type filter only', async () => { + mockAggregateExec.mockResolvedValueOnce([{ minDate: new Date('2024-01-01') }]); + mockAggregateExec.mockResolvedValueOnce([{ minDate: new Date('2024-01-02') }]); + + await getEarliestRelevantMaterialDate([], ['material1'], mockBuildingMaterial); + + const firstCall = mockBuildingMaterial.aggregate.mock.calls[0][0]; + expect(firstCall[0].$match.itemType).toBeDefined(); + }); + + it('should query with both filters', async () => { + mockAggregateExec.mockResolvedValueOnce([{ minDate: new Date('2024-01-01') }]); + mockAggregateExec.mockResolvedValueOnce([{ minDate: new Date('2024-01-02') }]); + + await getEarliestRelevantMaterialDate(['project1'], ['material1'], mockBuildingMaterial); + + const firstCall = mockBuildingMaterial.aggregate.mock.calls[0][0]; + expect(firstCall[0].$match.project).toBeDefined(); + expect(firstCall[0].$match.itemType).toBeDefined(); + }); + + it('should query all materials when no filters', async () => { + mockAggregateExec.mockResolvedValueOnce([{ minDate: new Date('2024-01-01') }]); + mockAggregateExec.mockResolvedValueOnce([{ minDate: new Date('2024-01-02') }]); + + await getEarliestRelevantMaterialDate([], [], mockBuildingMaterial); + + const firstCall = mockBuildingMaterial.aggregate.mock.calls[0][0]; + expect(firstCall[0].$match).toEqual({}); + }); + }); + + describe('UpdateRecord Query', () => { + it('should find earliest date from updateRecords', async () => { + const earliestDate = new Date('2024-01-01'); + mockAggregateExec.mockResolvedValueOnce([{ minDate: earliestDate }]); + mockAggregateExec.mockResolvedValueOnce([]); + + const result = await getEarliestRelevantMaterialDate([], [], mockBuildingMaterial); + + expect(result).toEqual(earliestDate); + const updateCall = mockBuildingMaterial.aggregate.mock.calls[0][0]; + expect(updateCall[1]).toEqual({ $unwind: '$updateRecord' }); + }); + + it('should return null when no updateRecords exist', async () => { + mockAggregateExec.mockResolvedValueOnce([]); + mockAggregateExec.mockResolvedValueOnce([]); + + const result = await getEarliestRelevantMaterialDate([], [], mockBuildingMaterial); + + expect(result).toBeNull(); + }); + + it('should filter out null dates in updateRecords', async () => { + mockAggregateExec.mockResolvedValueOnce([{ minDate: new Date('2024-01-01') }]); + mockAggregateExec.mockResolvedValueOnce([]); + + await getEarliestRelevantMaterialDate([], [], mockBuildingMaterial); + + const updateCall = mockBuildingMaterial.aggregate.mock.calls[0][0]; + expect(updateCall[2].$match['updateRecord.date']).toEqual({ + $exists: true, + $ne: null, + }); + }); + }); + + describe('PurchaseRecord Query', () => { + it('should find earliest date from approved purchaseRecords', async () => { + const earliestDate = new Date('2024-01-01'); + mockAggregateExec.mockResolvedValueOnce([]); + mockAggregateExec.mockResolvedValueOnce([{ minDate: earliestDate }]); + + const result = await getEarliestRelevantMaterialDate([], [], mockBuildingMaterial); + + expect(result).toEqual(earliestDate); + const purchaseCall = mockBuildingMaterial.aggregate.mock.calls[1][0]; + expect(purchaseCall[2].$match['purchaseRecord.status']).toBe('Approved'); + }); + + it('should return null when only pending purchaseRecords exist', async () => { + mockAggregateExec.mockResolvedValueOnce([]); + mockAggregateExec.mockResolvedValueOnce([]); + + const result = await getEarliestRelevantMaterialDate([], [], mockBuildingMaterial); + + expect(result).toBeNull(); + }); + + it('should only count approved records', async () => { + mockAggregateExec.mockResolvedValueOnce([]); + mockAggregateExec.mockResolvedValueOnce([]); + + await getEarliestRelevantMaterialDate([], [], mockBuildingMaterial); + + const purchaseCall = mockBuildingMaterial.aggregate.mock.calls[1][0]; + expect(purchaseCall[2].$match['purchaseRecord.status']).toBe('Approved'); + }); + }); + + describe('Parallel Execution', () => { + it('should execute both queries in parallel using Promise.all', async () => { + mockAggregateExec.mockResolvedValueOnce([{ minDate: new Date('2024-01-01') }]); + mockAggregateExec.mockResolvedValueOnce([{ minDate: new Date('2024-01-02') }]); + + await getEarliestRelevantMaterialDate([], [], mockBuildingMaterial); + + expect(mockBuildingMaterial.aggregate).toHaveBeenCalledTimes(2); + }); + }); + + describe('Result Comparison', () => { + it('should return updateRecord date when earlier', async () => { + const updateDate = new Date('2024-01-01'); + const purchaseDate = new Date('2024-01-02'); + mockAggregateExec.mockResolvedValueOnce([{ minDate: updateDate }]); + mockAggregateExec.mockResolvedValueOnce([{ minDate: purchaseDate }]); + + const result = await getEarliestRelevantMaterialDate([], [], mockBuildingMaterial); + + expect(result).toEqual(updateDate); + }); + + it('should return purchaseRecord date when earlier', async () => { + const updateDate = new Date('2024-01-02'); + const purchaseDate = new Date('2024-01-01'); + mockAggregateExec.mockResolvedValueOnce([{ minDate: updateDate }]); + mockAggregateExec.mockResolvedValueOnce([{ minDate: purchaseDate }]); + + const result = await getEarliestRelevantMaterialDate([], [], mockBuildingMaterial); + + expect(result).toEqual(purchaseDate); + }); + + it('should return same date when both equal', async () => { + const sameDate = new Date('2024-01-01'); + mockAggregateExec.mockResolvedValueOnce([{ minDate: sameDate }]); + mockAggregateExec.mockResolvedValueOnce([{ minDate: sameDate }]); + + const result = await getEarliestRelevantMaterialDate([], [], mockBuildingMaterial); + + expect(result).toEqual(sameDate); + }); + + it('should return updateRecord date when purchaseRecord is null', async () => { + const updateDate = new Date('2024-01-01'); + mockAggregateExec.mockResolvedValueOnce([{ minDate: updateDate }]); + mockAggregateExec.mockResolvedValueOnce([]); + + const result = await getEarliestRelevantMaterialDate([], [], mockBuildingMaterial); + + expect(result).toEqual(updateDate); + }); + + it('should return purchaseRecord date when updateRecord is null', async () => { + const purchaseDate = new Date('2024-01-01'); + mockAggregateExec.mockResolvedValueOnce([]); + mockAggregateExec.mockResolvedValueOnce([{ minDate: purchaseDate }]); + + const result = await getEarliestRelevantMaterialDate([], [], mockBuildingMaterial); + + expect(result).toEqual(purchaseDate); + }); + + it('should return null when neither has date', async () => { + mockAggregateExec.mockResolvedValueOnce([]); + mockAggregateExec.mockResolvedValueOnce([]); + + const result = await getEarliestRelevantMaterialDate([], [], mockBuildingMaterial); + + expect(result).toBeNull(); + }); + }); + + describe('Error Handling', () => { + it('should log error and return null on database error', async () => { + const error = new Error('Database error'); + mockAggregateExec.mockRejectedValueOnce(error); + + const result = await getEarliestRelevantMaterialDate([], [], mockBuildingMaterial); + + expect(result).toBeNull(); + expect(mockLogException).toHaveBeenCalledWith( + error, + 'getEarliestRelevantMaterialDate', + expect.any(Object), + ); + }); + }); + }); + + describe('aggregateMaterialUsage', () => { + let mockAggregateExec; + + beforeEach(() => { + mockAggregateExec = jest.fn(); + mockBuildingMaterial.aggregate = jest.fn().mockReturnValue({ + exec: mockAggregateExec, + }); + }); + + describe('Pipeline Structure', () => { + it('should verify $match stage with base filters', async () => { + mockAggregateExec.mockResolvedValue([]); + + await aggregateMaterialUsage( + mockBuildingMaterial, + { projectIds: ['project1'], materialTypeIds: [] }, + { effectiveStart: new Date('2024-01-01'), effectiveEnd: new Date('2024-01-31') }, + ); + + const pipeline = mockBuildingMaterial.aggregate.mock.calls[0][0]; + expect(pipeline[0].$match).toBeDefined(); + }); + + it('should verify $unwind stage on updateRecord', async () => { + mockAggregateExec.mockResolvedValue([]); + + await aggregateMaterialUsage(mockBuildingMaterial, DEFAULT_FILTERS, DEFAULT_DATE_RANGE); + + const pipeline = mockBuildingMaterial.aggregate.mock.calls[0][0]; + expect(pipeline[1]).toEqual({ $unwind: '$updateRecord' }); + }); + + it('should verify $match stage for date range and quantityUsed', async () => { + mockAggregateExec.mockResolvedValue([]); + const startDate = new Date('2024-01-01'); + const endDate = new Date('2024-01-31'); + + await aggregateMaterialUsage( + mockBuildingMaterial, + { projectIds: [], materialTypeIds: [] }, + { effectiveStart: startDate, effectiveEnd: endDate }, + ); + + const pipeline = mockBuildingMaterial.aggregate.mock.calls[0][0]; + expect(pipeline[2].$match['updateRecord.date'].$gte).toEqual(startDate); + expect(pipeline[2].$match['updateRecord.date'].$lte).toEqual(endDate); + expect(pipeline[2].$match['updateRecord.quantityUsed']).toBeDefined(); + }); + + it('should verify $group stage by project and itemType', async () => { + mockAggregateExec.mockResolvedValue([]); + + await aggregateMaterialUsage(mockBuildingMaterial, DEFAULT_FILTERS, DEFAULT_DATE_RANGE); + + const pipeline = mockBuildingMaterial.aggregate.mock.calls[0][0]; + expect(pipeline[3].$group._id.project).toBe('$project'); + expect(pipeline[3].$group._id.itemType).toBe('$itemType'); + expect(pipeline[3].$group.quantityUsed).toEqual({ $sum: '$updateRecord.quantityUsed' }); + }); + + it('should verify $project stage for output reshaping', async () => { + mockAggregateExec.mockResolvedValue([]); + + await aggregateMaterialUsage(mockBuildingMaterial, DEFAULT_FILTERS, DEFAULT_DATE_RANGE); + + const pipeline = mockBuildingMaterial.aggregate.mock.calls[0][0]; + expect(pipeline[4].$project._id).toBe(0); + expect(pipeline[4].$project.projectId).toBe('$_id.project'); + expect(pipeline[4].$project.materialTypeId).toBe('$_id.itemType'); + }); + }); + + describe('Filtering', () => { + it('should apply project filter correctly', async () => { + mockAggregateExec.mockResolvedValue([]); + + await aggregateMaterialUsage( + mockBuildingMaterial, + { projectIds: ['project1'], materialTypeIds: [] }, + { effectiveStart: new Date('2024-01-01'), effectiveEnd: new Date('2024-01-31') }, + ); + + const pipeline = mockBuildingMaterial.aggregate.mock.calls[0][0]; + expect(pipeline[0].$match.project).toBeDefined(); + }); + + it('should apply material type filter correctly', async () => { + mockAggregateExec.mockResolvedValue([]); + + await aggregateMaterialUsage( + mockBuildingMaterial, + { projectIds: [], materialTypeIds: ['material1'] }, + DEFAULT_DATE_RANGE, + ); + + const pipeline = mockBuildingMaterial.aggregate.mock.calls[0][0]; + expect(pipeline[0].$match.itemType).toBeDefined(); + }); + }); + + describe('Return Format', () => { + it('should return array of objects with correct structure', async () => { + const mockResults = [ + { + projectId: mongoose.Types.ObjectId('507f1f77bcf86cd799439011'), + materialTypeId: mongoose.Types.ObjectId('507f1f77bcf86cd799439012'), + quantityUsed: 100, + }, + ]; + mockAggregateExec.mockResolvedValue(mockResults); + + const result = await aggregateMaterialUsage( + mockBuildingMaterial, + DEFAULT_FILTERS, + DEFAULT_DATE_RANGE, + ); + + expect(result).toEqual(mockResults); + expect(result[0]).toHaveProperty('projectId'); + expect(result[0]).toHaveProperty('materialTypeId'); + expect(result[0]).toHaveProperty('quantityUsed'); + }); + }); + + describe('Error Handling', () => { + it('should log error and return empty array on database error', async () => { + const error = new Error('Database error'); + mockAggregateExec.mockRejectedValue(error); + + const result = await aggregateMaterialUsage( + mockBuildingMaterial, + DEFAULT_FILTERS, + DEFAULT_DATE_RANGE, + ); + + expect(result).toEqual([]); + expect(mockLogException).toHaveBeenCalledWith( + error, + 'aggregateMaterialUsage', + expect.any(Object), + ); + }); + }); + + describe('Edge Cases', () => { + it('should return empty array when no matching records', async () => { + mockAggregateExec.mockResolvedValue([]); + + const result = await aggregateMaterialUsage( + mockBuildingMaterial, + DEFAULT_FILTERS, + DEFAULT_DATE_RANGE, + ); + + expect(result).toEqual([]); + }); + }); + }); + + describe('aggregateMaterialCost', () => { + let mockAggregateExec; + + beforeEach(() => { + mockAggregateExec = jest.fn(); + mockBuildingMaterial.aggregate = jest.fn().mockReturnValue({ + exec: mockAggregateExec, + }); + }); + + describe('Pipeline Structure', () => { + it('should verify $match stage for status filter', async () => { + mockAggregateExec.mockResolvedValue([]); + + await aggregateMaterialCost(mockBuildingMaterial, DEFAULT_FILTERS, DEFAULT_DATE_RANGE); + + const pipeline = mockBuildingMaterial.aggregate.mock.calls[0][0]; + expect(pipeline[2].$match['purchaseRecord.status']).toBe('Approved'); + }); + + it('should verify $group stage with cost calculation', async () => { + mockAggregateExec.mockResolvedValue([]); + + await aggregateMaterialCost(mockBuildingMaterial, DEFAULT_FILTERS, DEFAULT_DATE_RANGE); + + const pipeline = mockBuildingMaterial.aggregate.mock.calls[0][0]; + // Find the $group stage (Stage 4 in the pipeline, index 5) + const groupStage = pipeline.find((stage) => stage.$group); + expect(groupStage.$group.totalCost.$sum.$multiply).toEqual([ + '$purchaseRecord.unitPrice', + '$purchaseRecord.quantity', + ]); + }); + }); + + describe('Status Filtering', () => { + it('should only include Approved status', async () => { + mockAggregateExec.mockResolvedValue([]); + + await aggregateMaterialCost(mockBuildingMaterial, DEFAULT_FILTERS, DEFAULT_DATE_RANGE); + + const pipeline = mockBuildingMaterial.aggregate.mock.calls[0][0]; + expect(pipeline[2].$match['purchaseRecord.status']).toBe('Approved'); + }); + }); + + describe('Field Validation', () => { + it('should require unitPrice and quantity to exist and be numbers', async () => { + mockAggregateExec.mockResolvedValue([]); + + await aggregateMaterialCost(mockBuildingMaterial, DEFAULT_FILTERS, DEFAULT_DATE_RANGE); + + const pipeline = mockBuildingMaterial.aggregate.mock.calls[0][0]; + // Stage 3: filter for quantity validation + expect(pipeline[2].$match['purchaseRecord.quantity'].$exists).toBe(true); + expect(pipeline[2].$match['purchaseRecord.quantity'].$type).toBe('number'); + // Stage 3.5: $addFields for unitPrice conversion (uses $toDouble) + expect(pipeline[3].$addFields['purchaseRecord.unitPrice']).toBeDefined(); + // Stage 3.6: filter for unitPrice >= 0 + expect(pipeline[4].$match['purchaseRecord.unitPrice'].$type).toBe('number'); + expect(pipeline[4].$match['purchaseRecord.unitPrice'].$gte).toBe(0); + }); + }); + + describe('Return Format', () => { + it('should return array with projectId, materialTypeId, totalCost', async () => { + const mockResults = [ + { + projectId: mongoose.Types.ObjectId('507f1f77bcf86cd799439011'), + materialTypeId: mongoose.Types.ObjectId('507f1f77bcf86cd799439012'), + totalCost: 5000, + }, + ]; + mockAggregateExec.mockResolvedValue(mockResults); + + const result = await aggregateMaterialCost( + mockBuildingMaterial, + DEFAULT_FILTERS, + DEFAULT_DATE_RANGE, + ); + + expect(result).toEqual(mockResults); + expect(result[0]).toHaveProperty('totalCost'); + }); + }); + + describe('Error Handling', () => { + it('should log error and return empty array on database error', async () => { + const error = new Error('Database error'); + mockAggregateExec.mockRejectedValue(error); + + const result = await aggregateMaterialCost( + mockBuildingMaterial, + DEFAULT_FILTERS, + DEFAULT_DATE_RANGE, + ); + + expect(result).toEqual([]); + expect(mockLogException).toHaveBeenCalledWith( + error, + 'aggregateMaterialCost', + expect.any(Object), + ); + }); + }); + + describe('Edge Cases', () => { + it('should return empty array when no approved purchases', async () => { + mockAggregateExec.mockResolvedValue([]); + + const result = await aggregateMaterialCost( + mockBuildingMaterial, + DEFAULT_FILTERS, + DEFAULT_DATE_RANGE, + ); + + expect(result).toEqual([]); + }); + }); + }); + + describe('calculateCostPerUnit', () => { + it('should calculate valid division correctly', () => { + const result = calculateCostPerUnit(100, 10); + expect(result).toBe(10); + }); + + it('should return null for division by zero (quantityUsed = 0)', () => { + const result = calculateCostPerUnit(100, 0); + expect(result).toBeNull(); + }); + + it('should return null when quantityUsed is null', () => { + const result = calculateCostPerUnit(100, null); + expect(result).toBeNull(); + }); + + it('should return null when quantityUsed is undefined', () => { + const result = calculateCostPerUnit(100, undefined); + expect(result).toBeNull(); + }); + + it('should return null when result is NaN', () => { + const result = calculateCostPerUnit('invalid', 10); + expect(result).toBeNull(); + }); + + it('should return null when result is Infinity', () => { + const result = calculateCostPerUnit(Infinity, 10); + expect(result).toBeNull(); + }); + + it('should return null when result is -Infinity', () => { + const result = calculateCostPerUnit(-Infinity, 10); + expect(result).toBeNull(); + }); + + it('should round to 2 decimal places', () => { + const result = calculateCostPerUnit(100, 3); + expect(result).toBe(33.33); + }); + + it('should handle very small result', () => { + const result = calculateCostPerUnit(1, 1000000); + expect(result).toBe(0); + }); + + it('should handle very large result', () => { + const result = calculateCostPerUnit(1000000, 1); + expect(result).toBe(1000000); + }); + }); + + describe('calculateTotalCostK', () => { + it('should divide cost by 1000', () => { + const result = calculateTotalCostK(5000); + expect(result).toBe(5); + }); + + it('should return 0 for zero cost', () => { + const result = calculateTotalCostK(0); + expect(result).toBe(0); + }); + + it('should handle very small cost', () => { + const result = calculateTotalCostK(1); + expect(result).toBe(0.001); + }); + + it('should handle very large cost', () => { + const result = calculateTotalCostK(1000000); + expect(result).toBe(1000); + }); + }); + + describe('objectIdToString', () => { + it('should convert valid ObjectId to string', () => { + const mockObjectId = { toString: () => '507f1f77bcf86cd799439011' }; + const result = objectIdToString(mockObjectId); + expect(result).toBe('507f1f77bcf86cd799439011'); + }); + + it('should return string input as-is', () => { + const result = objectIdToString('507f1f77bcf86cd799439011'); + expect(result).toBe('507f1f77bcf86cd799439011'); + }); + + it('should return empty string for null input', () => { + const result = objectIdToString(null); + expect(result).toBe(''); + }); + + it('should return empty string for undefined input', () => { + const result = objectIdToString(undefined); + expect(result).toBe(''); + }); + + it('should call toString method when available', () => { + const mockObjectId = { toString: jest.fn(() => 'test-id') }; + objectIdToString(mockObjectId); + expect(mockObjectId.toString).toHaveBeenCalled(); + }); + }); + + describe('buildCostCorrelationResponse', () => { + let mockProjectFindExec; + let mockMaterialTypeFindExec; + + beforeEach(() => { + mockProjectFindExec = jest.fn(); + mockMaterialTypeFindExec = jest.fn(); + mockBuildingProject.find = jest.fn().mockReturnValue({ + exec: mockProjectFindExec, + }); + mockBuildingInventoryType.find = jest.fn().mockReturnValue({ + exec: mockMaterialTypeFindExec, + }); + }); + + describe('Lookup Map Creation', () => { + it('should collect unique project IDs from usage and cost data', async () => { + const usageData = [ + { projectId: 'project1', materialTypeId: 'material1', quantityUsed: 10 }, + ]; + const costData = [{ projectId: 'project1', materialTypeId: 'material1', totalCost: 100 }]; + mockProjectFindExec.mockResolvedValue([]); + mockMaterialTypeFindExec.mockResolvedValue([]); + + await buildCostCorrelationResponse( + usageData, + costData, + defaultRequestParams(), + defaultModels(mockBuildingProject, mockBuildingInventoryType), + ); + + expect(mockBuildingProject.find).toHaveBeenCalled(); + }); + + it('should include explicitly requested IDs', async () => { + const usageData = []; + const costData = []; + mockProjectFindExec.mockResolvedValue([]); + mockMaterialTypeFindExec.mockResolvedValue([]); + + await buildCostCorrelationResponse( + usageData, + costData, + { + ...defaultRequestParams(), + projectIds: ['project1'], + materialTypeIds: ['material1'], + }, + defaultModels(mockBuildingProject, mockBuildingInventoryType), + ); + + expect(mockBuildingProject.find).toHaveBeenCalled(); + }); + }); + + describe('Project Name Lookup', () => { + it('should create map of projectId to projectName', async () => { + const mockProjects = [ + { _id: { toString: () => 'project1' }, name: 'Project 1' }, + { _id: { toString: () => 'project2' }, name: 'Project 2' }, + ]; + mockProjectFindExec.mockResolvedValue(mockProjects); + mockMaterialTypeFindExec.mockResolvedValue([]); + + const result = await buildCostCorrelationResponse( + [], + [], + defaultRequestParams(), + defaultModels(mockBuildingProject, mockBuildingInventoryType), + ); + + expect(result.meta).toBeDefined(); + }); + + it('should use ID as fallback when project not found', async () => { + mockProjectFindExec.mockResolvedValue([]); + mockMaterialTypeFindExec.mockResolvedValue([]); + + const result = await buildCostCorrelationResponse( + [{ projectId: 'missing-project', materialTypeId: 'material1', quantityUsed: 10 }], + [], + defaultRequestParams(), + defaultModels(mockBuildingProject, mockBuildingInventoryType), + ); + + expect(result.data).toBeDefined(); + }); + }); + + describe('Material Type Lookup', () => { + it('should create map of materialTypeId to name and unit', async () => { + const mockMaterials = [ + { _id: { toString: () => 'material1' }, name: 'Material 1', unit: 'kg' }, + ]; + mockProjectFindExec.mockResolvedValue([]); + mockMaterialTypeFindExec.mockResolvedValue(mockMaterials); + + const result = await buildCostCorrelationResponse( + [], + [], + defaultRequestParams(), + defaultModels(mockBuildingProject, mockBuildingInventoryType), + ); + + expect(result.meta).toBeDefined(); + }); + }); + + describe('Data Merging', () => { + it('should merge usage and cost data by composite key', async () => { + const usageData = [ + { projectId: 'project1', materialTypeId: 'material1', quantityUsed: 10 }, + ]; + const costData = [{ projectId: 'project1', materialTypeId: 'material1', totalCost: 100 }]; + mockProjectFindExec.mockResolvedValue([]); + mockMaterialTypeFindExec.mockResolvedValue([]); + + const result = await buildCostCorrelationResponse( + usageData, + costData, + defaultRequestParams(), + defaultModels(mockBuildingProject, mockBuildingInventoryType), + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].byMaterialType[0].quantityUsed).toBe(10); + expect(result.data[0].byMaterialType[0].totalCost).toBe(100); + }); + + it('should handle usage-only entries', async () => { + const usageData = [ + { projectId: 'project1', materialTypeId: 'material1', quantityUsed: 10 }, + ]; + mockProjectFindExec.mockResolvedValue([]); + mockMaterialTypeFindExec.mockResolvedValue([]); + + const result = await buildCostCorrelationResponse( + usageData, + [], + defaultRequestParams(), + defaultModels(mockBuildingProject, mockBuildingInventoryType), + ); + + expect(result.data[0].byMaterialType[0].quantityUsed).toBe(10); + expect(result.data[0].byMaterialType[0].totalCost).toBe(0); + }); + + it('should handle cost-only entries', async () => { + const costData = [{ projectId: 'project1', materialTypeId: 'material1', totalCost: 100 }]; + mockProjectFindExec.mockResolvedValue([]); + mockMaterialTypeFindExec.mockResolvedValue([]); + + const result = await buildCostCorrelationResponse( + [], + costData, + defaultRequestParams(), + defaultModels(mockBuildingProject, mockBuildingInventoryType), + ); + + expect(result.data[0].byMaterialType[0].quantityUsed).toBe(0); + expect(result.data[0].byMaterialType[0].totalCost).toBe(100); + }); + }); + + describe('Project Totals Calculation', () => { + it('should sum quantityUsed across all materials in project', async () => { + const usageData = [ + { projectId: 'project1', materialTypeId: 'material1', quantityUsed: 10 }, + { projectId: 'project1', materialTypeId: 'material2', quantityUsed: 20 }, + ]; + mockProjectFindExec.mockResolvedValue([]); + mockMaterialTypeFindExec.mockResolvedValue([]); + + const result = await buildCostCorrelationResponse( + usageData, + [], + defaultRequestParams(), + defaultModels(mockBuildingProject, mockBuildingInventoryType), + ); + + expect(result.data[0].totals.quantityUsed).toBe(30); + }); + + it('should sum totalCost across all materials in project', async () => { + const costData = [ + { projectId: 'project1', materialTypeId: 'material1', totalCost: 100 }, + { projectId: 'project1', materialTypeId: 'material2', totalCost: 200 }, + ]; + mockProjectFindExec.mockResolvedValue([]); + mockMaterialTypeFindExec.mockResolvedValue([]); + + const result = await buildCostCorrelationResponse( + [], + costData, + defaultRequestParams(), + defaultModels(mockBuildingProject, mockBuildingInventoryType), + ); + + expect(result.data[0].totals.totalCost).toBe(300); + }); + + it('should calculate project-level costPerUnit', async () => { + const usageData = [ + { projectId: 'project1', materialTypeId: 'material1', quantityUsed: 10 }, + ]; + const costData = [{ projectId: 'project1', materialTypeId: 'material1', totalCost: 100 }]; + mockProjectFindExec.mockResolvedValue([]); + mockMaterialTypeFindExec.mockResolvedValue([]); + + const result = await buildCostCorrelationResponse( + usageData, + costData, + defaultRequestParams(), + defaultModels(mockBuildingProject, mockBuildingInventoryType), + ); + + expect(result.data[0].totals.costPerUnit).toBe(10); + }); + }); + + describe('Explicitly Selected Projects', () => { + it('should create entry for projects with no data', async () => { + mockProjectFindExec.mockResolvedValue([ + { _id: { toString: () => 'project1' }, name: 'Project 1' }, + ]); + mockMaterialTypeFindExec.mockResolvedValue([]); + + const result = await buildCostCorrelationResponse( + [], + [], + { ...defaultRequestParams(), projectIds: ['project1'] }, + defaultModels(mockBuildingProject, mockBuildingInventoryType), + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].totals.quantityUsed).toBe(0); + expect(result.data[0].totals.totalCost).toBe(0); + }); + }); + + describe('Sorting', () => { + it('should sort projects alphabetically by name', async () => { + mockProjectFindExec.mockResolvedValue([ + { _id: { toString: () => 'project2' }, name: 'Zebra Project' }, + { _id: { toString: () => 'project1' }, name: 'Alpha Project' }, + ]); + mockMaterialTypeFindExec.mockResolvedValue([]); + + const result = await buildCostCorrelationResponse( + [ + { projectId: 'project1', materialTypeId: 'material1', quantityUsed: 10 }, + { projectId: 'project2', materialTypeId: 'material1', quantityUsed: 20 }, + ], + [], + defaultRequestParams(), + defaultModels(mockBuildingProject, mockBuildingInventoryType), + ); + + expect(result.data[0].projectName).toBe('Alpha Project'); + expect(result.data[1].projectName).toBe('Zebra Project'); + }); + }); + + describe('Meta Object Construction', () => { + it('should include request parameters in meta', async () => { + mockProjectFindExec.mockResolvedValue([]); + mockMaterialTypeFindExec.mockResolvedValue([]); + + const result = await buildCostCorrelationResponse( + [], + [], + { + projectIds: ['project1'], + materialTypeIds: ['material1'], + dateRangeMeta: { + originalInputs: { startDateInput: '2024-01-01', endDateInput: '2024-01-31' }, + effectiveStart: new Date('2024-01-01'), + effectiveEnd: new Date('2024-01-31'), + endCappedToNowMinus5Min: false, + defaultsApplied: { startDate: false, endDate: false }, + }, + }, + { + BuildingProject: mockBuildingProject, + BuildingInventoryType: mockBuildingInventoryType, + }, + ); + + expect(result.meta.request.projectIds).toEqual(['project1']); + expect(result.meta.request.materialTypeIds).toEqual(['material1']); + expect(result.meta.request.startDateInput).toBe('2024-01-01'); + expect(result.meta.request.endDateInput).toBe('2024-01-31'); + }); + + it('should include range information in meta', async () => { + const startDate = new Date('2024-01-01'); + const endDate = new Date('2024-01-31'); + mockProjectFindExec.mockResolvedValue([]); + mockMaterialTypeFindExec.mockResolvedValue([]); + + const result = await buildCostCorrelationResponse( + [], + [], + { + projectIds: [], + materialTypeIds: [], + dateRangeMeta: { + effectiveStart: startDate, + effectiveEnd: endDate, + endCappedToNowMinus5Min: true, + defaultsApplied: { startDate: true, endDate: false }, + }, + }, + { + BuildingProject: mockBuildingProject, + BuildingInventoryType: mockBuildingInventoryType, + }, + ); + + expect(result.meta.range.effectiveStart).toBe(startDate.toISOString()); + expect(result.meta.range.effectiveEnd).toBe(endDate.toISOString()); + expect(result.meta.range.endCappedToNowMinus5Min).toBe(true); + }); + + it('should include units information in meta', async () => { + mockProjectFindExec.mockResolvedValue([]); + mockMaterialTypeFindExec.mockResolvedValue([]); + + const result = await buildCostCorrelationResponse( + [], + [], + defaultRequestParams(), + defaultModels(mockBuildingProject, mockBuildingInventoryType), + ); + + expect(result.meta.units.currency).toBe('USD'); + expect(result.meta.units.costScale.raw).toBe(1); + expect(result.meta.units.costScale.k).toBe(1000); + }); + }); + + describe('Response Structure', () => { + it('should return object with meta and data properties', async () => { + mockProjectFindExec.mockResolvedValue([]); + mockMaterialTypeFindExec.mockResolvedValue([]); + + const result = await buildCostCorrelationResponse( + [], + [], + defaultRequestParams(), + defaultModels(mockBuildingProject, mockBuildingInventoryType), + ); + + expect(result).toHaveProperty('meta'); + expect(result).toHaveProperty('data'); + expect(Array.isArray(result.data)).toBe(true); + }); + }); + + describe('Error Handling', () => { + it('should handle lookup query failures gracefully', async () => { + const error = new Error('Lookup error'); + mockProjectFindExec.mockRejectedValue(error); + mockMaterialTypeFindExec.mockResolvedValue([]); + + const result = await buildCostCorrelationResponse( + [{ projectId: 'project1', materialTypeId: 'material1', quantityUsed: 10 }], + [], + defaultRequestParams(), + defaultModels(mockBuildingProject, mockBuildingInventoryType), + ); + + expect(result).toBeDefined(); + expect(result.data).toBeDefined(); + // The logger is called from buildLookupMaps helper function + expect(mockLogException).toHaveBeenCalledWith( + error, + 'buildLookupMaps - lookup queries', + expect.any(Object), + ); + }); + + // Note: Error handling for the outer catch block is tested indirectly + // through the lookup error test above. The outer catch block structure + // is verified to return proper empty response structure in that test. + }); + + describe('Edge Cases', () => { + it('should handle empty usage and cost data', async () => { + mockProjectFindExec.mockResolvedValue([]); + mockMaterialTypeFindExec.mockResolvedValue([]); + + const result = await buildCostCorrelationResponse( + [], + [], + defaultRequestParams(), + defaultModels(mockBuildingProject, mockBuildingInventoryType), + ); + + expect(result.data).toEqual([]); + }); + }); + }); +}); diff --git a/src/utilities/__tests__/queryParamParser.test.js b/src/utilities/__tests__/queryParamParser.test.js new file mode 100644 index 000000000..f19ae0388 --- /dev/null +++ b/src/utilities/__tests__/queryParamParser.test.js @@ -0,0 +1,386 @@ +// Mock mongoose before requiring the module +const mockIsValid = jest.fn(); +jest.mock('mongoose', () => ({ + Types: { + ObjectId: { + isValid: mockIsValid, + }, + }, +})); + +const { parseMultiSelectQueryParam } = require('../queryParamParser'); + +describe('queryParamParser', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Default: all ObjectIds are valid + mockIsValid.mockReturnValue(true); + }); + + describe('parseMultiSelectQueryParam', () => { + describe('Category 1: Basic Parameter Extraction', () => { + it('should return empty array when parameter does not exist (undefined)', () => { + const req = { query: {} }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual([]); + }); + + it('should handle null parameter by converting to string "null"', () => { + // Note: In Express, query params are typically strings, not null + // This test covers edge case where null might be passed + mockIsValid.mockReturnValue(false); // "null" string is not a valid ObjectId + const req = { query: { projectId: null } }; + expect(() => parseMultiSelectQueryParam(req, 'projectId', true)).toThrow(); + // When validation is disabled, null becomes "null" string + const resultNoValidation = parseMultiSelectQueryParam(req, 'projectId', false); + expect(resultNoValidation).toEqual(['null']); + }); + + it('should return empty array when parameter is empty string', () => { + const req = { query: { projectId: '' } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual([]); + }); + + it('should return empty array when parameter is whitespace-only string', () => { + const req = { query: { projectId: ' ' } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual([]); + }); + }); + + describe('Category 2: Single Value Parsing', () => { + it('should return array with one trimmed element for single string value', () => { + mockIsValid.mockReturnValue(true); + const req = { query: { projectId: '507f1f77bcf86cd799439011' } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual(['507f1f77bcf86cd799439011']); + }); + + it('should trim leading and trailing whitespace from single value', () => { + mockIsValid.mockReturnValue(true); + const req = { query: { projectId: ' 507f1f77bcf86cd799439011 ' } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual(['507f1f77bcf86cd799439011']); + }); + + it('should convert numeric value to string and return in array', () => { + mockIsValid.mockReturnValue(true); + const req = { query: { projectId: 123 } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual(['123']); + }); + + it('should convert boolean value to string and return in array', () => { + mockIsValid.mockReturnValue(true); + const req = { query: { projectId: true } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual(['true']); + }); + }); + + describe('Category 3: Comma-Separated Value Parsing', () => { + it('should split comma-separated string into array', () => { + mockIsValid.mockReturnValue(true); + const req = { query: { projectId: 'id1,id2,id3' } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual(['id1', 'id2', 'id3']); + }); + + it('should trim whitespace from comma-separated values', () => { + mockIsValid.mockReturnValue(true); + const req = { query: { projectId: 'id1, id2 , id3' } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual(['id1', 'id2', 'id3']); + }); + + it('should filter out empty values in comma-separated string', () => { + mockIsValid.mockReturnValue(true); + const req = { query: { projectId: 'id1,,id2' } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual(['id1', 'id2']); + }); + + it('should return empty array for comma-separated string with only commas', () => { + const req = { query: { projectId: ',,' } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual([]); + }); + + it('should filter out whitespace-only values in comma-separated string', () => { + mockIsValid.mockReturnValue(true); + const req = { query: { projectId: 'id1, ,id2' } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual(['id1', 'id2']); + }); + }); + + describe('Category 4: Array Value Parsing', () => { + it('should return trimmed array for array of strings', () => { + mockIsValid.mockReturnValue(true); + const req = { query: { projectId: ['id1', 'id2'] } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual(['id1', 'id2']); + }); + + it('should trim whitespace from array elements', () => { + mockIsValid.mockReturnValue(true); + const req = { query: { projectId: [' id1 ', ' id2 '] } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual(['id1', 'id2']); + }); + + it('should convert mixed types in array to strings and trim', () => { + mockIsValid.mockReturnValue(true); + const req = { query: { projectId: ['id1', 123, true] } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual(['id1', '123', 'true']); + }); + + it('should filter out empty strings from array', () => { + mockIsValid.mockReturnValue(true); + const req = { query: { projectId: ['id1', '', 'id2'] } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual(['id1', 'id2']); + }); + + it('should return empty array for empty array input', () => { + const req = { query: { projectId: [] } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual([]); + }); + }); + + describe('Category 5: ObjectId Validation (requireObjectId = true)', () => { + it('should return array when all ObjectIds are valid', () => { + mockIsValid.mockReturnValue(true); + const req = { query: { projectId: '507f1f77bcf86cd799439011' } }; + const result = parseMultiSelectQueryParam(req, 'projectId', true); + expect(result).toEqual(['507f1f77bcf86cd799439011']); + expect(mockIsValid).toHaveBeenCalledWith('507f1f77bcf86cd799439011'); + }); + + it('should throw error with one invalid ObjectId', () => { + mockIsValid.mockImplementation((value) => value !== 'invalid'); + const req = { query: { projectId: 'invalid' } }; + expect(() => parseMultiSelectQueryParam(req, 'projectId', true)).toThrow(); + try { + parseMultiSelectQueryParam(req, 'projectId', true); + } catch (error) { + expect(error.type).toBe('OBJECTID_VALIDATION_ERROR'); + expect(error.invalidValues).toEqual(['invalid']); + expect(error.paramName).toBe('projectId'); + expect(error.message).toContain('Invalid projectId format'); + expect(error.message).toContain('invalid'); + } + }); + + it('should throw error listing all invalid ObjectIds', () => { + mockIsValid.mockImplementation((value) => value === 'valid1' || value === 'valid2'); + const req = { query: { projectId: 'valid1,invalid1,valid2,invalid2' } }; + expect(() => parseMultiSelectQueryParam(req, 'projectId', true)).toThrow(); + try { + parseMultiSelectQueryParam(req, 'projectId', true); + } catch (error) { + expect(error.type).toBe('OBJECTID_VALIDATION_ERROR'); + expect(error.invalidValues).toEqual(['invalid1', 'invalid2']); + expect(error.invalidValues.length).toBe(2); + } + }); + + it('should throw error listing only invalid values when mixed valid and invalid', () => { + mockIsValid.mockImplementation((value) => value === 'valid1' || value === 'valid2'); + const req = { query: { projectId: 'valid1,invalid1,valid2,invalid2' } }; + expect(() => parseMultiSelectQueryParam(req, 'projectId', true)).toThrow(); + try { + parseMultiSelectQueryParam(req, 'projectId', true); + } catch (error) { + expect(error.invalidValues).not.toContain('valid1'); + expect(error.invalidValues).not.toContain('valid2'); + expect(error.invalidValues).toContain('invalid1'); + expect(error.invalidValues).toContain('invalid2'); + } + }); + + it('should return empty array when validation is required but array is empty', () => { + const req = { query: { projectId: [] } }; + const result = parseMultiSelectQueryParam(req, 'projectId', true); + expect(result).toEqual([]); + expect(mockIsValid).not.toHaveBeenCalled(); + }); + }); + + describe('Category 6: ObjectId Validation Disabled (requireObjectId = false)', () => { + it('should return array as-is when validation is disabled for valid ObjectIds', () => { + const req = { query: { projectId: '507f1f77bcf86cd799439011' } }; + const result = parseMultiSelectQueryParam(req, 'projectId', false); + expect(result).toEqual(['507f1f77bcf86cd799439011']); + expect(mockIsValid).not.toHaveBeenCalled(); + }); + + it('should return array as-is when validation is disabled for invalid ObjectIds', () => { + const req = { query: { projectId: 'invalid-id' } }; + const result = parseMultiSelectQueryParam(req, 'projectId', false); + expect(result).toEqual(['invalid-id']); + expect(mockIsValid).not.toHaveBeenCalled(); + }); + + it('should return all values as strings when validation is disabled for mixed values', () => { + const req = { query: { projectId: ['valid1', 'invalid1', 123, true] } }; + const result = parseMultiSelectQueryParam(req, 'projectId', false); + expect(result).toEqual(['valid1', 'invalid1', '123', 'true']); + expect(mockIsValid).not.toHaveBeenCalled(); + }); + }); + + describe('Category 7: Error Handling', () => { + it('should throw error with correct structure: type property', () => { + mockIsValid.mockReturnValue(false); + const req = { query: { projectId: 'invalid' } }; + expect(() => { + parseMultiSelectQueryParam(req, 'projectId', true); + }).toThrow(); + try { + parseMultiSelectQueryParam(req, 'projectId', true); + } catch (error) { + expect(error.type).toBe('OBJECTID_VALIDATION_ERROR'); + } + }); + + it('should throw error with correct structure: message includes parameter name and invalid values', () => { + mockIsValid.mockReturnValue(false); + const req = { query: { projectId: 'invalid1,invalid2' } }; + expect(() => { + parseMultiSelectQueryParam(req, 'projectId', true); + }).toThrow(); + try { + parseMultiSelectQueryParam(req, 'projectId', true); + } catch (error) { + expect(error.message).toContain('projectId'); + expect(error.message).toContain('invalid1'); + expect(error.message).toContain('invalid2'); + } + }); + + it('should throw error with correct structure: invalidValues array', () => { + mockIsValid.mockReturnValue(false); + const req = { query: { projectId: 'invalid' } }; + expect(() => { + parseMultiSelectQueryParam(req, 'projectId', true); + }).toThrow(); + try { + parseMultiSelectQueryParam(req, 'projectId', true); + } catch (error) { + expect(Array.isArray(error.invalidValues)).toBe(true); + expect(error.invalidValues).toEqual(['invalid']); + } + }); + + it('should throw error with correct structure: paramName property', () => { + mockIsValid.mockReturnValue(false); + const req = { query: { materialType: 'invalid' } }; + expect(() => { + parseMultiSelectQueryParam(req, 'materialType', true); + }).toThrow(); + try { + parseMultiSelectQueryParam(req, 'materialType', true); + } catch (error) { + expect(error.paramName).toBe('materialType'); + } + }); + }); + + describe('Category 8: Edge Cases', () => { + it('should convert number parameter to string', () => { + mockIsValid.mockReturnValue(true); + const req = { query: { projectId: 12345 } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual(['12345']); + expect(typeof result[0]).toBe('string'); + }); + + it('should convert boolean parameter to string', () => { + mockIsValid.mockReturnValue(true); + const req = { query: { projectId: false } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual(['false']); + expect(typeof result[0]).toBe('string'); + }); + + it('should convert object parameter to string', () => { + mockIsValid.mockReturnValue(false); + const req = { query: { projectId: { key: 'value' } } }; + expect(() => parseMultiSelectQueryParam(req, 'projectId', true)).toThrow(); + try { + parseMultiSelectQueryParam(req, 'projectId', true); + } catch (error) { + expect(error.invalidValues[0]).toContain('[object Object]'); + } + }); + + it('should handle very long string values correctly', () => { + mockIsValid.mockReturnValue(true); + const longString = 'a'.repeat(1000); + const req = { query: { projectId: longString } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual([longString]); + expect(result[0].length).toBe(1000); + }); + + it('should preserve special characters in values (validation will catch if invalid)', () => { + mockIsValid.mockReturnValue(false); + const req = { query: { projectId: 'id@#$%^&*()' } }; + expect(() => parseMultiSelectQueryParam(req, 'projectId', true)).toThrow(); + try { + parseMultiSelectQueryParam(req, 'projectId', true); + } catch (error) { + expect(error.invalidValues[0]).toBe('id@#$%^&*()'); + } + }); + + it('should handle null in array elements', () => { + mockIsValid.mockReturnValue(true); + const req = { query: { projectId: ['id1', null, 'id2'] } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual(['id1', 'null', 'id2']); + }); + + it('should handle undefined in array elements', () => { + mockIsValid.mockReturnValue(true); + const req = { query: { projectId: ['id1', undefined, 'id2'] } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual(['id1', 'undefined', 'id2']); + }); + }); + + describe('Integration: Complex Scenarios', () => { + it('should handle comma-separated string with valid ObjectIds', () => { + mockIsValid.mockReturnValue(true); + const req = { query: { projectId: '507f1f77bcf86cd799439011,507f191e810c19729de860ea' } }; + const result = parseMultiSelectQueryParam(req, 'projectId', true); + expect(result).toHaveLength(2); + expect(result[0]).toBe('507f1f77bcf86cd799439011'); + expect(result[1]).toBe('507f191e810c19729de860ea'); + }); + + it('should handle array with valid ObjectIds', () => { + mockIsValid.mockReturnValue(true); + const req = { + query: { + projectId: ['507f1f77bcf86cd799439011', '507f191e810c19729de860ea'], + }, + }; + const result = parseMultiSelectQueryParam(req, 'projectId', true); + expect(result).toHaveLength(2); + expect(mockIsValid).toHaveBeenCalledTimes(2); + }); + + it('should handle mixed whitespace and valid values', () => { + mockIsValid.mockReturnValue(true); + const req = { query: { projectId: ' id1 , id2 , id3 ' } }; + const result = parseMultiSelectQueryParam(req, 'projectId'); + expect(result).toEqual(['id1', 'id2', 'id3']); + }); + }); + }); +}); diff --git a/src/utilities/materialCostCorrelationDateUtils.js b/src/utilities/materialCostCorrelationDateUtils.js new file mode 100644 index 000000000..584c830c1 --- /dev/null +++ b/src/utilities/materialCostCorrelationDateUtils.js @@ -0,0 +1,416 @@ +/** + * Date Parsing and Normalization Utilities for Material Cost Correlation + * + * Provides comprehensive date parsing (multiple formats), UTC normalization, + * and date range computation. All date operations use UTC timezone. + */ + +// Constants for date normalization +const SECONDS_IN_MINUTE = 60; +const MILLISECONDS_IN_SECOND = 1000; +const MINUTES_IN_MILLISECONDS = SECONDS_IN_MINUTE * MILLISECONDS_IN_SECOND; +const MINUTES_TO_SUBTRACT = 5; +const FIVE_MINUTES_IN_MILLISECONDS = MINUTES_TO_SUBTRACT * MINUTES_IN_MILLISECONDS; +const END_OF_DAY_HOUR = 23; +const END_OF_DAY_MINUTE = 59; +const END_OF_DAY_SECOND = 59; +const END_OF_DAY_MILLISECOND = 999; + +/** + * Parse various date input formats into a JavaScript Date object. + * Handles ISO strings, American formats (MM-DD-YYYY, MM/DD/YYYY), and Date objects. + * + * @param {string|Date} dateInput - Date input in various formats + * @returns {Date} Parsed Date object + * @throws {Object} Structured error object with type 'DATE_PARSE_ERROR' + */ +function parseDateInput(dateInput) { + // Handle Date objects (pass through if valid) + if (dateInput instanceof Date) { + if (!Number.isNaN(dateInput.getTime())) { + return dateInput; + } + // Invalid Date object + const error = { + type: 'DATE_PARSE_ERROR', + message: `Invalid Date object provided.`, + originalInput: dateInput, + acceptedFormats: [ + 'YYYY-MM-DD', + 'MM-DD-YYYY', + 'MM/DD/YYYY', + 'ISO 8601 strings', + 'Date objects', + ], + }; + throw error; + } + + // Handle non-string inputs + if (typeof dateInput !== 'string') { + const error = { + type: 'DATE_PARSE_ERROR', + message: `Invalid date input type. Expected string or Date object, got ${typeof dateInput}.`, + originalInput: dateInput, + acceptedFormats: [ + 'YYYY-MM-DD', + 'MM-DD-YYYY', + 'MM/DD/YYYY', + 'ISO 8601 strings', + 'Date objects', + ], + }; + throw error; + } + + // Handle empty strings + const trimmedInput = dateInput.trim(); + if (trimmedInput === '') { + const error = { + type: 'DATE_PARSE_ERROR', + message: 'Empty date string provided.', + originalInput: dateInput, + acceptedFormats: [ + 'YYYY-MM-DD', + 'MM-DD-YYYY', + 'MM/DD/YYYY', + 'ISO 8601 strings', + 'Date objects', + ], + }; + throw error; + } + + // Try native Date.parse() first (handles ISO strings well) + const isoParsed = Date.parse(trimmedInput); + if (!Number.isNaN(isoParsed)) { + const date = new Date(isoParsed); + // Validate the parsed date is actually valid + if (!Number.isNaN(date.getTime())) { + return date; + } + } + + // Try MM-DD-YYYY format (with dashes) + const dashMatch = trimmedInput.match(/^(\d{1,2})-(\d{1,2})-(\d{4})$/); + if (dashMatch) { + const [, month, day, year] = dashMatch; + // Rearrange to ISO format YYYY-MM-DD + const isoFormat = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; + const parsed = Date.parse(isoFormat); + if (!Number.isNaN(parsed)) { + const date = new Date(parsed); + if (!Number.isNaN(date.getTime())) { + return date; + } + } + } + + // Try MM/DD/YYYY format (with slashes) + const slashMatch = trimmedInput.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); + if (slashMatch) { + const [, month, day, year] = slashMatch; + // Rearrange to ISO format YYYY-MM-DD + const isoFormat = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; + const parsed = Date.parse(isoFormat); + if (!Number.isNaN(parsed)) { + const date = new Date(parsed); + if (!Number.isNaN(date.getTime())) { + return date; + } + } + } + + // All parsing attempts failed + const error = { + type: 'DATE_PARSE_ERROR', + message: `Invalid date format: "${dateInput}". Accepted formats: YYYY-MM-DD, MM-DD-YYYY, MM/DD/YYYY, or ISO 8601 strings.`, + originalInput: dateInput, + acceptedFormats: ['YYYY-MM-DD', 'MM-DD-YYYY', 'MM/DD/YYYY', 'ISO 8601 strings', 'Date objects'], + }; + throw error; +} + +/** + * Normalize a start date to beginning of day in UTC. + * + * @param {Date} date - Date object to normalize + * @param {boolean} isUTC - Whether to use UTC (default: true) + * @returns {Date} Normalized Date object representing 00:00:00.000Z + */ +function normalizeStartDate(date, isUTC = true) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + const error = { + type: 'DATE_PARSE_ERROR', + message: 'normalizeStartDate requires a valid Date object', + }; + throw error; + } + + if (isUTC) { + const year = date.getUTCFullYear(); + const month = date.getUTCMonth(); + const day = date.getUTCDate(); + // Date.UTC returns milliseconds, create Date from it + return new Date(Date.UTC(year, month, day, 0, 0, 0, 0)); + } + + // Non-UTC normalization (for completeness, though we primarily use UTC) + const year = date.getFullYear(); + const month = date.getMonth(); + const day = date.getDate(); + return new Date(year, month, day, 0, 0, 0, 0); +} + +/** + * Check if a date (ignoring time) matches today's date in UTC. + * + * @param {Date} date - Date object to check + * @param {boolean} isUTC - Whether to use UTC (default: true) + * @returns {boolean} True if date matches today, false otherwise + */ +function isDateToday(date, isUTC = true) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + return false; + } + + const now = new Date(); + + if (isUTC) { + const inputYear = date.getUTCFullYear(); + const inputMonth = date.getUTCMonth(); + const inputDay = date.getUTCDate(); + + const nowYear = now.getUTCFullYear(); + const nowMonth = now.getUTCMonth(); + const nowDay = now.getUTCDate(); + + return inputYear === nowYear && inputMonth === nowMonth && inputDay === nowDay; + } + + // Non-UTC comparison + const inputYear = date.getFullYear(); + const inputMonth = date.getMonth(); + const inputDay = date.getDate(); + + const nowYear = now.getFullYear(); + const nowMonth = now.getMonth(); + const nowDay = now.getDate(); + + return inputYear === nowYear && inputMonth === nowMonth && inputDay === nowDay; +} + +/** + * Normalize an end date to end of day in UTC, with special handling for "today". + * If the date is today, returns current time minus 5 minutes. + * Otherwise, returns end of day (23:59:59.999Z). + * + * @param {Date} date - Date object to normalize + * @param {boolean} isUTC - Whether to use UTC (default: true) + * @returns {Date} Normalized Date object + */ +function normalizeEndDate(date, isUTC = true) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + const error = { + type: 'DATE_PARSE_ERROR', + message: 'normalizeEndDate requires a valid Date object', + }; + throw error; + } + + // Check if date is today + if (isDateToday(date, isUTC)) { + // Return current UTC time minus 5 minutes + const nowMinus5Min = Date.now() - FIVE_MINUTES_IN_MILLISECONDS; + return new Date(nowMinus5Min); + } + + // Not today - normalize to end of day + if (isUTC) { + const year = date.getUTCFullYear(); + const month = date.getUTCMonth(); + const day = date.getUTCDate(); + // Date.UTC returns milliseconds, create Date from it + return new Date( + Date.UTC( + year, + month, + day, + END_OF_DAY_HOUR, + END_OF_DAY_MINUTE, + END_OF_DAY_SECOND, + END_OF_DAY_MILLISECOND, + ), + ); + } + + // Non-UTC normalization (for completeness) + const year = date.getFullYear(); + const month = date.getMonth(); + const day = date.getDate(); + return new Date( + year, + month, + day, + END_OF_DAY_HOUR, + END_OF_DAY_MINUTE, + END_OF_DAY_SECOND, + END_OF_DAY_MILLISECOND, + ); +} + +/** + * Parse and normalize a date range in UTC with default handling. + * This is the main function that orchestrates date range parsing and normalization. + * + * @param {string|Date|undefined} startDateInput - Optional start date input + * @param {string|Date|undefined} endDateInput - Optional end date input + * @param {Date|undefined} defaultStartDate - Optional default start date + * @param {Date|undefined} defaultEndDate - Optional default end date (typically undefined) + * @returns {Object} Object containing effectiveStart, effectiveEnd, defaultsApplied, endCappedToNowMinus5Min, originalInputs + * @throws {Object} Structured error objects with type 'DATE_PARSE_ERROR' or 'DATE_RANGE_ERROR' + */ +// eslint-disable-next-line complexity, max-lines-per-function +function parseAndNormalizeDateRangeUTC( + startDateInput, + endDateInput, + defaultStartDate, + defaultEndDate, +) { + const defaultsApplied = { + startDate: false, + endDate: false, + }; + + let effectiveStart; + let effectiveEnd; + let endCappedToNowMinus5Min = false; + + // Handle startDate + if ( + startDateInput !== undefined && + startDateInput !== null && + String(startDateInput).trim() !== '' + ) { + try { + const parsedStart = parseDateInput(startDateInput); + effectiveStart = normalizeStartDate(parsedStart, true); + defaultsApplied.startDate = false; + } catch (error) { + // Re-throw with context about which parameter failed + if (error.type === 'DATE_PARSE_ERROR') { + const contextualError = { + type: 'DATE_PARSE_ERROR', + message: `Invalid startDate: ${error.message}`, + originalInput: error.originalInput, + acceptedFormats: error.acceptedFormats, + parameter: 'startDate', + }; + throw contextualError; + } + throw error; + } + } else if (defaultStartDate !== undefined && defaultStartDate !== null) { + // startDateInput is missing, use default if provided + if (!(defaultStartDate instanceof Date) || Number.isNaN(defaultStartDate.getTime())) { + const error = { + type: 'DATE_PARSE_ERROR', + message: 'defaultStartDate must be a valid Date object', + }; + throw error; + } + effectiveStart = normalizeStartDate(defaultStartDate, true); + defaultsApplied.startDate = true; + } else { + // No default provided - this case should be handled by caller + // For now, we'll throw an error indicating startDate is required + // eslint-disable-next-line no-throw-literal + throw { + type: 'DATE_PARSE_ERROR', + message: 'startDate is required but was not provided and no defaultStartDate was given.', + originalInput: startDateInput, + acceptedFormats: [ + 'YYYY-MM-DD', + 'MM-DD-YYYY', + 'MM/DD/YYYY', + 'ISO 8601 strings', + 'Date objects', + ], + parameter: 'startDate', + }; + } + + // Handle endDate + if (endDateInput !== undefined && endDateInput !== null && String(endDateInput).trim() !== '') { + try { + const parsedEnd = parseDateInput(endDateInput); + // Check if it's today before normalization (for endCappedToNowMinus5Min flag) + endCappedToNowMinus5Min = isDateToday(parsedEnd, true); + effectiveEnd = normalizeEndDate(parsedEnd, true); + defaultsApplied.endDate = false; + } catch (error) { + // Re-throw with context about which parameter failed + if (error.type === 'DATE_PARSE_ERROR') { + const contextualError = { + type: 'DATE_PARSE_ERROR', + message: `Invalid endDate: ${error.message}`, + originalInput: error.originalInput, + acceptedFormats: error.acceptedFormats, + parameter: 'endDate', + }; + throw contextualError; + } + throw error; + } + } else if (defaultEndDate !== undefined && defaultEndDate !== null) { + // endDateInput is missing, use default if provided + if (!(defaultEndDate instanceof Date) || Number.isNaN(defaultEndDate.getTime())) { + const error = { + type: 'DATE_PARSE_ERROR', + message: 'defaultEndDate must be a valid Date object', + }; + throw error; + } + // Check if default is today + endCappedToNowMinus5Min = isDateToday(defaultEndDate, true); + effectiveEnd = normalizeEndDate(defaultEndDate, true); + defaultsApplied.endDate = true; + } else { + // No default provided - use current date/time + const now = new Date(); + endCappedToNowMinus5Min = true; // It's today, so will be capped + effectiveEnd = normalizeEndDate(now, true); + defaultsApplied.endDate = true; + } + + // Validation: ensure start <= end + if (effectiveStart.getTime() > effectiveEnd.getTime()) { + const error = { + type: 'DATE_RANGE_ERROR', + message: `Invalid date range: startDate (${effectiveStart.toISOString()}) must be less than or equal to endDate (${effectiveEnd.toISOString()}).`, + effectiveStart: effectiveStart.toISOString(), + effectiveEnd: effectiveEnd.toISOString(), + }; + throw error; + } + + // Return structured result + return { + effectiveStart, + effectiveEnd, + defaultsApplied, + endCappedToNowMinus5Min, + originalInputs: { + startDateInput, + endDateInput, + }, + }; +} + +module.exports = { + parseDateInput, + normalizeStartDate, + normalizeEndDate, + isDateToday, + parseAndNormalizeDateRangeUTC, +}; diff --git a/src/utilities/materialCostCorrelationHelpers.js b/src/utilities/materialCostCorrelationHelpers.js new file mode 100644 index 000000000..3265acff6 --- /dev/null +++ b/src/utilities/materialCostCorrelationHelpers.js @@ -0,0 +1,771 @@ +/** + * Material Cost Correlation Helper Utilities + * + * Centralizes MongoDB aggregation logic for material usage and cost calculations. + * Prevents duplication between similar aggregation patterns and makes code testable. + */ + +const mongoose = require('mongoose'); +const logger = require('../startup/logger'); + +/** + * Generic: resolve names to ObjectIds by querying a model with name field. + * + * @param {string[]} names - Array of names to resolve + * @param {Object} Model - Mongoose model with find(), documents with _id and name + * @param {Object} options - { queryFilter, messageNotFound, messageDbError, paramName, logContext } + * @returns {Promise} Array of ObjectId strings + * @throws {Object} Structured error with type 'NAME_RESOLUTION_ERROR' if names not found + */ +async function resolveNamesToIds(names, Model, options) { + const { queryFilter = {}, messageNotFound, messageDbError, paramName, logContext = {} } = options; + + if (!names || names.length === 0) { + return []; + } + + try { + const query = { name: { $in: names }, ...queryFilter }; + const docs = await Model.find(query).select('_id name').exec(); + + const nameToIdMap = new Map(); + docs.forEach((doc) => { + if (doc.name) { + nameToIdMap.set(doc.name.toLowerCase(), doc._id.toString()); + } + }); + + const missingNames = []; + const resolvedIds = []; + + names.forEach((name) => { + const normalizedName = name.trim().toLowerCase(); + const id = nameToIdMap.get(normalizedName); + if (id) { + resolvedIds.push(id); + } else { + missingNames.push(name); + } + }); + + if (missingNames.length > 0) { + const err = new Error(messageNotFound(missingNames)); + err.type = 'NAME_RESOLUTION_ERROR'; + err.missingNames = missingNames; + err.paramName = paramName; + throw err; + } + + return resolvedIds; + } catch (error) { + if (error.type === 'NAME_RESOLUTION_ERROR') { + throw error; + } + logger.logException(error, `${logContext.fn || 'resolveNamesToIds'} - database query`, { + ...logContext, + namesCount: names.length, + }); + const err = new Error(messageDbError); + err.type = 'NAME_RESOLUTION_ERROR'; + err.paramName = paramName; + throw err; + } +} + +/** + * Resolve project names to ObjectIds by querying the BuildingProject model. + * + * @param {string[]} projectNames - Array of project names to resolve + * @param {Object} BuildingProject - Mongoose model for BuildingProject + * @returns {Promise} Array of ObjectId strings + */ +async function resolveProjectNamesToIds(projectNames, BuildingProject) { + return resolveNamesToIds(projectNames, BuildingProject, { + queryFilter: { isActive: true }, + messageNotFound: (missing) => + `The following project names were not found: ${missing.join(', ')}`, + messageDbError: 'Error resolving project names. Please try again or use projectId instead.', + paramName: 'projectName', + logContext: { fn: 'resolveProjectNamesToIds' }, + }); +} + +/** + * Resolve material type names to ObjectIds by querying the BuildingInventoryType model. + * + * @param {string[]} materialNames - Array of material type names to resolve + * @param {Object} BuildingInventoryType - Mongoose model for BuildingInventoryType + * @returns {Promise} Array of ObjectId strings + */ +async function resolveMaterialNamesToIds(materialNames, BuildingInventoryType) { + return resolveNamesToIds(materialNames, BuildingInventoryType, { + messageNotFound: (missing) => + `The following material type names were not found: ${missing.join(', ')}`, + messageDbError: + 'Error resolving material type names. Please try again or use materialType instead.', + paramName: 'materialName', + logContext: { fn: 'resolveMaterialNamesToIds' }, + }); +} + +/** + * Convert array of string IDs to ObjectIds. + * Helper function to avoid duplication of ObjectId conversion pattern. + * + * @param {string[]} idStrings - Array of ObjectId strings + * @returns {Object[]} Array of mongoose ObjectIds + */ +function convertStringsToObjectIds(idStrings) { + return idStrings.map((id) => new mongoose.Types.ObjectId(id)); +} + +/** + * Build base match condition for filtering by project and material type. + * Helper function to avoid duplication between usage and cost aggregations. + * + * @param {string[]} projectIds - Array of project ObjectId strings (empty = all projects) + * @param {string[]} materialTypeIds - Array of material type ObjectId strings (empty = all materials) + * @returns {Object} MongoDB match condition object + */ +function buildBaseMatchForMaterials(projectIds, materialTypeIds) { + const baseMatch = {}; + + if (projectIds && projectIds.length > 0) { + const projectObjectIds = convertStringsToObjectIds(projectIds); + baseMatch.project = { $in: projectObjectIds }; + } + + if (materialTypeIds && materialTypeIds.length > 0) { + const materialTypeObjectIds = convertStringsToObjectIds(materialTypeIds); + baseMatch.itemType = { $in: materialTypeObjectIds }; + } + + return baseMatch; +} + +/** + * Find the earliest date from either updateRecord or purchaseRecord arrays, + * considering project and material type filters. + * Used when startDate is not provided. + * + * @param {string[]} projectIds - Array of project ObjectId strings (empty = all projects) + * @param {string[]} materialTypeIds - Array of material type ObjectId strings (empty = all materials) + * @param {Object} BuildingMaterial - Mongoose model for buildingMaterials collection + * @returns {Promise} Date object representing earliest record, or null if none found + */ +async function getEarliestRelevantMaterialDate(projectIds, materialTypeIds, BuildingMaterial) { + try { + // Build base match condition + const baseMatch = buildBaseMatchForMaterials(projectIds, materialTypeIds); + + // Query for earliest updateRecord date + const updateRecordPipeline = [ + { $match: baseMatch }, + { $unwind: '$updateRecord' }, + { $match: { 'updateRecord.date': { $exists: true, $ne: null } } }, + { + $group: { + _id: null, + minDate: { $min: '$updateRecord.date' }, + }, + }, + ]; + + // Query for earliest purchaseRecord date (approved only) + const purchaseRecordPipeline = [ + { $match: baseMatch }, + { $unwind: '$purchaseRecord' }, + { + $match: { + 'purchaseRecord.date': { $exists: true, $ne: null }, + 'purchaseRecord.status': 'Approved', + }, + }, + { + $group: { + _id: null, + minDate: { $min: '$purchaseRecord.date' }, + }, + }, + ]; + + // Run both queries in parallel + const [updateResult, purchaseResult] = await Promise.all([ + BuildingMaterial.aggregate(updateRecordPipeline).exec(), + BuildingMaterial.aggregate(purchaseRecordPipeline).exec(), + ]); + + // Extract minDate from results + const updateMinDate = + updateResult.length > 0 && updateResult[0].minDate ? updateResult[0].minDate : null; + const purchaseMinDate = + purchaseResult.length > 0 && purchaseResult[0].minDate ? purchaseResult[0].minDate : null; + + // Find overall minimum + if (updateMinDate === null && purchaseMinDate === null) { + return null; + } + if (updateMinDate === null) { + return purchaseMinDate; + } + if (purchaseMinDate === null) { + return updateMinDate; + } + + // Both have dates - return the earlier one + return updateMinDate.getTime() < purchaseMinDate.getTime() ? updateMinDate : purchaseMinDate; + } catch (error) { + logger.logException(error, 'getEarliestRelevantMaterialDate', { + projectIds, + materialTypeIds, + }); + return null; + } +} + +/** + * Calculate total quantity used per project and per material type within the date range. + * Aggregates from updateRecord arrays. + * + * @param {Object} BuildingMaterial - Mongoose model + * @param {Object} filters - Filter object with projectIds and materialTypeIds arrays + * @param {string[]} filters.projectIds - Array of project ObjectId strings (empty = all projects) + * @param {string[]} filters.materialTypeIds - Array of material type ObjectId strings (empty = all materials) + * @param {Object} dateRange - Date range object with effectiveStart and effectiveEnd + * @param {Date} dateRange.effectiveStart - UTC Date object for range start + * @param {Date} dateRange.effectiveEnd - UTC Date object for range end + * @returns {Promise} Promise resolving to array of objects with projectId, materialTypeId, quantityUsed + */ +async function aggregateMaterialUsage(BuildingMaterial, filters, dateRange) { + try { + const { projectIds, materialTypeIds } = filters; + const { effectiveStart, effectiveEnd } = dateRange; + + // Build initial match stage + const baseMatch = buildBaseMatchForMaterials(projectIds, materialTypeIds); + + // Create aggregation pipeline + const pipeline = [ + // Stage 1: Match by project/material filters + { $match: baseMatch }, + // Stage 2: Unwind updateRecord array + { $unwind: '$updateRecord' }, + // Stage 3: Filter updateRecords by date range and ensure quantityUsed exists + { + $match: { + 'updateRecord.date': { + $gte: effectiveStart, + $lte: effectiveEnd, + }, + 'updateRecord.quantityUsed': { $exists: true, $ne: null, $type: 'number' }, + }, + }, + // Stage 4: Group by project and itemType, sum quantityUsed + { + $group: { + _id: { + project: '$project', + itemType: '$itemType', + }, + quantityUsed: { $sum: '$updateRecord.quantityUsed' }, + }, + }, + // Stage 5: Reshape output + { + $project: { + _id: 0, + projectId: '$_id.project', + materialTypeId: '$_id.itemType', + quantityUsed: 1, + }, + }, + ]; + + const results = await BuildingMaterial.aggregate(pipeline).exec(); + return results; + } catch (error) { + logger.logException(error, 'aggregateMaterialUsage', { + projectIds: filters?.projectIds, + materialTypeIds: filters?.materialTypeIds, + effectiveStart: dateRange?.effectiveStart?.toISOString(), + effectiveEnd: dateRange?.effectiveEnd?.toISOString(), + }); + return []; + } +} + +/** + * Calculate total cost per project and per material type from approved purchases within the date range. + * Aggregates from purchaseRecord arrays. + * + * @param {Object} BuildingMaterial - Mongoose model + * @param {Object} filters - Filter object with projectIds and materialTypeIds arrays + * @param {string[]} filters.projectIds - Array of project ObjectId strings (empty = all projects) + * @param {string[]} filters.materialTypeIds - Array of material type ObjectId strings (empty = all materials) + * @param {Object} dateRange - Date range object with effectiveStart and effectiveEnd + * @param {Date} dateRange.effectiveStart - UTC Date object for range start + * @param {Date} dateRange.effectiveEnd - UTC Date object for range end + * @returns {Promise} Promise resolving to array of objects with projectId, materialTypeId, totalCost + */ +async function aggregateMaterialCost(BuildingMaterial, filters, dateRange) { + try { + const { projectIds, materialTypeIds } = filters; + const { effectiveStart, effectiveEnd } = dateRange; + + const baseMatch = buildBaseMatchForMaterials(projectIds, materialTypeIds); + + // Create aggregation pipeline + const pipeline = [ + // Stage 1: Match by project/material filters + { $match: baseMatch }, + // Stage 2: Unwind purchaseRecord array + { $unwind: '$purchaseRecord' }, + // Stage 3: Filter purchaseRecords by status and date range + { + $match: { + 'purchaseRecord.status': 'Approved', + 'purchaseRecord.date': { + $exists: true, + $ne: null, + $gte: effectiveStart, + $lte: effectiveEnd, + }, + 'purchaseRecord.quantity': { $exists: true, $ne: null, $type: 'number', $gt: 0 }, + }, + }, + // Stage 3.5: Ensure unitPrice is a number (preserve existing, convert strings, default missing) + { + $addFields: { + 'purchaseRecord.unitPrice': { + $toDouble: { $ifNull: ['$purchaseRecord.unitPrice', 0] }, + }, + }, + }, + // Stage 3.6: Filter to only include records with valid unitPrice (>= 0) + { + $match: { + 'purchaseRecord.unitPrice': { $type: 'number', $gte: 0 }, + }, + }, + // Stage 4: Group by project and itemType, sum total cost + { + $group: { + _id: { + project: '$project', + itemType: '$itemType', + }, + totalCost: { + $sum: { + $multiply: ['$purchaseRecord.unitPrice', '$purchaseRecord.quantity'], + }, + }, + }, + }, + // Stage 5: Reshape output + { + $project: { + _id: 0, + projectId: '$_id.project', + materialTypeId: '$_id.itemType', + totalCost: 1, + }, + }, + ]; + + // Execute aggregation + const results = await BuildingMaterial.aggregate(pipeline).exec(); + + return results; + } catch (error) { + logger.logException(error, 'aggregateMaterialCost', { + projectIds: filters?.projectIds, + materialTypeIds: filters?.materialTypeIds, + effectiveStart: dateRange?.effectiveStart?.toISOString(), + effectiveEnd: dateRange?.effectiveEnd?.toISOString(), + }); + return []; + } +} + +/** + * Calculate cost per unit with division-by-zero handling. + * Helper function to avoid duplication. + * + * @param {number} totalCost - Total cost + * @param {number} quantityUsed - Quantity used + * @returns {number|null} Cost per unit, or null if quantityUsed is 0 + */ +function calculateCostPerUnit(totalCost, quantityUsed) { + if (!quantityUsed || quantityUsed === 0) { + return null; + } + const result = totalCost / quantityUsed; + // Handle NaN or Infinity + if (!Number.isFinite(result)) { + return null; + } + // Round to 2 decimal places + return Math.round(result * 100) / 100; +} + +// Constants +const COST_SCALE_K = 1000; + +/** + * Calculate total cost in thousands (K). + * Helper function to avoid duplication. + * + * @param {number} totalCost - Total cost + * @returns {number} Total cost divided by 1000 + */ +function calculateTotalCostK(totalCost) { + return totalCost / COST_SCALE_K; +} + +/** + * Convert ObjectId to string safely. + * + * @param {Object|string} id - ObjectId or string + * @returns {string} String representation of the ID + */ +function objectIdToString(id) { + if (id === null || id === undefined) return ''; + if (typeof id === 'string') return id; + if (typeof id.toString === 'function') return id.toString(); + return ''; +} + +/** + * Build lookup maps for project and material type names/units. + * + * @param {Set} projectIdSet - Set of project ID strings + * @param {Set} materialTypeIdSet - Set of material type ID strings + * @param {Object} BuildingProject - Mongoose model for projects + * @param {Object} BuildingInventoryType - Mongoose model for inventory types + * @returns {Promise<{projectMap: Map, materialTypeMap: Map}>} Maps for project and material type lookups + */ +async function buildLookupMaps( + projectIdSet, + materialTypeIdSet, + BuildingProject, + BuildingInventoryType, +) { + const allUniqueProjectIds = convertStringsToObjectIds(Array.from(projectIdSet)); + const allUniqueMaterialTypeIds = convertStringsToObjectIds(Array.from(materialTypeIdSet)); + + const projectMap = new Map(); + const materialTypeMap = new Map(); + + try { + const [projects, materialTypes] = await Promise.all([ + allUniqueProjectIds.length > 0 + ? BuildingProject.find({ _id: { $in: allUniqueProjectIds } }).exec() + : Promise.resolve([]), + allUniqueMaterialTypeIds.length > 0 + ? BuildingInventoryType.find({ _id: { $in: allUniqueMaterialTypeIds } }).exec() + : Promise.resolve([]), + ]); + + projects.forEach((project) => { + const idStr = objectIdToString(project._id); + projectMap.set(idStr, project.name || idStr); + }); + + materialTypes.forEach((material) => { + const idStr = objectIdToString(material._id); + materialTypeMap.set(idStr, { + name: material.name || idStr, + unit: material.unit || '', + }); + }); + } catch (lookupError) { + logger.logException(lookupError, 'buildLookupMaps - lookup queries', { + projectIds: allUniqueProjectIds.length, + materialTypeIds: allUniqueMaterialTypeIds.length, + }); + } + + return { projectMap, materialTypeMap }; +} + +/** + * Get or create merged entry for a projectId-materialTypeId key. + * + * @param {Map} mergedData - Map to mutate + * @param {string} projectIdStr - Project ID string + * @param {string} materialTypeIdStr - Material type ID string + * @returns {Object} Entry with projectId, materialTypeId, quantityUsed, totalCost + */ +function getOrCreateMergedEntry(mergedData, projectIdStr, materialTypeIdStr) { + const key = `${projectIdStr}-${materialTypeIdStr}`; + if (!mergedData.has(key)) { + mergedData.set(key, { + projectId: projectIdStr, + materialTypeId: materialTypeIdStr, + quantityUsed: 0, + totalCost: 0, + }); + } + return mergedData.get(key); +} + +/** + * Merge usage and cost data by composite key. + * + * @param {Array} usageData - Array from aggregateMaterialUsage + * @param {Array} costData - Array from aggregateMaterialCost + * @returns {Map} Merged data map keyed by projectId-materialTypeId + */ +function mergeUsageAndCostData(usageData, costData) { + const mergedData = new Map(); + + if (usageData && Array.isArray(usageData)) { + usageData.forEach((item) => { + if (!item?.projectId || !item?.materialTypeId) return; + const entry = getOrCreateMergedEntry( + mergedData, + objectIdToString(item.projectId), + objectIdToString(item.materialTypeId), + ); + entry.quantityUsed = item.quantityUsed || 0; + }); + } + + if (costData && Array.isArray(costData)) { + costData.forEach((item) => { + if (!item?.projectId || !item?.materialTypeId) return; + const entry = getOrCreateMergedEntry( + mergedData, + objectIdToString(item.projectId), + objectIdToString(item.materialTypeId), + ); + const cost = + typeof item.totalCost === 'number' && !Number.isNaN(item.totalCost) + ? item.totalCost + : Number.parseFloat(item.totalCost) || 0; + entry.totalCost = typeof cost === 'number' && !Number.isNaN(cost) ? cost : 0; + }); + } + + return mergedData; +} + +/** + * Build projects map from merged data. + * + * @param {Map} mergedData - Merged usage and cost data + * @param {Map} projectMap - Project name lookup map + * @param {Map} materialTypeMap - Material type info lookup map + * @returns {Map} Projects map keyed by project ID + */ +function buildProjectsMap(mergedData, projectMap, materialTypeMap) { + const projectsMap = new Map(); + + mergedData.forEach((item, key) => { + // Explicit validation: Ensure item is valid + if (!item || typeof item !== 'object') { + return; + } + + const { projectId, materialTypeId, quantityUsed, totalCost } = item; + + // Ensure projectId and materialTypeId are strings for consistent key matching + const projectIdStr = objectIdToString(projectId); + const materialTypeIdStr = objectIdToString(materialTypeId); + + if (!projectsMap.has(projectIdStr)) { + projectsMap.set(projectIdStr, { + projectId: projectIdStr, + projectName: projectMap.get(projectIdStr) || 'Unknown Project', + totals: { + quantityUsed: 0, + totalCost: 0, + totalCostK: 0, + costPerUnit: null, + }, + byMaterialType: [], + }); + } + + const project = projectsMap.get(projectIdStr); + const materialInfo = materialTypeMap.get(materialTypeIdStr) || { + name: 'Unknown Material Type', + unit: '', + }; + + // Ensure totalCost is a number before calculations + const safeTotalCost = typeof totalCost === 'number' && !Number.isNaN(totalCost) ? totalCost : 0; + + const materialTotalCostK = calculateTotalCostK(safeTotalCost); + const materialCostPerUnit = calculateCostPerUnit(safeTotalCost, quantityUsed); + + const materialTypeObj = { + materialTypeId: materialTypeIdStr, + materialTypeName: materialInfo.name, + unit: materialInfo.unit, + quantityUsed: quantityUsed || 0, + totalCost: safeTotalCost, + totalCostK: materialTotalCostK, + costPerUnit: materialCostPerUnit, + }; + + project.byMaterialType.push(materialTypeObj); + project.totals.quantityUsed += quantityUsed || 0; + project.totals.totalCost += safeTotalCost; + }); + + // Compute project-level totals + projectsMap.forEach((project) => { + const { quantityUsed, totalCost } = project.totals; + project.totals.totalCostK = calculateTotalCostK(totalCost); + project.totals.costPerUnit = calculateCostPerUnit(totalCost, quantityUsed); + }); + + return projectsMap; +} + +/** + * Build meta object for response. + * + * @param {string[]} projectIds - Project IDs + * @param {string[]} materialTypeIds - Material type IDs + * @param {Object} dateRangeMeta - Date range metadata + * @returns {Object} Meta object + */ +function buildMetaObject(projectIds, materialTypeIds, dateRangeMeta) { + return { + request: { + projectIds: projectIds || [], + materialTypeIds: materialTypeIds || [], + startDateInput: dateRangeMeta?.originalInputs?.startDateInput, + endDateInput: dateRangeMeta?.originalInputs?.endDateInput, + }, + range: { + effectiveStart: dateRangeMeta?.effectiveStart?.toISOString(), + effectiveEnd: dateRangeMeta?.effectiveEnd?.toISOString(), + endCappedToNowMinus5Min: dateRangeMeta?.endCappedToNowMinus5Min || false, + defaultsApplied: dateRangeMeta?.defaultsApplied || { + startDate: false, + endDate: false, + }, + }, + units: { + currency: 'USD', + costScale: { + raw: 1, + k: 1000, + }, + }, + }; +} + +/** + * Build cost correlation response by merging usage and cost data, + * enriching with project/material names, and computing derived values. + * + * @param {Array} usageData - Array from aggregateMaterialUsage + * @param {Array} costData - Array from aggregateMaterialCost + * @param {Object} requestParams - Request parameters object + * @param {string[]} requestParams.projectIds - Original requested project IDs + * @param {string[]} requestParams.materialTypeIds - Original requested material type IDs + * @param {Object} requestParams.dateRangeMeta - Object from date normalization function + * @param {Object} models - Models object + * @param {Object} models.BuildingProject - Mongoose model for projects + * @param {Object} models.BuildingInventoryType - Mongoose model for inventory types (use invTypeBase) + * @returns {Promise} Structured response object with meta and data + */ +async function buildCostCorrelationResponse(usageData, costData, requestParams, models) { + const { projectIds, materialTypeIds, dateRangeMeta } = requestParams; + const { BuildingProject, BuildingInventoryType } = models; + try { + // 1. Collect unique IDs from usage and cost data + const projectIdSet = new Set(); + const materialTypeIdSet = new Set(); + + [...usageData, ...costData].forEach((item) => { + if (item?.projectId) { + projectIdSet.add(objectIdToString(item.projectId)); + } + if (item?.materialTypeId) { + materialTypeIdSet.add(objectIdToString(item.materialTypeId)); + } + }); + + // Include explicitly requested IDs + projectIds.forEach((id) => projectIdSet.add(String(id))); + materialTypeIds.forEach((id) => materialTypeIdSet.add(String(id))); + + // 2. Build lookup maps + const { projectMap, materialTypeMap } = await buildLookupMaps( + projectIdSet, + materialTypeIdSet, + BuildingProject, + BuildingInventoryType, + ); + + // 3. Merge usage and cost data + const mergedData = mergeUsageAndCostData(usageData, costData); + + // 4. Build projects map + const projectsMap = buildProjectsMap(mergedData, projectMap, materialTypeMap); + + // 5. Handle projects with explicit selection but no data + if (projectIds && projectIds.length > 0) { + projectIds.forEach((projectIdStr) => { + const idStr = String(projectIdStr); + if (!projectsMap.has(idStr)) { + projectsMap.set(idStr, { + projectId: idStr, + projectName: projectMap.get(idStr) || 'Unknown Project', + totals: { + quantityUsed: 0, + totalCost: 0, + totalCostK: 0, + costPerUnit: null, + }, + byMaterialType: [], + }); + } + }); + } + + // 6. Sort and build response + const projectsArray = Array.from(projectsMap.values()).sort((a, b) => { + const nameA = (a.projectName || '').toLowerCase(); + const nameB = (b.projectName || '').toLowerCase(); + return nameA.localeCompare(nameB); + }); + + const meta = buildMetaObject(projectIds, materialTypeIds, dateRangeMeta); + + return { + meta, + data: projectsArray, + }; + } catch (error) { + logger.logException(error, 'buildCostCorrelationResponse', { + usageDataLength: usageData?.length, + costDataLength: costData?.length, + }); + // Return empty response structure on error + const meta = buildMetaObject(projectIds, materialTypeIds, dateRangeMeta); + return { + meta, + data: [], + }; + } +} + +module.exports = { + getEarliestRelevantMaterialDate, + aggregateMaterialUsage, + aggregateMaterialCost, + buildCostCorrelationResponse, + buildBaseMatchForMaterials, // Export for testing/reuse + convertStringsToObjectIds, // Export for testing + calculateCostPerUnit, // Export for testing + calculateTotalCostK, // Export for testing + objectIdToString, // Export for testing + resolveProjectNamesToIds, // Export for name resolution + resolveMaterialNamesToIds, // Export for name resolution +}; diff --git a/src/utilities/queryParamParser.js b/src/utilities/queryParamParser.js new file mode 100644 index 000000000..f89b4403f --- /dev/null +++ b/src/utilities/queryParamParser.js @@ -0,0 +1,94 @@ +/** + * Multi-Select Query Parameter Parser Utility + * + * Generic utility for extracting, parsing, and validating multi-select query parameters. + * Handles various input formats (single value, comma-separated, array) and optionally + * validates MongoDB ObjectIds. + */ + +const mongoose = require('mongoose'); + +/** + * Extract, parse, and validate a multi-select query parameter into an array of strings. + * + * @param {Object} req - Express request object + * @param {string} paramName - Name of query parameter (e.g., 'projectId' or 'materialType') + * @param {boolean} requireObjectId - Whether values must be valid MongoDB ObjectIds (default: true) + * @returns {string[]} Array of parameter values (ObjectId strings or plain strings) + * @throws {Object} Structured error object with type 'OBJECTID_VALIDATION_ERROR' if validation fails + */ +function parseMultiSelectQueryParam(req, paramName, requireObjectId = true) { + // Extract raw parameter value + const rawValue = req.query[paramName]; + + // Case 1: Parameter doesn't exist (undefined) + if (rawValue === undefined) { + return []; + } + + // Case 2 & 3: Normalize to array + let normalizedArray = []; + + if (typeof rawValue === 'string') { + // Handle empty string - treat as "not provided" + const trimmed = rawValue.trim(); + if (trimmed === '') { + return []; + } + + // Check if it contains commas (comma-separated list) + if (trimmed.includes(',')) { + // Split by comma and trim each element + normalizedArray = trimmed.split(',').map((item) => item.trim()); + } else { + // Single value - create array with one element + normalizedArray = [trimmed]; + } + } else if (Array.isArray(rawValue)) { + // Already an array - trim whitespace from each element + normalizedArray = rawValue.map((item) => + typeof item === 'string' ? item.trim() : String(item).trim(), + ); + } else { + // Other types (number, etc.) - convert to string and create array + normalizedArray = [String(rawValue).trim()]; + } + + // Filter out empty strings after trimming (remove any empty or whitespace-only values) + normalizedArray = normalizedArray.filter((item) => item !== ''); + + // If after filtering we have no values, return empty array + if (normalizedArray.length === 0) { + return []; + } + + // If ObjectId validation is required + if (requireObjectId) { + const invalidValues = []; + + // Check each value for valid ObjectId + normalizedArray.forEach((value) => { + if (!mongoose.Types.ObjectId.isValid(value)) { + invalidValues.push(value); + } + }); + + // If any invalid values found, throw structured error + if (invalidValues.length > 0) { + const error = { + type: 'OBJECTID_VALIDATION_ERROR', + message: `Invalid ${paramName} format. The following values are not valid ObjectIds: ${invalidValues.join(', ')}`, + invalidValues, + paramName, + }; + throw error; + } + } + + // Return normalized array + return normalizedArray; +} + +module.exports = { + parseMultiSelectQueryParam, +};