Skip to content
Open
640 changes: 383 additions & 257 deletions src/controllers/costsController.js

Large diffs are not rendered by default.

750 changes: 750 additions & 0 deletions src/controllers/costsController.spec.js

Large diffs are not rendered by default.

89 changes: 67 additions & 22 deletions src/models/costs.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,73 @@
const mongoose = require('mongoose');

const { Schema } = mongoose;
const costSchema = new Schema({
amount: {
type: Number,
required: true,
},
category: {
type: String,
required: true,
},
projectId: {
type: String,
required: true,
index: true,
},
createdAt: {
type: Date,
required: true,
default: Date.now,
index: true,

const COST_CATEGORIES = [
'Total Cost of Labor',
'Total Cost of Materials',
'Total Cost of Equipment',
];

const DEFAULT_HOURLY_RATE = 25;

const costSchema = new Schema(
{
projectId: {
type: Schema.Types.ObjectId,
ref: 'buildingProject',
required: true,
index: true,
},
category: {
type: String,
required: true,
enum: COST_CATEGORIES,
index: true,
},
amount: {
type: Number,
required: true,
min: 0,
},
costDate: {
type: Date,
required: true,
index: true,
},
projectName: {
type: String,
required: true,
trim: true,
},
projectType: {
type: String,
enum: ['commercial', 'residential', 'private'],
default: 'private',
index: true,
},
calculatedAt: {
type: Date,
default: Date.now,
},
lastUpdated: {
type: Date,
default: Date.now,
},
source: {
type: String,
enum: ['aggregation', 'manual', 'correction'],
default: 'aggregation',
},
},
});
{ timestamps: true },
);

costSchema.index({ projectId: 1, category: 1, costDate: 1 }, { unique: true });
costSchema.index({ projectId: 1, costDate: 1 });
costSchema.index({ costDate: 1 });

costSchema.index({ projectId: 1, createdAt: -1 });
const Cost = mongoose.model('Cost', costSchema);

module.exports = mongoose.model('Cost', costSchema);
module.exports = Cost;
module.exports.COST_CATEGORIES = COST_CATEGORIES;
module.exports.DEFAULT_HOURLY_RATE = DEFAULT_HOURLY_RATE;
9 changes: 6 additions & 3 deletions src/routes/costsRouter.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
const express = require('express');

const routes = function (Costs) {
const routes = () => {
const costsRouter = express.Router();
const controller = require('../controllers/costsController')(Costs);
const controller = require('../controllers/costsController')();

// Static routes MUST come before parameterized routes
costsRouter.route('/breakdown').get(controller.getCostBreakdown);

costsRouter.route('/refresh').post(controller.refreshCosts);

costsRouter.route('/').post(controller.addCostEntry);

costsRouter.route('/:costId').put(controller.updateCostEntry).delete(controller.deleteCostEntry);

costsRouter.route('/:projectId').get(controller.getCostsByProject);
costsRouter.route('/project/:projectId').get(controller.getCostsByProject);

return costsRouter;
};
Expand Down
103 changes: 103 additions & 0 deletions src/routes/costsRouter.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
const express = require('express');
const request = require('supertest');

const mockController = {
getCostBreakdown: jest.fn((req, res) => res.status(200).json({ ok: true })),
refreshCosts: jest.fn((req, res) => res.status(200).json({ ok: true })),
addCostEntry: jest.fn((req, res) => res.status(201).json({ ok: true })),
updateCostEntry: jest.fn((req, res) => res.status(200).json({ ok: true })),
deleteCostEntry: jest.fn((req, res) => res.status(200).json({ ok: true })),
getCostsByProject: jest.fn((req, res) => res.status(200).json({ ok: true })),
};

jest.mock('../controllers/costsController', () => jest.fn(() => mockController));

const costsRouter = require('./costsRouter');

function createApp() {
const app = express();
app.use(express.json());
app.use('/api/costs', costsRouter());
return app;
}

describe('costsRouter', () => {
let app;

beforeAll(() => {
app = createApp();
});

beforeEach(() => {
jest.clearAllMocks();
});

test('C.1 — GET /api/costs/breakdown routes to getCostBreakdown', async () => {
const res = await request(app).get('/api/costs/breakdown');

expect(res.status).toBe(200);
expect(mockController.getCostBreakdown).toHaveBeenCalled();
});

test('C.2 — POST /api/costs/refresh routes to refreshCosts', async () => {
const res = await request(app).post('/api/costs/refresh').send({});

expect(res.status).toBe(200);
expect(mockController.refreshCosts).toHaveBeenCalled();
});

test('C.3 — POST /api/costs/ routes to addCostEntry', async () => {
const res = await request(app).post('/api/costs').send({});

expect(res.status).toBe(201);
expect(mockController.addCostEntry).toHaveBeenCalled();
});

test('C.4 — PUT /api/costs/:costId routes to updateCostEntry', async () => {
const res = await request(app).put('/api/costs/507f1f77bcf86cd799439011').send({});

expect(res.status).toBe(200);
expect(mockController.updateCostEntry).toHaveBeenCalled();
});

test('C.5 — DELETE /api/costs/:costId routes to deleteCostEntry', async () => {
const res = await request(app).delete('/api/costs/507f1f77bcf86cd799439011');

expect(res.status).toBe(200);
expect(mockController.deleteCostEntry).toHaveBeenCalled();
});

test('C.6 — GET /api/costs/project/:projectId routes to getCostsByProject', async () => {
const res = await request(app).get('/api/costs/project/507f1f77bcf86cd799439011');

expect(res.status).toBe(200);
expect(mockController.getCostsByProject).toHaveBeenCalled();
});

test('C.extra — PUT /:costId passes costId in params', async () => {
await request(app).put('/api/costs/507f1f77bcf86cd799439011').send({});

const req = mockController.updateCostEntry.mock.calls[0][0];
expect(req.params.costId).toBe('507f1f77bcf86cd799439011');
});

test('C.extra — GET /project/:projectId passes projectId in params', async () => {
await request(app).get('/api/costs/project/507f1f77bcf86cd799439011');

const req = mockController.getCostsByProject.mock.calls[0][0];
expect(req.params.projectId).toBe('507f1f77bcf86cd799439011');
});

test('C.extra — DELETE /:costId passes costId in params', async () => {
await request(app).delete('/api/costs/507f1f77bcf86cd799439011');

const req = mockController.deleteCostEntry.mock.calls[0][0];
expect(req.params.costId).toBe('507f1f77bcf86cd799439011');
});

test('C.extra — unknown route returns 404', async () => {
const res = await request(app).get('/api/costs/unknown/route/here');

expect(res.status).toBe(404);
});
});
Loading
Loading