Skip to content
Open
209 changes: 201 additions & 8 deletions src/controllers/bmdashboard/__tests__/bmMaterialsController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -23,6 +24,7 @@ const BuildingMaterial = {
create: mockCreate,
findOneAndUpdate: mockFindOneAndUpdate,
updateOne: mockUpdateOne,
updateMany: mockUpdateMany,
populate: mockPopulate,
exec: mockExec,
};
Expand All @@ -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 = {
Expand All @@ -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(() => ({
Expand All @@ -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';
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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);
});
});
});
60 changes: 60 additions & 0 deletions src/controllers/bmdashboard/bmMaterialsController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -584,6 +643,7 @@ const bmMaterialsController = function (BuildingMaterial) {
bmMaterialsList,
bmPostMaterialUpdateRecord,
bmPostMaterialUpdateBulk,
bmApplyMaterialBulkAction,
bmPurchaseMaterials,
bmupdatePurchaseStatus,
bmGetMaterialSummaryByProject,
Expand Down
3 changes: 3 additions & 0 deletions src/models/bmdashboard/buildingMaterial.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand Down
2 changes: 2 additions & 0 deletions src/routes/bmdashboard/bmMaterialsRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const routes = function (buildingMaterial) {

materialsRouter.route('/updateMaterialRecordBulk').post(controller.bmPostMaterialUpdateBulk);

materialsRouter.route('/materials/bulk-actions').post(controller.bmApplyMaterialBulkAction);

@AdiDubbs AdiDubbs Aug 1, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can bulk-modify any materials but has no permission check - any authenticated user can call it. Most controllers here gate with hasPermission(req.body.requestor, ...). I think this might need to be restricted.

@kunchalasireesha kunchalasireesha Sep 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bulk-actions endpoint sits behind the same JWT auth as the rest of /api/bm. None of the existing materials write endpoints (bmPurchaseMaterials, bmPostMaterialUpdateBulk, bmupdatePurchaseStatus) use hasPermission, and there's no materials permission string in the seed yet. Adding a proper manageMaterials permission across the BM controllers is worth doing as its own PR rather than bolting it onto just this endpoint.


materialsRouter.route('/updateMaterialStatus').post(controller.bmupdatePurchaseStatus);

materialsRouter.route('/materials/stock-out-risk').get(controller.bmGetMaterialStockOutRisk);
Expand Down
Loading