diff --git a/README.md b/README.md index 4b4d635..5e8751a 100644 --- a/README.md +++ b/README.md @@ -26,3 +26,4 @@ mongorestore ./dump To pass this exam all excercises must be resolved, every feature must be tested (with all tests passing) and there must be no linting errors. Summission must be done through a single Pull Request. + diff --git a/app.js b/app.js index 9cfb46a..533129a 100644 --- a/app.js +++ b/app.js @@ -3,7 +3,7 @@ const bodyParser = require("body-parser"); const morgan = require("morgan"); const mongoose = require("mongoose"); const config = require("./config"); -const {responseHelpers} = require("./middleware"); +const {responseHelpers, cacheMiddleware} = require("./middleware"); const routes = require("./routes"); require("./models"); @@ -22,7 +22,7 @@ app.use(morgan("dev")); app.use(responseHelpers); // Add cache middleware -// app.use(cacheMiddleware); +app.use(cacheMiddleware); // Setup mongoose and load models mongoose.Promise = global.Promise; diff --git a/controllers/billing.js b/controllers/billing.js index e4c98b8..e9f2b72 100644 --- a/controllers/billing.js +++ b/controllers/billing.js @@ -2,6 +2,7 @@ module.exports = (mongoose) => { const Course = mongoose.model("Course"); const Evaluation = mongoose.model("Evaluation"); const Student = mongoose.model("Student"); + const request = require("request"); // para cada curso dame la evaluacion // para cada evaluacion dame los aprobados @@ -67,7 +68,7 @@ module.exports = (mongoose) => { firstName: student.firstName, lastName: student.lastName, address: student.billingAddress, - price: studentsWithPrice[id] + price: studentsWithPrice[id] / 100 }); }); }); @@ -81,8 +82,93 @@ module.exports = (mongoose) => { res.response500(err, "Courses couldn't be found!"); }); } + function getStudentsBillingInfo() { + const options = { + url: "http://localhost:8000/api/admin/billing/getChargeableStudents", + headers: { + "user-agent": "node-exam" + } + }; + + return new Promise((resolve, reject) => { + request.get(options, (err, response, body) => { + if (err) { + reject(err); + } else if (response.statusCode === 200) { + try { + const studentsBillingInfo = JSON.parse(body).data.studentsBillingInfo; + resolve(studentsBillingInfo); + } catch (ex) { + // console.error(ex); + // Log the exception but returns an error without any sensitive data! + reject(new Error("Couldn't get node repos!")); + } + } else { + console.error(response); + // Log the conflictive response but returns an error without any sensitive data! + reject(new Error("Couldn't get billing student!")); + } + }); + }); + } + function formatObject(infoAfip, studentInfo) { + return ({ + "BillingNumber": infoAfip.id, + "FirstAndLastName": studentInfo.nomYAp, + "Address": studentInfo.dir, + "price": studentInfo.importe + }); + } + function getAfipInfo(studentInfo) { + const options = { + url: "http://localhost:8000/api/afip", + headers: { + "user-agent": "node-exam" + } + }; + return new Promise((resolve, reject) => { + request.post(options.url, {json: studentInfo}, (_, response, body) => { + if (response.statusCode === 404) { + resolve(getAfipInfo(studentInfo)); + } else if (response.statusCode === 200) { + try { + resolve(formatObject(body.data, studentInfo)); + } catch (ex) { + console.error(ex); + // Log the exception but returns an error without any sensitive data! + reject(new Error("Couldn't get node repos!")); + } + } else { + console.error(response); + // Log the conflictive response but returns an error without any sensitive data! + reject(new Error("Couldn't get billing student!")); + } + }); + }); + } + function formatStudentForAfip(student) { + const {firstName, lastName, address, price} = student; + return {"nomYAp": `${firstName} ${lastName}`, "dir": address.street1, "importe": price}; + } + function getAfipStudensInfo(students) { + const promiseArray = students.map((student) => { + const studentInfo = formatStudentForAfip(student); + return getAfipInfo(studentInfo); + }); + return Promise.all(promiseArray); + } + async function getInvoices(req, res) { + try { + const students = await getStudentsBillingInfo(req, res); + const afipFinalInfo = await getAfipStudensInfo(students); + res.response200(afipFinalInfo, "Success"); + } catch (error) { + res.response500(error, "Error"); + } + } return { - getChargeableStudents + getChargeableStudents, + getInvoices }; }; diff --git a/controllers/courses.js b/controllers/courses.js index 4de1399..833d8e5 100644 --- a/controllers/courses.js +++ b/controllers/courses.js @@ -1,9 +1,9 @@ +/* eslint-disable no-param-reassign */ module.exports = (mongoose) => { - var Course = mongoose.model("Course"), - filterFields = ["status"], - sortFields = ["status"]; + const Course = mongoose.model("Course"); + const filterFields = ["status", "technologyId"]; - var buildFilterQuery = function(params) { + function buildFilterQuery(params) { return filterFields.reduce((query, prop) => { if (params[prop]) { query[prop] = params[prop]; @@ -20,32 +20,32 @@ module.exports = (mongoose) => { function list(req, res) { Course.find(buildFilterQuery(req.query)).sort() - .then(function (courses) { - res.response200({courses}, "Found '" + courses.length + "' Courses."); + .then((courses) => { + res.response200({courses}, `Found '${courses.length}' Courses.`); }) - .catch(function (err) { + .catch((err) => { res.response500(err, "Courses couldn't be found!"); }); } function create(req, res) { - let courseDTO = getCourseDTO(req.body); + const courseDTO = getCourseDTO(req.body); Course.create(courseDTO) .then((course) => { res.response200(course, `Course '${course._id}' successfully created.`); }) .catch((err) => { - res.response500(err, "Course couldn't be created!"); + res.response500(err, "Course couldn't be created!"); }); } function read(req, res) { - Course.findById({ _id: req.params.id }) + Course.findById({_id: req.params.id}) .then((course) => { if (course) { - res.response200({course}, `Course '${course._id}' found.`); + res.response200({course}, `Course '${course._id}' found.`); } else { - res.response404("Course not found!"); + res.response404("Course not found!"); } }) .catch((err) => { @@ -59,7 +59,7 @@ module.exports = (mongoose) => { if (course) { res.response200({course}, `Course '${course._id}' successfully updated.`); } else { - res.response404('Course not found!'); + res.response404("Course not found!"); } }) .catch((err) => { diff --git a/controllers/index.js b/controllers/index.js index 85840f2..8e2423f 100644 --- a/controllers/index.js +++ b/controllers/index.js @@ -3,11 +3,13 @@ const CoursesController = require("./courses"); const StudentsController = require("./students"); const EvaluationsController = require("./evaluations"); const TechnologiesController = require("./technologies"); +const StatsController = require("./stats"); module.exports = { BillingController, EvaluationsController, CoursesController, StudentsController, + StatsController, TechnologiesController }; diff --git a/controllers/stats.js b/controllers/stats.js new file mode 100644 index 0000000..b84460c --- /dev/null +++ b/controllers/stats.js @@ -0,0 +1,63 @@ +module.exports = (mongoose) => { + const Evaluation = mongoose.model("Evaluation"); + const Student = mongoose.model("Student"); + const stateArray = {}; + const addedStudents = []; + function verifyExistentStudent(studentId, array) { + let exist = false; + array.forEach((student) => { + if (studentId.toString() === student._id) { + exist = true; + } + }); + + return exist; + } + + function incrementState(state) { + if (stateArray[state]) { + stateArray[state]++; + } else { + stateArray[state] = 1; + } + } + + function addStateByStudent(state, studentId) { + if (!verifyExistentStudent(studentId, addedStudents)) { + addedStudents.push(studentId); + incrementState(state); + } + } + + function addStudentsToList(array) { + array.forEach((student) => { + addStateByStudent(student.billingAddress.state, student._id); + }); + } + + function getFailuresByState(req, res) { + Evaluation.find() + .then((failedEvals) => { + const notes = failedEvals.reduce((notesFiltered, toConcat) => { + return notesFiltered.concat(toConcat.notes); + }, []).filter((note) => { + return note.status === "failed"; + }); + + return Promise.all(notes); + }) + .then((notes) => { + const studentsFiltered = notes.map((note) => { + return Student.findOne({_id: note.studentId}); + }); + + return Promise.all(studentsFiltered); + }) + .then((students) => { + addStudentsToList(students); + res.response200(stateArray); + }); + } + + return {getFailuresByState}; +}; diff --git a/middleware/cacheMiddleware.js b/middleware/cacheMiddleware.js new file mode 100644 index 0000000..5cebca7 --- /dev/null +++ b/middleware/cacheMiddleware.js @@ -0,0 +1,36 @@ +const memCache = {}; + +function isInCache({url}) { + return memCache[url]; +} + +function getNewUrl(oldUrl) { + const splitted = oldUrl.split("/").slice(1, -1); + return splitted.reduce((oldValue, newValur) => { + return (`${oldValue}/${newValur}`); + }, ""); +} + +module.exports = (req, res, next) => { + const forbiddenPaths = ["/admin/billing/getChargeableStudents"]; + const dontAuth = forbiddenPaths.find((path) => { + return path === `${req.method} ${req.url}`; + }); + const isCached = isInCache(req); + if (req.method === "GET" && !dontAuth) { + if (isCached) { + return res.response200((isCached), "Was cached!"); + } + res.response200 = (data = {}, message = "ok") => { + memCache[req.originalUrl] = data; + res.json({data, status: "success", message}); + }; + } else if (req.method === "POST" && isCached) { + Reflect.deleteProperty(memCache, req.url); + return next(); + } else if (req.method === "PUT" && isCached) { + Reflect.deleteProperty(memCache, getNewUrl(req.originalUrl)); + return next(); + } + return next(); +}; diff --git a/middleware/index.js b/middleware/index.js index f7c2174..2b53879 100644 --- a/middleware/index.js +++ b/middleware/index.js @@ -1,3 +1,4 @@ const responseHelpers = require("./responseHelpers"); +const cacheMiddleware = require("./cacheMiddleware"); -module.exports = {responseHelpers}; +module.exports = {responseHelpers, cacheMiddleware}; diff --git a/package.json b/package.json index ac05951..77b2baf 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "app.js", "scripts": { "start": "node ./app.js", - "test": "NODE_ENV=test ./node_modules/mocha/bin/_mocha --timeout 1000 --exit" + "test": "NODE_ENV=test ./node_modules/mocha/bin/_mocha --timeout 10000 --exit" }, "keywords": [ "exam", diff --git a/routes.js b/routes.js index 5b0d932..dd5bb52 100644 --- a/routes.js +++ b/routes.js @@ -3,6 +3,7 @@ const { EvaluationsController, CoursesController, StudentsController, + StatsController, TechnologiesController } = require("./controllers"); const afipAPI = require("./services/afip-mock-api"); @@ -27,6 +28,7 @@ module.exports = (app, router) => { const evaluationController = EvaluationsController(mongoose); const technologyController = TechnologiesController(mongoose); const billingController = BillingController(mongoose); + const statsController = StatsController(mongoose); const controllers = [ {basePath: "/evaluations", controller: evaluationController}, @@ -40,6 +42,11 @@ module.exports = (app, router) => { router.route("/admin/billing/getChargeableStudents") .get(billingController.getChargeableStudents); + router.route("/admin/billing/getInvoices") + .get(billingController.getInvoices); + router.route("/stats/failuresByStates") + .get(statsController.getFailuresByState); + router.route("/afip") .post(afipAPI.getInvoice); diff --git a/test/dummy_tests.js b/test/dummy_tests.js index e69de29..46dde39 100644 --- a/test/dummy_tests.js +++ b/test/dummy_tests.js @@ -0,0 +1,442 @@ +const app = require("../app.js"); +const {expect} = require("chai"); +const request = require("supertest"); +const mongoose = app.get("mongoose"); +const Course = mongoose.model("Course"); +const basePath = "/api/courses"; +const invoicePath = "/api/admin/billing/getInvoices"; +const validFilterParam = "?technologyId="; +const invalidFilterParam = "?techId=JS-000"; +const Student = mongoose.model("Student"); +const Evaluation = mongoose.model("Evaluation"); + +describe("Node Test", () => { + let course1 = null; + let course2 = null; + + before(() => { + return Course.deleteMany({}) + .then(() => { + console.log("Tasks collection cleaned!"); + return Promise.all([ + Course.create({ + "technologyId": "JS-000", + "date": { + "from": "2019-05-01T00:00:00.000Z", + "to": "2019-05-15T00:00:00.000Z" + }, + "description": "Node Js course", + "status": "new", + "classes": [ + { + "name": "Programming basics with Node JS", + "description": "A class to start programming with Node JS", + "content": "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", + "tags": "" + }, + { + "name": "Programming with Node JS", + "description": "A class to continue programming with Node JS", + "content": "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", + "tags": "" + }, + { + "name": "Programming advanced with Node JS", + "description": "A class to program with Node JS", + "content": "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", + "tags": "" + } + ], + "price": 123400, + "students": [] + }), + Course.create({ + "technologyId": "JS-001", + "date": { + "from": "2019-02-01T00:00:00.000Z", + "to": "2019-02-28T00:00:00.000Z" + }, + "description": "Vue Js course", + "status": "finished", + "classes": [ + { + "name": "Programming basics with Vue JS", + "description": "A class to start programming with Vue JS", + "content": "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", + "tags": "" + }, + { + "name": "Programming with Vue JS", + "description": "A class to continue programming with Vue JS", + "content": "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", + "tags": "" + }, + { + "name": "Programming advanced with Vue JS", + "description": "A class to program with Vue JS", + "content": "Lorem Ipsum is simply dummy text of the printing and typesetting industry.", + "tags": "" + } + ], + "price": 432100, + "students": [ + "5c8be6ec89a91670892d731a", + "5c8be6ec89a91670892d731b", + "5c8be6ec89a91670892d731c", + "5c8be6ec89a91670892d731d", + "5c8be6ec89a91670892d731e" + ] + }) + ]); + }) + .then((data) => { + course1 = data[0]; + course2 = data[1]; + }); + }); + + context("Issue #1 - Testing Filter", () => { + it("should filter courses by technologyId and return just one with the id JS-000", () => { + return request(app) + .get(`${basePath}${validFilterParam}${course1.technologyId}`) + .expect(200) + .then((res) => { + expect(res.body.status).to.eql("success"); + expect(res.body.data.courses).to.be.an("array"); + expect(res.body.data.courses.length).to.eql(1); + expect(res.body.data.courses[0]._id.toString()).to.eql(course1._id.toString()); + }); + }); + + it("should filter courses by technologyId and return just one with the id JS-001", () => { + return request(app) + .get(`${basePath}${validFilterParam}${course2.technologyId}`) + .expect(200) + .then((res) => { + expect(res.body.status).to.eql("success"); + expect(res.body.data.courses).to.be.an("array"); + expect(res.body.data.courses.length).to.eql(1); + expect(res.body.data.courses[0]._id.toString()).to.eql(course2._id.toString()); + }); + }); + + it("should return all courses due to invalid filter with an invalid param", () => { + return request(app) + .get(`${basePath}${invalidFilterParam}`) + .expect(200) + .then((res) => { + expect(res.body.status).to.eql("success"); + expect(res.body.data.courses).to.be.an("array"); + expect(res.body.data.courses.length).to.eql(2); + }); + }); + + it("Should return all courses without passing any filtering params", () => { + return request(app) + .get(`${basePath}`) + .expect(200) + .then((res) => { + expect(res.body.status).to.eql("success"); + expect(res.body.data.courses).to.be.an("array"); + expect(res.body.data.courses.length).to.eql(2); + }); + }); + }); + + context("Issue #2 - Testing getInvoice with AFIP", () => { + // This test works with the database that not from test. Rest to mock de necessary data + it.skip("Should return an array of student with their respective invoices", () => { + return request(app) + .get(`${invoicePath}`) + .expect(200) + .then((res) => { + expect(res.body.status).to.eql("success"); + expect(res.body.data).to.be.an("array"); + }); + }); + + it.skip("Should returns the list of students with their respective invoice", () => { + return request(app) + .get(`${invoicePath}`) + .expect(200) + .then((res) => { + expect(res.body.data.data); + expect(res.body.data.length).to.be.eql(7); + const invoice = res.body.data[0]; + expect(invoice.BillingNumber).to.be.a("number"); + expect(invoice.FirstAndLastName).to.be.eql("Everette Lehner"); + expect(invoice.address).to.be.eql("126 Schuppe Shore"); + expect(invoice.price).to.be.eql(932100); + }); + }); + }); + context("Issue #3 - Testing cache MiddleWare", () => { + it("Should return message 'Was cached!'", () => { + return request(app) + .get("/api/courses") + .expect(200) + .then(() => { + return request(app) + .get(`${basePath}`) + .expect(200); + }) + .then((res) => { + expect(res.body.status).to.eql("success"); + expect(res.body.message).to.be.eql("Was cached!"); + }); + }); + + it("Should not return message 'Was cached!' When Posting new Course", () => { + return request(app) + .get("/api/courses") + .expect(200) + .then(() => { + return request(app) + .post("/api/courses") + .send({ + "technologyId": "Ruby", + "date": { + "from": "2018-10-20T00:00:00Z", + "to": "2018-10-21T00:00:00Z" + }, + "description": "A Ruby starter course", + "classes": [{"name": "Class 1", "description": "zaraza"}], + "price": 3000, + "students": ["pepe"] + }) + .expect(200); + }) + .then(() => { + return request(app) + .get("/api/courses") + .expect(200); + }) + .then((res) => { + expect(res.body.status).to.eql("success"); + expect(res.body.message).not.to.be.eql("Was cached!"); + }); + }); + }); + + context("Issue #4 - Testing States who Students Fail the", () => { + before(async () => { + await Student.deleteMany({}); + await Evaluation.deleteMany({}); + const students = [ + { + "_id": "5c92b9f262e5287d7db5602f", + "firstName": "Pupa", + "lastName": "Polainas", + "billingAddress": { + "_id": "5c92b9f262e5287d7db56030", + "street1": "Av. De Mayo 776", + "city": "Buenos Aires", + "state": "Buenos Aires", + "zipCode": "1084", + "country": "Argentina" + }, + "creditCards": [ + ] + }, + { + "_id": "5c92b9f262e5287d7db56fff", + "firstName": "Pupa", + "lastName": "Polainas", + "billingAddress": { + "_id": "5c92b9f262e5287d7db56030", + "street1": "Av. De Mayo 776", + "city": "Buenos Aires", + "state": "Catamarca", + "zipCode": "1084", + "country": "Argentina" + }, + "creditCards": [ + ] + }, + { + "_id": "5c92b9f262e5287d7db5ffff", + "firstName": "Pupa", + "lastName": "Polainas", + "billingAddress": { + "_id": "5c92b9f262e5287d7db56030", + "street1": "Av. De Mayo 776", + "city": "Buenos Aires", + "state": "Santiago", + "zipCode": "1084", + "country": "Argentina" + }, + "creditCards": [ + ] + }, + { + "_id": "5c92b9f262e5287d7db56aaa", + "firstName": "Pupa", + "lastName": "Polainas", + "billingAddress": { + "_id": "5c92b9f262e5287d7db56030", + "street1": "Av. De Mayo 776", + "city": "Buenos Aires", + "state": "CABA", + "zipCode": "1084", + "country": "Argentina" + }, + "creditCards": [ + ] + }, + { + "_id": "5c92b9f262e5287d7db56aab", + "firstName": "Pupa", + "lastName": "Polainas", + "billingAddress": { + "_id": "5c92b9f262e5287d7db56030", + "street1": "Av. De Mayo 776", + "city": "Buenos Aires", + "state": "CABA", + "zipCode": "1084", + "country": "Argentina" + }, + "creditCards": [ + ] + } + ]; + const evaluations = [ + { + "_id": "5c92d654e34b581661a5779a", + "courseId": "5c8c09ff444bfa0dfa450066", + "date": { + "from": "2019-03-21T00:00:00.000Z", + "to": "2019-03-22T00:00:00.000Z" + }, + "abstract": "The evaluation for the Ruby course. Write JS stuff to pass", + "notes": [ + { + "_id": "5c92d654e34b581661a5779b", + "studentId": "5c92b9f262e5287d7db5602f", + "qualification": 2, + "status": "failed" + } + ] + }, + { + "_id": "5c92d654e34b581661a577dd", + "courseId": "5c8c09ff444bfa0dfa450066", + "date": { + "from": "2019-03-21T00:00:00.000Z", + "to": "2019-03-22T00:00:00.000Z" + }, + "abstract": "The evaluation for the Ruby course. Write JS stuff to pass", + "notes": [ + { + "_id": "5c92d654e34b581661a5779b", + "studentId": "5c92b9f262e5287d7db5602f", + "qualification": 2, + "status": "failed" + } + ] + }, + { + "_id": "5c92d654e34b581661a577de", + "courseId": "5c8c09ff444bfa0dfa450066", + "date": { + "from": "2019-03-21T00:00:00.000Z", + "to": "2019-03-22T00:00:00.000Z" + }, + "abstract": "The evaluation for the Ruby course. Write JS stuff to pass", + "notes": [ + { + "_id": "5c92d654e34b581661a5779b", + "studentId": "5c92b9f262e5287d7db56fff", + "qualification": 1, + "status": "failed" + } + ] + }, + { + "_id": "5c92d654e34b581661a577ee", + "courseId": "5c8c09ff444bfa0dfa450066", + "date": { + "from": "2019-03-21T00:00:00.000Z", + "to": "2019-03-22T00:00:00.000Z" + }, + "abstract": "The evaluation for the Ruby course. Write JS stuff to pass", + + "notes": [ + { + "_id": "5c92d654e34b581661a5779b", + "studentId": "5c92b9f262e5287d7db56aaa", + "qualification": 1, + "status": "failed" + } + ] + }, + { + "_id": "5c92d654e34b581661a577e2", + "courseId": "5c8c09ff444bfa0dfa450066", + "date": { + "from": "2019-03-21T00:00:00.000Z", + "to": "2019-03-22T00:00:00.000Z" + }, + "abstract": "The evaluation for the Ruby course. Write JS stuff to pass", + "notes": [ + { + "_id": "5c92d654e34b581661a5779b", + "studentId": "5c92b9f262e5287d7db56aab", + "qualification": 1, + "status": "failed" + } + ] + }, + { + "_id": "5c92d654e34b581661a5ffe2", + "courseId": "5c8c09ff444bfa0dfa450066", + "date": { + "from": "2019-03-21T00:00:00.000Z", + "to": "2019-03-22T00:00:00.000Z" + }, + "abstract": "The evaluation for the Ruby course. Write JS stuff to pass", + "notes": [ + { + "_id": "5c92d654e34b581661a5779b", + "studentId": "5c92b9f262e5287d7db5ffff", + "qualification": 9, + "status": "passed" + } + ] + } + ]; + await Student.insertMany(students); + await Evaluation.insertMany(evaluations); + }); + + it("Should returns the Number of students by city that have failed one evaluationn", () => { + return request(app) + .get("/api/stats/failuresByStates") + .expect(200) + .then((res) => { + expect(res.body.data.data); + expect(res.body.data.Catamarca).to.be.eql(1); + }); + }); + + it("Should returns the Number of students by city that have failed two evaluation", () => { + return request(app) + .get("/api/stats/failuresByStates") + .expect(200) + .then((res) => { + expect(res.body.data.data); + expect(res.body.data.CABA).to.be.eql(2); + }); + }); + + it("Should returns the Number of students by city that have no failed any evaluations", () => { + return request(app) + .get("/api/stats/failuresByStates") + .expect(200) + .then((res) => { + expect(res.body.data.data); + // eslint-disable-next-line no-unused-expressions + expect(res.body.data.Santiago).to.be.undefined; + }); + }); + }); +});