diff --git a/src/lib/logapi.js b/src/lib/logapi.js new file mode 100644 index 0000000..dbb4ac5 --- /dev/null +++ b/src/lib/logapi.js @@ -0,0 +1,170 @@ +const lineReader = require('line-reader') +const fs = require('fs') + +const config = require('../../config') + +let _this + +class LogsApi { + constructor () { + _this = this + _this.fs = fs + _this.lineReader = lineReader + _this.config = config + } + + async getLogs (password) { + try { + // console.log('entering getLogs()') + _this.password = password + // console.log(`password: ${password}`) + + // Password matches the password set in the config file. + if (password === _this.config.logPass) { + // Generate the full path and file name for the current log file. + const fullPath = _this.generateFileName() + // console.log(`fullPath: ${JSON.stringify(fullPath, null, 2)}`) + + // Throw an error if the file does not exist. + if (!_this.fs.existsSync(fullPath)) { + return { + success: false, + data: 'file does not exist' + } + } else { + // Read in the data from the log file. + const data = await _this.readLines(fullPath) + // console.log(`data: ${JSON.stringify(data, null, 2)}`) + + // Filter the logs before passing them to the front end. + const filteredData = _this.filterLogs(data) + + return { + success: true, + data: filteredData + } + } + + // Password does not match password in config file. + } else { + return { + success: false + } + } + } catch (err) { + console.error('Error in lib/logapi.js/getLogs()') + throw err + } + } + + // Sorts the log data by their timestamp. Returns the LIMIT or less elements. + filterLogs (data, LIMIT = 100) { + try { + if (!Array.isArray(data)) { + throw new Error('Data must be array') + } + // console.log(`data: ${JSON.stringify(data, null, 2)}`) + + // const LIMIT = 100 // Max number of entries to return. + + // Sort the elements by date. + data.sort(function (a, b) { + let dateA = new Date(a.timestamp) + dateA = dateA.getTime() + + let dateB = new Date(b.timestamp) + dateB = dateB.getTime() + + return dateB - dateA + }) + + // Limit the number of elements. + if (data.length > LIMIT) { + return data.slice(0, LIMIT) + } + + // else + return data + } catch (err) { + console.error('Error in lib/logapi.js/filterLogs()') + throw err + } + } + + generateFileName () { + try { + const now = new Date() + let thisDate = now.getDate() + thisDate = ('0' + thisDate).slice(-2) + + let thisMonth = now.getMonth() + 1 + thisMonth = ('0' + thisMonth).slice(-2) + // console.log(`thisMonth: ${thisMonth}`) + + const thisYear = now.getFullYear() + + const filename = `koa-${ + _this.config.env + }-${thisYear}-${thisMonth}-${thisDate}.log` + // console.log(`filename: ${filename}`) + const logDir = `${__dirname.toString()}/../../logs/` + const fullPath = `${logDir}${filename}` + // console.log(`fullPath: ${fullPath}`) + + return fullPath + } catch (err) { + console.error('Error in lib/logapi.js/generateFileName()') + throw err + } + } + + // Promise based read-file + /* readFile (path, opts = 'utf8') { + return new Promise((resolve, reject) => { + _this.fs.readFile(path, opts, (err, data) => { + if (err) reject(err) + else resolve(data) + }) + }) + } */ + + // Returns an array with each element containing a line of the file. + readLines (filename) { + return new Promise((resolve, reject) => { + try { + if (!filename || typeof filename !== 'string') { + throw new Error('filename must be a string') + } + // Throw an error if the file does not exist. + + if (!_this.fs.existsSync(filename)) { + throw new Error('file does not exist') + } + + const data = [] + + // let i = 0 + + _this.lineReader.eachLine(filename, function (line, last) { + try { + data.push(JSON.parse(line)) + + // Uncomment to display the raw data in each line of the winston log file. + // console.log(`line ${i}: ${line}`) + // i++ + + if (last) return resolve(data) + } catch (err) { + // console.log('err: ', err) + if (last) return resolve(data) + } + }) + } catch (err) { + console.log('Error in lib/logapi.js/readLines()') + return reject(err) + } + }) + } +} + +module.exports = LogsApi diff --git a/src/lib/nodemailer.js b/src/lib/nodemailer.js index e512b70..d8bd1cf 100644 --- a/src/lib/nodemailer.js +++ b/src/lib/nodemailer.js @@ -72,6 +72,7 @@ class NodeMailer { // send mail with defined transport object const info = await _this.transporter.sendMail(sendObj) console.log('Message sent: %s', info.messageId) + return info } catch (err) { wlogger.error('Error in lib/nodemailer.js/sendEmail()') diff --git a/src/modules/logapi/controller.js b/src/modules/logapi/controller.js index e002de5..97346dd 100644 --- a/src/modules/logapi/controller.js +++ b/src/modules/logapi/controller.js @@ -1,16 +1,12 @@ -const lineReader = require('line-reader') -const fs = require('fs') - -const config = require('../../../config') +const LogsApiLib = require('../../lib/logapi') +const logsApiLib = new LogsApiLib() let _this class LogsApi { constructor () { _this = this - _this.fs = fs - _this.lineReader = lineReader - _this.config = config + _this.logsApiLib = logsApiLib } /** @@ -55,158 +51,16 @@ class LogsApi { // Get the user-provided password. const password = ctx.request.body.password - _this.password = password - // console.log(`password: ${password}`) - - // Password matches the password set in the config file. - if (password === _this.config.logPass) { - // Generate the full path and file name for the current log file. - const fullPath = _this.generateFileName() - // console.log(`fullPath: ${JSON.stringify(fullPath, null, 2)}`) - - // Throw an error if the file does not exist. - if (!_this.fs.existsSync(fullPath)) { - ctx.body = { - success: false, - data: 'file does not exist' - } - } else { - // Read in the data from the log file. - const data = await _this.readLines(fullPath) - // console.log(`data: ${JSON.stringify(data, null, 2)}`) - - // Filter the logs before passing them to the front end. - const filteredData = _this.filterLogs(data) - - ctx.body = { - success: true, - data: filteredData - } - } - - // Password does not match password in config file. - } else { - ctx.body = { - success: false - } - } + const result = await _this.logsApiLib.getLogs(password) + ctx.body = result } catch (err) { if (err && err.message) { - ctx.throw(500, err.message) + ctx.throw(422, err.message) } else { ctx.throw(500, 'Unhandled error') } } } - - // Sorts the log data by their timestamp. Returns the LIMIT or less elements. - filterLogs (data, LIMIT = 100) { - try { - if (!Array.isArray(data)) { - throw new Error('Data must be array') - } - // console.log(`data: ${JSON.stringify(data, null, 2)}`) - - // const LIMIT = 100 // Max number of entries to return. - - // Sort the elements by date. - data.sort(function (a, b) { - let dateA = new Date(a.timestamp) - dateA = dateA.getTime() - - let dateB = new Date(b.timestamp) - dateB = dateB.getTime() - - return dateB - dateA - }) - - // Limit the number of elements. - if (data.length > LIMIT) { - return data.slice(0, LIMIT) - } - - // else - return data - } catch (err) { - console.error('Error in logapi/controller.js/filterLogs()') - throw err - } - } - - generateFileName () { - try { - const now = new Date() - let thisDate = now.getDate() - thisDate = ('0' + thisDate).slice(-2) - - let thisMonth = now.getMonth() + 1 - thisMonth = ('0' + thisMonth).slice(-2) - // console.log(`thisMonth: ${thisMonth}`) - - const thisYear = now.getFullYear() - - const filename = `koa-${ - _this.config.env - }-${thisYear}-${thisMonth}-${thisDate}.log` - // console.log(`filename: ${filename}`) - const logDir = `${__dirname.toString()}/../../../logs/` - const fullPath = `${logDir}${filename}` - // console.log(`fullPath: ${fullPath}`) - - return fullPath - } catch (err) { - console.error('Error in logapi/controller.js/generateFileName()') - throw err - } - } - - // Promise based read-file - /* readFile (path, opts = 'utf8') { - return new Promise((resolve, reject) => { - _this.fs.readFile(path, opts, (err, data) => { - if (err) reject(err) - else resolve(data) - }) - }) - } */ - - // Returns an array with each element containing a line of the file. - readLines (filename) { - return new Promise((resolve, reject) => { - try { - if (!filename || typeof filename !== 'string') { - throw new Error('filename must be a string') - } - // Throw an error if the file does not exist. - - if (!_this.fs.existsSync(filename)) { - throw new Error('file does not exist') - } - - const data = [] - - // let i = 0 - - _this.lineReader.eachLine(filename, function (line, last) { - try { - data.push(JSON.parse(line)) - - // Uncomment to display the raw data in each line of the winston log file. - // console.log(`line ${i}: ${line}`) - // i++ - - if (last) return resolve(data) - } catch (err) { - // console.log('err: ', err) - if (last) return resolve(data) - } - }) - } catch (err) { - console.log('Error in readLines()') - return reject(err) - } - }) - } } module.exports = LogsApi diff --git a/test/e2e/automated/a06-logapi.rest-e2e.js b/test/e2e/automated/a06-logapi.rest-e2e.js new file mode 100644 index 0000000..82ee275 --- /dev/null +++ b/test/e2e/automated/a06-logapi.rest-e2e.js @@ -0,0 +1,126 @@ +const config = require('../../../config') +const assert = require('chai').assert + +const axios = require('axios').default +const sinon = require('sinon') + +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +const LOCALHOST = `http://localhost:${config.port}` + +const LogsController = require('../../../src/modules/logapi/controller') +const mockContext = require('../../unit/mocks/ctx-mock').context + +let sandbox +let uut +describe('LogsApi', () => { + beforeEach(() => { + uut = new LogsController() + + sandbox = sinon.createSandbox() + }) + + afterEach(() => sandbox.restore()) + + describe('POST /logapi', () => { + it('should return false if password is not provided', async () => { + try { + const options = { + method: 'post', + url: `${LOCALHOST}/logapi`, + data: {} + } + + const result = await axios(options) + assert.isFalse(result.data.success) + } catch (err) { + assert(false, 'Unexpected result') + } + }) + it('should return log', async () => { + try { + const options = { + method: 'post', + url: `${LOCALHOST}/logapi`, + data: { + password: 'test' + } + } + + const result = await axios(options) + + assert.isTrue(result.data.success) + assert.isArray(result.data.data) + assert.property(result.data.data[0], 'message') + assert.property(result.data.data[0], 'level') + assert.property(result.data.data[0], 'timestamp') + } catch (err) { + assert(false, 'Unexpected result') + } + }) + it('should return false if files are not found!', async () => { + try { + sandbox.stub(uut.logsApiLib, 'getLogs').resolves({ + success: false, + data: 'file does not exist' + }) + + const ctx = mockContext() + ctx.request = { + body: { + password: 'test' + } + } + await uut.getLogs(ctx) + + assert.isFalse(ctx.body.success) + assert.include(ctx.body.data, 'file does not exist') + } catch (err) { + assert.fail('Unexpected result') + } + }) + it('should catch and handle errors', async () => { + try { + // Force an error + sandbox.stub(uut.logsApiLib.fs, 'existsSync').throws(new Error('test error')) + + // Mock the context object. + const ctx = mockContext() + + ctx.request = { + body: { + password: 'test' + } + } + + await uut.getLogs(ctx) + + assert.fail('Unexpected result') + } catch (err) { + assert.include(err.message, 'test error') + } + }) + it('should throw unhandled error', async () => { + try { + // Force an error + sandbox.stub(uut.logsApiLib.fs, 'existsSync').throws(new Error()) + + // Mock the context object. + const ctx = mockContext() + + ctx.request = { + body: { + password: 'test' + } + } + + await uut.getLogs(ctx) + + assert.fail('Unexpected result') + } catch (err) { + assert.include(err.message, 'Unhandled error') + } + }) + }) +}) diff --git a/test/unit/biz-logic/a03-nodemailer.lib-unit.js b/test/unit/biz-logic/a03-nodemailer.lib-unit.js index b0444b8..79c2fce 100644 --- a/test/unit/biz-logic/a03-nodemailer.lib-unit.js +++ b/test/unit/biz-logic/a03-nodemailer.lib-unit.js @@ -101,13 +101,16 @@ describe('NodeMailer', () => { sandbox .stub(uut.transporter, 'sendMail') .resolves({ messageId: 'messageId' }) + const data = { email: 'test@email.com', formMessage: 'test msg', to: ['test2@email.com'], subject: 'test subject' } + const info = await uut.sendEmail(data) + assert.isObject(info) assert.isString(info.messageId) } catch (err) { diff --git a/test/unit/biz-logic/a06-logapi.lib-unit.js b/test/unit/biz-logic/a06-logapi.lib-unit.js new file mode 100644 index 0000000..19b3390 --- /dev/null +++ b/test/unit/biz-logic/a06-logapi.lib-unit.js @@ -0,0 +1,231 @@ +const assert = require('chai').assert + +const sinon = require('sinon') + +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +const LogsApiLib = require('../../../src/lib/logapi') +const mockData = require('../mocks/log-api-mock') + +const context = {} +let sandbox +let uut +describe('#LogsApiLib', () => { + beforeEach(() => { + uut = new LogsApiLib() + + sandbox = sinon.createSandbox() + }) + + afterEach(() => sandbox.restore()) + + describe('#getLogs()', () => { + it('should return false if password is not provided', async () => { + try { + const result = await uut.getLogs() + assert.property(result, 'success') + assert.isFalse(result.success) + } catch (err) { + assert(false, 'Unexpected result') + } + }) + + it('should return log', async () => { + try { + const pass = 'test' + const result = await uut.getLogs(pass) + // console.log('result', result) + + assert.isTrue(result.success) + assert.isArray(result.data) + assert.property(result.data[0], 'message') + assert.property(result.data[0], 'level') + assert.property(result.data[0], 'timestamp') + } catch (err) { + assert(false, 'Unexpected result') + } + }) + + it('should return false if files are not found!', async () => { + try { + sandbox.stub(uut, 'generateFileName').resolves('bad router') + + const password = 'test' + + const result = await uut.getLogs(password) + // console.log(result) + + assert.isFalse(result.success) + assert.include(result.data, 'file does not exist') + } catch (err) { + console.log('ERRROR', err) + assert.fail('Unexpected result') + } + }) + + it('should catch and handle errors', async () => { + try { + // Force an error + sandbox.stub(uut.fs, 'existsSync').throws(new Error('test error')) + const password = 'test' + + await uut.getLogs(password) + + assert.fail('Unexpected result') + } catch (err) { + assert.include(err.message, 'test error') + } + }) + + it('should throw unhandled error', async () => { + try { + // Force an error + sandbox.stub(uut.fs, 'existsSync').throws(new Error('Unhandled error')) + const password = 'test' + + await uut.getLogs(password) + + assert.fail('Unexpected result') + } catch (err) { + assert.include(err.message, 'Unhandled error') + } + }) + }) + + describe('#filterLogs()', () => { + it('should throw error if data is not provided', async () => { + try { + await uut.filterLogs() + + assert.fail('Unexpected result') + } catch (err) { + assert.include(err.message, 'Data must be array') + } + }) + + it('should throw error if data provided is not an array', async () => { + try { + const data = 'data' + await uut.filterLogs(data) + + assert.fail('Unexpected result') + } catch (err) { + assert.include(err.message, 'Data must be array') + } + }) + + it('should sort the log data', async () => { + try { + const data = mockData.data + const result = await uut.filterLogs(data) + assert.isArray(result) + assert.property(result[1], 'message') + assert.property(result[1], 'level') + assert.property(result[1], 'timestamp') + } catch (err) { + assert.fail('Unexpected result') + } + }) + + it('should sort the log data with a limit', async () => { + try { + const data = mockData.data + const limit = 1 + const result = await uut.filterLogs(data, limit) + assert.isArray(result) + assert.equal(result.length, limit) + assert.property(result[0], 'message') + assert.property(result[0], 'level') + assert.property(result[0], 'timestamp') + } catch (err) { + assert.fail('Unexpected result') + } + }) + }) + + describe('#generateFileName()', () => { + it('should return file name', async () => { + try { + const fileName = await uut.generateFileName() + assert.isString(fileName) + context.fileName = fileName + } catch (err) { + assert.fail('Unexpected result') + } + }) + + it('should throw error if something fails', async () => { + try { + uut.config = null + await uut.generateFileName() + assert.fail('Unexpected result') + } catch (err) { + assert.exists(err) + assert.isString(err.message) + } + }) + }) + + describe('#readLines()', () => { + it('should throw error if fileName is not provided', async () => { + try { + await uut.readLines() + + assert.fail('Unexpected result') + } catch (err) { + assert.include(err.message, 'filename must be a string') + } + }) + + it('should throw error if fileName provided is not string', async () => { + try { + const fileName = true + await uut.readLines(fileName) + + assert.fail('Unexpected result') + } catch (err) { + assert.include(err.message, 'filename must be a string') + } + }) + + it('should throw error if the file does not exist', async () => { + try { + const fileName = 'test/logs/' + await uut.readLines(fileName) + + assert.fail('Unexpected result') + } catch (err) { + assert.include(err.message, 'file does not exist') + } + }) + + it('should ignore fileReader callback errors', async () => { + try { + // https://sinonjs.org/releases/latest/stubs/ + // About yields + sandbox.stub(uut.lineReader, 'eachLine').yieldsRight({}, true) + + const fileName = context.fileName + const result = await uut.readLines(fileName) + assert.isArray(result) + } catch (err) { + assert.fail('Unexpected result') + } + }) + + it('should return data', async () => { + try { + const fileName = context.fileName + const result = await uut.readLines(fileName) + + assert.isArray(result) + assert.property(result[1], 'message') + assert.property(result[1], 'level') + assert.property(result[1], 'timestamp') + } catch (err) { + assert.fail('Unexpected result') + } + }) + }) +}) diff --git a/test/unit/rest-api/a04-contact.rest-api.js b/test/unit/rest-api/a04-contact.rest-api.js index 71218e6..874634c 100644 --- a/test/unit/rest-api/a04-contact.rest-api.js +++ b/test/unit/rest-api/a04-contact.rest-api.js @@ -13,7 +13,7 @@ let ctx const mockContext = require('../../unit/mocks/ctx-mock').context -describe('Users', () => { +describe('Contact', () => { before(async () => { }) diff --git a/test/unit/rest-api/a06-logapi.rest-unit.js b/test/unit/rest-api/a06-logapi.rest-unit.js new file mode 100644 index 0000000..88a955c --- /dev/null +++ b/test/unit/rest-api/a06-logapi.rest-unit.js @@ -0,0 +1,75 @@ +/* + Unit tests for the REST API handler for the /users endpoints. +*/ + +// Public npm libraries +const assert = require('chai').assert +const sinon = require('sinon') + +const LogsApiController = require('../../../src/modules/logapi/controller') +let uut +let sandbox +let ctx + +const mockContext = require('../../unit/mocks/ctx-mock').context + +describe('Logapi', () => { + before(async () => { + }) + + beforeEach(() => { + uut = new LogsApiController() + + sandbox = sinon.createSandbox() + + // Mock the context object. + ctx = mockContext() + }) + + afterEach(() => sandbox.restore()) + + describe('#POST /logapi', () => { + it('should return 422 status on biz logic error', async () => { + try { + await uut.getLogs(ctx) + + assert.fail('Unexpected result') + } catch (err) { + assert.equal(err.status, 422) + assert.include(err.message, 'Cannot read property') + } + }) + it('should return 500 status on biz logic Unhandled error', async () => { + try { + // eslint-disable + sandbox.stub(uut.logsApiLib, 'getLogs').returns(Promise.reject(new Error())) + + ctx.request.body = { + password: 'test' + } + + await uut.getLogs(ctx) + + assert.fail('Unexpected result') + } catch (err) { + assert.equal(err.status, 500) + assert.include(err.message, 'Unhandled error') + } + }) + + it('should return 200 status on success', async () => { + ctx.request.body = { + password: 'test' + } + + await uut.getLogs(ctx) + + // Assert the expected HTTP response + assert.equal(ctx.status, 200) + + // Assert that expected properties exist in the returned data. + assert.property(ctx.response.body, 'success') + assert.isTrue(ctx.response.body.success) + }) + }) +})