diff --git a/src/controllers/bmdashboard/__tests__/bmMaterialsController.test.js b/src/controllers/bmdashboard/__tests__/bmMaterialsController.test.js index 0c7079ecf..c3fce4c6e 100644 --- a/src/controllers/bmdashboard/__tests__/bmMaterialsController.test.js +++ b/src/controllers/bmdashboard/__tests__/bmMaterialsController.test.js @@ -15,6 +15,7 @@ const mockFindOne = jest.fn(); const mockCreate = jest.fn(); const mockFindOneAndUpdate = jest.fn(); const mockUpdateOne = jest.fn(); +const mockUpdateMany = jest.fn(); // Mock BuildingMaterial model const BuildingMaterial = { @@ -23,6 +24,7 @@ const BuildingMaterial = { create: mockCreate, findOneAndUpdate: mockFindOneAndUpdate, updateOne: mockUpdateOne, + updateMany: mockUpdateMany, populate: mockPopulate, exec: mockExec, }; @@ -40,20 +42,23 @@ describe('bmMaterialsController', () => { const controller = bmMaterialsController(BuildingMaterial); describe('bmMaterialsList', () => { - it('should fetch and return materials list', async () => { - const mockResults = [{ name: 'Cement', quantity: 100 }]; - // Fix the chaining of populate calls + const mockPopulateChain = (results) => { mockPopulate.mockImplementation(() => ({ populate: mockPopulate, exec() { return { then(callback) { - callback(mockResults); + callback(results); return { catch: mockCatch }; }, }; }, })); + }; + + it('should fetch and return materials list', async () => { + const mockResults = [{ name: 'Cement', quantity: 100 }]; + mockPopulateChain(mockResults); const req = {}; const res = { @@ -70,6 +75,40 @@ describe('bmMaterialsController', () => { expect(res.send).toHaveBeenCalledWith(mockResults); }); + it('returns stockHold, isReviewed and notes without applying a restrictive projection', async () => { + const mockResults = [ + { + _id: 'mat1', + name: 'Cement', + stockHold: true, + isReviewed: false, + notes: 'Damaged pallet, awaiting review', + }, + ]; + mockPopulateChain(mockResults); + + const req = {}; + const res = { + status: jest.fn().mockReturnThis(), + send: jest.fn(), + json: jest.fn(), + }; + + await controller.bmMaterialsList(req, res); + + // find() is called with no projection argument, so nothing is excluded. + expect(mockFind).toHaveBeenCalledWith(); + expect(res.status).toHaveBeenCalledWith(200); + const [payload] = res.send.mock.calls[0]; + expect(payload[0]).toEqual( + expect.objectContaining({ + stockHold: true, + isReviewed: false, + notes: 'Damaged pallet, awaiting review', + }), + ); + }); + it('should handle errors during fetch', async () => { const mockError = new Error('Database error'); mockThen.mockImplementation(() => ({ @@ -93,6 +132,13 @@ describe('bmMaterialsController', () => { }); describe('bmPurchaseMaterials', () => { + // One test below stubs mongoose.Types.ObjectId; restore it so later suites + // (e.g. bmApplyMaterialBulkAction) still have access to ObjectId.isValid. + const realObjectId = mongoose.Types.ObjectId; + afterEach(() => { + mongoose.Types.ObjectId = realObjectId; + }); + const validProjectId = '507f1f77bcf86cd799439011'; const validMatTypeId = '507f1f77bcf86cd799439012'; const validRequestorId = '507f1f77bcf86cd799439013'; @@ -140,11 +186,11 @@ 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; + // Mock ObjectId.isValid to return true, and ObjectId constructor. + // Replace the whole ObjectId reference rather than mutating the real + // one in place, so the outer afterEach can actually restore it. mongoose.Types.ObjectId = jest.fn().mockReturnValue('507f1f77bcf86cd799439014'); - mongoose.Types.ObjectId.isValid = originalObjectId.isValid; + mongoose.Types.ObjectId.isValid = jest.fn().mockReturnValue(true); mockFindOneAndUpdate.mockReturnValue({ exec: jest.fn().mockReturnValue({ @@ -363,4 +409,151 @@ describe('bmMaterialsController', () => { ); }); }); + + describe('bmApplyMaterialBulkAction', () => { + const validIds = ['5f9d88b9c9d1c8b1a0e7e111', '5f9d88b9c9d1c8b1a0e7e222']; + + const makeRes = () => ({ + status: jest.fn().mockReturnThis(), + send: jest.fn(), + }); + + it('reports the matched count from a Mongoose 5 result (n/nModified)', async () => { + mockUpdateMany.mockResolvedValue({ ok: 1, n: 2, nModified: 2 }); + + const req = { body: { materialIds: validIds, action: 'hold' } }; + const res = makeRes(); + + await controller.bmApplyMaterialBulkAction(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith({ + matchedCount: 2, + modifiedCount: 2, + result: "Applied 'hold' to 2 material records.", + }); + const [payload] = res.send.mock.calls[0]; + expect(payload.result).not.toContain('undefined'); + }); + + it('reports the matched count from a newer driver result (matchedCount/modifiedCount)', async () => { + mockUpdateMany.mockResolvedValue({ acknowledged: true, matchedCount: 2, modifiedCount: 1 }); + + const req = { body: { materialIds: validIds, action: 'review' } }; + const res = makeRes(); + + await controller.bmApplyMaterialBulkAction(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith({ + matchedCount: 2, + modifiedCount: 1, + result: "Applied 'review' to 2 material records.", + }); + }); + + it('reports the matched count even when nothing actually changed (idempotent re-apply)', async () => { + // e.g. re-applying "hold" to items that are already on hold: MongoDB + // reports modifiedCount 0 since no field value changed, but the action + // still matched and was applied to these records. + mockUpdateMany.mockResolvedValue({ acknowledged: true, matchedCount: 3, modifiedCount: 0 }); + + const req = { body: { materialIds: validIds, action: 'hold' } }; + const res = makeRes(); + + await controller.bmApplyMaterialBulkAction(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith({ + matchedCount: 3, + modifiedCount: 0, + result: "Applied 'hold' to 3 material records.", + }); + }); + + it('defaults the count to 0 instead of undefined when the driver omits it', async () => { + mockUpdateMany.mockResolvedValue({ ok: 1 }); + + const req = { body: { materialIds: validIds, action: 'hold' } }; + const res = makeRes(); + + await controller.bmApplyMaterialBulkAction(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + const [payload] = res.send.mock.calls[0]; + expect(payload.result).toBe("Applied 'hold' to 0 material records."); + expect(payload.matchedCount).toBe(0); + expect(payload.modifiedCount).toBe(0); + }); + + it('rejects an empty material id list', async () => { + const req = { body: { materialIds: [], action: 'hold' } }; + const res = makeRes(); + + await controller.bmApplyMaterialBulkAction(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(mockUpdateMany).not.toHaveBeenCalled(); + }); + + it('rejects an invalid bulk action', async () => { + const req = { body: { materialIds: validIds, action: 'delete' } }; + const res = makeRes(); + + await controller.bmApplyMaterialBulkAction(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(mockUpdateMany).not.toHaveBeenCalled(); + }); + + it('rejects a material id list containing an invalid id', async () => { + const req = { body: { materialIds: [...validIds, 'not-an-id'], action: 'hold' } }; + const res = makeRes(); + + await controller.bmApplyMaterialBulkAction(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith('One or more material ids are invalid.'); + expect(mockUpdateMany).not.toHaveBeenCalled(); + }); + + it('rejects a notes action with blank notes', async () => { + const req = { body: { materialIds: validIds, action: 'notes', notes: ' ' } }; + const res = makeRes(); + + await controller.bmApplyMaterialBulkAction(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith('Notes content is required for notes action.'); + expect(mockUpdateMany).not.toHaveBeenCalled(); + }); + + it('applies a notes action with trimmed notes', async () => { + mockUpdateMany.mockResolvedValue({ matchedCount: 2, modifiedCount: 2 }); + + const req = { body: { materialIds: validIds, action: 'notes', notes: ' Damaged pallet ' } }; + const res = makeRes(); + + await controller.bmApplyMaterialBulkAction(req, res); + + expect(mockUpdateMany).toHaveBeenCalledWith( + { _id: { $in: validIds } }, + { $set: { notes: 'Damaged pallet' } }, + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('returns a 500 when the update fails', async () => { + const mockError = new Error('Database error'); + mockUpdateMany.mockRejectedValue(mockError); + + const req = { body: { materialIds: validIds, action: 'hold' } }; + const res = makeRes(); + + await controller.bmApplyMaterialBulkAction(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.send).toHaveBeenCalledWith(mockError); + }); + }); }); diff --git a/src/controllers/bmdashboard/bmMaterialsController.js b/src/controllers/bmdashboard/bmMaterialsController.js index 59eb06385..ce5e0885c 100644 --- a/src/controllers/bmdashboard/bmMaterialsController.js +++ b/src/controllers/bmdashboard/bmMaterialsController.js @@ -302,6 +302,65 @@ const bmMaterialsController = function (BuildingMaterial) { } }; + const bmApplyMaterialBulkAction = async function (req, res) { + const { materialIds, action, notes } = req.body; + + if (!Array.isArray(materialIds) || materialIds.length === 0) { + return res.status(400).send('At least one material id is required.'); + } + + if (!materialIds.every((id) => mongoose.Types.ObjectId.isValid(id))) { + return res.status(400).send('One or more material ids are invalid.'); + } + + if (!['hold', 'review', 'notes'].includes(action)) { + return res.status(400).send('Invalid bulk action.'); + } + + const update = {}; + + if (action === 'hold') { + update.$set = { stockHold: true }; + } + + if (action === 'review') { + update.$set = { isReviewed: true }; + } + + if (action === 'notes') { + const trimmedNotes = typeof notes === 'string' ? notes.trim() : ''; + if (!trimmedNotes) { + return res.status(400).send('Notes content is required for notes action.'); + } + update.$set = { notes: trimmedNotes }; + } + + try { + const result = await BuildingMaterial.updateMany( + { + _id: { $in: materialIds }, + }, + update, + ); + + // Mongoose 5 returns `n`/`nModified`; newer drivers use `matchedCount`/`modifiedCount`. + const matchedCount = result.matchedCount ?? result.n ?? 0; + const modifiedCount = result.modifiedCount ?? result.nModified ?? 0; + + // Report matchedCount, not modifiedCount: MongoDB only counts a document + // as "modified" when a field's value actually changes, so re-applying + // the same action (e.g. holding an already-held item) would otherwise + // always read as "0 records" even though the action was applied fine. + return res.status(200).send({ + matchedCount, + modifiedCount, + result: `Applied '${action}' to ${matchedCount} material records.`, + }); + } catch (error) { + return res.status(500).send(error); + } + }; + const bmupdatePurchaseStatus = async function (req, res) { const { purchaseId, status, quantity } = req.body; try { @@ -584,6 +643,7 @@ const bmMaterialsController = function (BuildingMaterial) { bmMaterialsList, bmPostMaterialUpdateRecord, bmPostMaterialUpdateBulk, + bmApplyMaterialBulkAction, bmPurchaseMaterials, bmupdatePurchaseStatus, bmGetMaterialSummaryByProject, diff --git a/src/models/bmdashboard/buildingMaterial.js b/src/models/bmdashboard/buildingMaterial.js index 3d30ef7c7..60c21b307 100644 --- a/src/models/bmdashboard/buildingMaterial.js +++ b/src/models/bmdashboard/buildingMaterial.js @@ -9,6 +9,9 @@ const buildingMaterial = new Schema({ stockUsed: { type: Number, default: 0 }, // total amount of item used successfully in the project stockWasted: { type: Number, default: 0 }, // total amount of item wasted/ruined/lost in the project stockAvailable: { type: Number, default: 0 }, // bought - (used + wasted) + stockHold: { type: Boolean, default: false }, + isReviewed: { type: Boolean, default: false }, + notes: { type: String, default: '' }, purchaseRecord: [ { date: { type: Date, default: Date.now() }, diff --git a/src/routes/bmdashboard/bmMaterialsRouter.js b/src/routes/bmdashboard/bmMaterialsRouter.js index 0a660bbe2..38eebd35d 100644 --- a/src/routes/bmdashboard/bmMaterialsRouter.js +++ b/src/routes/bmdashboard/bmMaterialsRouter.js @@ -14,6 +14,8 @@ const routes = function (buildingMaterial) { materialsRouter.route('/updateMaterialRecordBulk').post(controller.bmPostMaterialUpdateBulk); + materialsRouter.route('/materials/bulk-actions').post(controller.bmApplyMaterialBulkAction); + materialsRouter.route('/updateMaterialStatus').post(controller.bmupdatePurchaseStatus); materialsRouter.route('/materials/stock-out-risk').get(controller.bmGetMaterialStockOutRisk);