diff --git a/server/package.json b/server/package.json index d8d61f84f..129bebb55 100644 --- a/server/package.json +++ b/server/package.json @@ -25,9 +25,11 @@ "express": "^4.17.1", "express-boom": "^3.0.0", "express-oauth-server": "^2.0.0", - "express-validator": "^6.1.1", + "express-validator": "^6.2.0", "jsonwebtoken": "^8.5.1", "knex": "^0.19.2", + "lodash": "^4.17.15", + "memory-cache": "^0.2.0", "moment": "^2.24.0", "mustache": "^3.0.3", "mysql2": "^1.6.5", @@ -57,6 +59,7 @@ "mocha-webpack": "^2.0.0-beta.0", "npm-run-all": "^4.1.5", "nyc": "^14.1.1", + "sinon": "^7.4.2", "webpack": "^4.0.0", "webpack-cli": "^3.3.7", "webpack-node-externals": "^1.7.2" diff --git a/server/src/database/factories/index.js b/server/src/database/factories/index.js index 55b36e612..fc884287a 100644 --- a/server/src/database/factories/index.js +++ b/server/src/database/factories/index.js @@ -12,18 +12,34 @@ factory.define('user', 'users', async () => { first_name: faker.name.firstName(), last_name: faker.name.lastName(), email: faker.internet.email(), - phone_number: faker.phone.phoneNumber(), + phone_number: faker.phone.phoneNumberFormat().replace('-', ''), active: 1, password: hashedPassword, }; }); -factory.define('account', 'accounts', async () => ({ - name: faker.lorem.word(), - type: faker.lorem.word(), - description: faker.lorem.paragraph(), +factory.define('password_reset', 'password_resets', async () => { + const user = await faker.create('user'); + return { + user_id: user.id, + token: faker.lorem.slug, + }; +}); + +factory.define('account_type', 'account_types', async () => ({ + name: faker.lorem.words(2), })); +factory.define('account', 'accounts', async () => { + const accountType = await factory.create('account_type'); + + return { + name: faker.lorem.word(), + account_type_id: accountType.id, + description: faker.lorem.paragraph(), + }; +}); + factory.define('item_category', 'items_categories', () => ({ label: faker.name.firstName(), description: faker.lorem.text(), @@ -55,4 +71,16 @@ factory.define('item', 'items', async () => { }; }); +factory.define('setting', 'settings', async () => { + const user = await factory.create('user'); + + return { + key: faker.lorem.slug(), + user_id: user.id, + type: 'string', + value: faker.lorem.words(), + group: 'default', + }; +}); + export default factory; diff --git a/server/src/database/migrations/20190104195900_create_password_resets_table.js b/server/src/database/migrations/20190104195900_create_password_resets_table.js new file mode 100644 index 000000000..64b67e4cc --- /dev/null +++ b/server/src/database/migrations/20190104195900_create_password_resets_table.js @@ -0,0 +1,9 @@ + +exports.up = (knex) => knex.schema.createTable('password_resets', (table) => { + table.increments(); + table.string('user_id'); + table.string('token'); + table.timestamp('created_at'); +}); + +exports.down = (knex) => knex.schema.dropTableIfExists('password_resets'); \ No newline at end of file diff --git a/server/src/database/migrations/20190822214301_create_oauth_clients_table.js b/server/src/database/migrations/20190822214301_create_oauth_clients_table.js index 15c8b309c..af117714c 100644 --- a/server/src/database/migrations/20190822214301_create_oauth_clients_table.js +++ b/server/src/database/migrations/20190822214301_create_oauth_clients_table.js @@ -1,6 +1,6 @@ exports.up = function(knex) { - return knex.schema.createTable('oauth_clients', table => { + return knex.schema.createTable('oauth_clients', (table) => { table.increments(); table.integer('client_id').unsigned(); table.string('client_secret'); diff --git a/server/src/database/migrations/20190822214302_create_settings_table.js b/server/src/database/migrations/20190822214302_create_settings_table.js index c6030afcd..57aeb37b3 100644 --- a/server/src/database/migrations/20190822214302_create_settings_table.js +++ b/server/src/database/migrations/20190822214302_create_settings_table.js @@ -1,8 +1,10 @@ -exports.up = function(knex) { - return knex.schema.createTable('settings', table => { +exports.up = function (knex) { + return knex.schema.createTable('settings', (table) => { table.increments(); table.integer('user_id').unsigned().references('id').inTable('users'); + table.string('group'); + table.string('type'); table.string('key'); table.string('value'); }); diff --git a/server/src/database/migrations/20190822214304_create_accounts_table.js b/server/src/database/migrations/20190822214304_create_accounts_table.js index dc327b622..7dd3aff01 100644 --- a/server/src/database/migrations/20190822214304_create_accounts_table.js +++ b/server/src/database/migrations/20190822214304_create_accounts_table.js @@ -3,7 +3,7 @@ exports.up = function (knex) { return knex.schema.createTable('accounts', (table) => { table.increments(); table.string('name'); - table.string('type'); + table.integer('account_type_id'); table.integer('parent_account_id'); table.string('code', 10); table.text('description'); diff --git a/server/src/database/migrations/20190822214904_create_account_types_table.js b/server/src/database/migrations/20190822214904_create_account_types_table.js new file mode 100644 index 000000000..69a1c4fdf --- /dev/null +++ b/server/src/database/migrations/20190822214904_create_account_types_table.js @@ -0,0 +1,9 @@ + +exports.up = function (knex) { + return knex.schema.createTable('account_types', (table) => { + table.increments(); + table.string('name'); + }); +}; + +exports.down = (knex) => knex.schema.dropTableIfExists('account_types'); diff --git a/server/src/http/controllers/AccountOpeningBalance.js b/server/src/http/controllers/AccountOpeningBalance.js new file mode 100644 index 000000000..d11049e93 --- /dev/null +++ b/server/src/http/controllers/AccountOpeningBalance.js @@ -0,0 +1,63 @@ +import express from 'express'; +import { check, validationResult, oneOf } from 'express-validator'; +import { difference } from 'lodash'; +import asyncMiddleware from '../middleware/asyncMiddleware'; +import Account from '@/models/Account'; +// import AccountBalance from '@/models/AccountBalance'; + +export default { + + router() { + const router = express.Router(); + + router.post('/', + this.openingBalnace.validation, + asyncMiddleware(this.openingBalnace.handler)); + + return router; + }, + + openingBalnace: { + validation: [ + check('accounts').isArray({ min: 1 }), + check('accounts.*.id').exists().isInt(), + oneOf([ + check('accounts.*.debit').isNumeric().toFloat(), + check('accounts.*.credit').isNumeric().toFloat(), + ]), + ], + async handler(req, res) { + const validationErrors = validationResult(req); + + if (!validationErrors.isEmpty()) { + return res.boom.badData(null, { + code: 'validation_error', ...validationErrors, + }); + } + + const { accounts } = req.body; + + const accountsIds = accounts.map((account) => account.id); + const accountsCollection = await Account.query((query) => { + query.select(['id']); + query.whereIn('id', accountsIds); + }).fetchAll(); + + const accountsStoredIds = accountsCollection.map((account) => account.attributes.id); + const notFoundAccountsIds = difference(accountsIds, accountsStoredIds); + + const errorReasons = []; + + if (notFoundAccountsIds.length > 0) { + const ids = notFoundAccountsIds.map((a) => parseInt(a, 10)); + errorReasons.push({ type: 'NOT_FOUND_ACCOUNT', code: 100, ids }); + } + + if (errorReasons.length > 0) { + return res.boom.badData(null, { errors: errorReasons }); + } + + return res.status(200).send(); + }, + }, +}; diff --git a/server/src/http/controllers/Accounts.js b/server/src/http/controllers/Accounts.js index a04ce23dd..620c21da3 100644 --- a/server/src/http/controllers/Accounts.js +++ b/server/src/http/controllers/Accounts.js @@ -4,7 +4,7 @@ import asyncMiddleware from '../middleware/asyncMiddleware'; import Account from '@/models/Account'; import AccountBalance from '@/models/AccountBalance'; import AccountType from '@/models/AccountType'; -import JWTAuth from '@/http/middleware/jwtAuth'; +// import JWTAuth from '@/http/middleware/jwtAuth'; export default { /** @@ -13,18 +13,22 @@ export default { router() { const router = express.Router(); - router.use(JWTAuth); + // router.use(JWTAuth); router.post('/', this.newAccount.validation, asyncMiddleware(this.newAccount.handler)); - router.get('/:id', - this.getAccount.validation, - asyncMiddleware(this.getAccount.handler)); + router.post('/:id', + this.editAccount.validation, + asyncMiddleware(this.editAccount.handler)); - router.delete('/:id', - this.deleteAccount.validation, - asyncMiddleware(this.deleteAccount.handler)); + // router.get('/:id', + // this.getAccount.validation, + // asyncMiddleware(this.getAccount.handler)); + + // router.delete('/:id', + // this.deleteAccount.validation, + // asyncMiddleware(this.deleteAccount.handler)); return router; }, @@ -36,20 +40,22 @@ export default { validation: [ check('name').isLength({ min: 3 }).trim().escape(), check('code').isLength({ max: 10 }).trim().escape(), - check('type_id').isNumeric().toInt(), + check('account_type_id').isNumeric().toInt(), check('description').trim().escape(), ], async handler(req, res) { - const errors = validationResult(req); + const validationErrors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(422).json({ errors: errors.array() }); + if (!validationErrors.isEmpty()) { + return res.boom.badData(null, { + code: 'validation_error', ...validationErrors, + }); } const { name, code, description } = req.body; - const { type_id: typeId } = req.body; + const { account_type_id: typeId } = req.body; - const foundAccountCodePromise = Account.where('code', code).fetch(); + const foundAccountCodePromise = code ? Account.where('code', code).fetch() : null; const foundAccountTypePromise = AccountType.where('id', typeId).fetch(); const [foundAccountCode, foundAccountType] = await Promise.all([ @@ -57,7 +63,7 @@ export default { foundAccountTypePromise, ]); - if (!foundAccountCode) { + if (!foundAccountCode && foundAccountCodePromise) { return res.boom.badRequest(null, { errors: [{ type: 'NOT_UNIQUE_CODE', code: 100 }], }); @@ -68,11 +74,67 @@ export default { }); } const account = Account.forge({ - name, code, type_id: typeId, description, + name, code, account_type_id: typeId, description, }); await account.save(); - return res.boom.success({ item: { ...account.attributes } }); + return res.status(200).send({ item: { ...account.attributes } }); + }, + }, + + /** + * Edit the given account details. + */ + editAccount: { + validation: [ + check('name').isLength({ min: 3 }).trim().escape(), + check('code').isLength({ max: 10 }).trim().escape(), + check('account_type_id').isNumeric().toInt(), + check('description').trim().escape(), + ], + async handler(req, res) { + const { id } = req.params; + const validationErrors = validationResult(req); + + if (!validationErrors.isEmpty()) { + return res.boom.badData(null, { + code: 'validation_error', ...validationErrors, + }); + } + + const account = await Account.where('id', id).fetch(); + + if (!account) { + return res.boom.notFound(); + } + const { name, code, description } = req.body; + const { account_type_id: typeId } = req.body; + + const foundAccountCodePromise = (code && code !== account.attributes.code) + ? Account.query({ where: { code }, whereNot: { id } }).fetch() : null; + + const foundAccountTypePromise = (typeId !== account.attributes.account_type_id) + ? AccountType.where('id', typeId).fetch() : null; + + const [foundAccountCode, foundAccountType] = await Promise.all([ + foundAccountCodePromise, foundAccountTypePromise, + ]); + + if (!foundAccountCode && foundAccountCodePromise) { + return res.boom.badRequest(null, { + errors: [{ type: 'NOT_UNIQUE_CODE', code: 100 }], + }); + } + if (!foundAccountType && foundAccountTypePromise) { + return res.boom.badRequest(null, { + errors: [{ type: 'NOT_EXIST_ACCOUNT_TYPE', code: 110 }], + }); + } + + await account.save({ + name, code, account_type_id: typeId, description, + }); + return res.status(200).send(); }, }, @@ -105,7 +167,6 @@ export default { if (!account) { return res.boom.notFound(); } - await account.destroy(); await AccountBalance.where('account_id', id).destroy({ require: false }); diff --git a/server/src/http/controllers/Authentication.js b/server/src/http/controllers/Authentication.js index 04884b047..e43314159 100644 --- a/server/src/http/controllers/Authentication.js +++ b/server/src/http/controllers/Authentication.js @@ -37,16 +37,15 @@ export default { */ login: { validation: [ - check('crediential').isEmail(), - check('password').isLength({ min: 5 }), + check('crediential').exists().isEmail(), + check('password').exists().isLength({ min: 5 }), ], async handler(req, res) { const validationErrors = validationResult(req); if (!validationErrors.isEmpty()) { return res.boom.badData(null, { - code: 'validation_error', - ...validationErrors, + code: 'validation_error', ...validationErrors, }); } const { crediential, password } = req.body; @@ -81,17 +80,19 @@ export default { */ sendResetPassword: { validation: [ - check('email').isEmail(), + check('email').exists().isEmail(), ], // eslint-disable-next-line consistent-return async handler(req, res) { - const errors = validationResult(req); + const validationErrors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(422).json({ errors: errors.array() }); + if (!validationErrors.isEmpty()) { + return res.boom.badData(null, { + code: 'validation_error', ...validationErrors, + }); } const { email } = req.body; - const user = User.where('email').fetch(); + const user = User.where('email', email).fetch(); if (!user) { return res.status(422).send(); @@ -137,14 +138,21 @@ export default { */ resetPassword: { validation: [ - check('password').isLength({ min: 5 }), - check('reset_password'), + check('password').exists().isLength({ min: 5 }).custom((value, { req }) => { + if (value !== req.body.confirm_password) { + throw new Error("Passwords don't match"); + } else { + return value; + } + }), ], async handler(req, res) { - const errors = validationResult(req); + const validationErrors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(422).json({ errors: errors.array() }); + if (!validationErrors.isEmpty()) { + return res.boom.badData(null, { + code: 'VALIDATION_ERROR', ...validationErrors, + }); } const { token } = req.params; const { password } = req.body; @@ -155,11 +163,8 @@ export default { }).fetch(); if (!tokenModel) { - return res.status(400).send({ - error: { - type: 'token.invalid', - message: 'Password reset token is invalid or has expired', - }, + return res.boom.badRequest(null, { + errors: [{ type: 'TOKEN_INVALID', code: 100 }], }); } @@ -167,8 +172,8 @@ export default { email: tokenModel.attributes.email, }); if (!user) { - return res.status(400).send({ - error: { message: 'An unexpected error occurred.' }, + return res.boom.badRequest(null, { + errors: [{ type: 'USER_NOT_FOUND', code: 120 }], }); } const hashedPassword = await hashPassword(password); diff --git a/server/src/http/controllers/Items.js b/server/src/http/controllers/Items.js index 7668af6d2..a634ff4ed 100644 --- a/server/src/http/controllers/Items.js +++ b/server/src/http/controllers/Items.js @@ -184,7 +184,10 @@ export default { page: filter.page, }); - return res.status(200).send({ ...items.toJSON() }); + return res.status(200).send({ + items: items.toJSON(), + pagination: items.pagination, + }); }, }, }; diff --git a/server/src/http/controllers/Users.js b/server/src/http/controllers/Users.js index e69de29bb..011ad79fc 100644 --- a/server/src/http/controllers/Users.js +++ b/server/src/http/controllers/Users.js @@ -0,0 +1,241 @@ +import express from 'express'; +import { check, validationResult } from 'express-validator'; +import User from '@/models/User'; +import asyncMiddleware from '@/http/middleware/asyncMiddleware'; + +export default { + + /** + * Router constructor. + */ + router() { + const router = express.Router(); + + router.post('/', + this.newUser.validation, + asyncMiddleware(this.newUser.handler)); + + router.post('/:id', + this.editUser.validation, + asyncMiddleware(this.editUser.handler)); + + // router.get('/', + // this.listUsers.validation, + // asyncMiddleware(this.listUsers.handler)); + + // router.get('/:id', + // this.getUser.validation, + // asyncMiddleware(this.getUser.handler)); + + router.delete('/:id', + this.deleteUser.validation, + asyncMiddleware(this.deleteUser.handler)); + + return router; + }, + + /** + * Creates a new user. + */ + newUser: { + validation: [ + check('first_name').exists(), + check('last_name').exists(), + check('email').exists().isEmail(), + check('phone_number').optional().isMobilePhone(), + check('password').isLength({ min: 4 }).exists().custom((value, { req }) => { + if (value !== req.body.confirm_password) { + throw new Error("Passwords don't match"); + } else { + return value; + } + }), + check('status').exists().isBoolean().toBoolean(), + ], + async handler(req, res) { + const validationErrors = validationResult(req); + + if (!validationErrors.isEmpty()) { + return res.boom.badData(null, { + code: 'validation_error', ...validationErrors, + }); + } + const { email, phone_number: phoneNumber } = req.body; + + const foundUsers = await User.query((query) => { + query.where('email', email); + query.orWhere('phone_number', phoneNumber); + }).fetchAll(); + + const foundUserEmail = foundUsers.find((u) => u.attributes.email === email); + const foundUserPhone = foundUsers.find((u) => u.attributes.phone_number === phoneNumber); + + const errorReasons = []; + + if (foundUserEmail) { + errorReasons.push({ type: 'EMAIL_ALREADY_EXIST', code: 100 }); + } + if (foundUserPhone) { + errorReasons.push({ type: 'PHONE_NUMBER_ALREADY_EXIST', code: 120 }); + } + if (errorReasons.length > 0) { + return res.boom.badRequest(null, { errors: errorReasons }); + } + + const user = User.forge({ + first_name: req.body.first_name, + last_name: req.body.last_name, + email: req.body.email, + phone_number: req.body.phone_number, + active: req.body.status, + }); + + await user.save(); + + return res.status(200).send({ id: user.get('id') }); + }, + }, + + /** + * Edit details of the given user. + */ + editUser: { + validation: [ + check('first_name').exists(), + check('last_name').exists(), + check('email').exists().isEmail(), + check('phone_number').optional().isMobilePhone(), + check('password').isLength({ min: 4 }).exists().custom((value, { req }) => { + if (value !== req.body.confirm_password) { + throw new Error("Passwords don't match"); + } else { + return value; + } + }), + check('status').exists().isBoolean().toBoolean(), + ], + async handler(req, res) { + const { id } = req.params; + const validationErrors = validationResult(req); + + if (!validationErrors.isEmpty()) { + return res.boom.badData(null, { + code: 'validation_error', ...validationErrors, + }); + } + const user = await User.where('id', id).fetch(); + + if (!user) { + return res.boom.notFound(); + } + const { email, phone_number: phoneNumber } = req.body; + + const foundUsers = await User.query((query) => { + query.whereNot('id', id); + query.where('email', email); + query.orWhere('phone_number', phoneNumber); + }).fetchAll(); + + const foundUserEmail = foundUsers.find((u) => u.attribues.email === email); + const foundUserPhone = foundUsers.find((u) => u.attribues.phone_number === phoneNumber); + + const errorReasons = []; + + if (foundUserEmail) { + errorReasons.push({ type: 'EMAIL_ALREADY_EXIST', code: 100 }); + } + if (foundUserPhone) { + errorReasons.push({ type: 'PHONE_NUMBER_ALREADY_EXIST', code: 120 }); + } + if (errorReasons.length > 0) { + return res.badRequest(null, { errors: errorReasons }); + } + + await user.save({ + first_name: req.body.first_name, + last_name: req.body.last_name, + email: req.body.email, + phone_number: req.body.phone_number, + status: req.body.status, + }); + + return res.status(200).send(); + }, + }, + + /** + * Soft deleting the given user. + */ + deleteUser: { + validation: [], + async handler(req, res) { + const { id } = req.params; + const user = await User.where('id', id).fetch(); + + if (!user) { + return res.boom.notFound(null, { + errors: [{ type: 'USER_NOT_FOUND', code: 100 }], + }); + } + + await user.destroy(); + return res.status(200).send(); + }, + }, + + getUser: { + validation: [], + async handler(req, res) { + const { id } = req.params; + const user = await User.where('id', id).fetch(); + + if (!user) { + return res.boom.notFound(); + } + + return res.status(200).send({ item: user.toJSON() }); + }, + }, + + /** + * Retrieve the list of users. + */ + listUsers: { + validation: [], + handler(req, res) { + const filter = { + first_name: '', + last_name: '', + email: '', + phone_number: '', + + page_size: 10, + page: 1, + ...req.query, + }; + + const users = User.query((query) => { + if (filter.first_name) { + query.where('first_name', filter.first_name); + } + if (filter.last_name) { + query.where('last_name', filter.last_name); + } + if (filter.email) { + query.where('email', filter.email); + } + if (filter.phone_number) { + query.where('phone_number', filter.phone_number); + } + }).fetchPage({ + page_size: filter.page_size, + page: filter.page, + }); + + return res.status(200).send({ + items: users.toJSON(), + pagination: users.pagination, + }); + }, + }, +}; diff --git a/server/src/http/index.js b/server/src/http/index.js index 41cd4d771..6f9f734a5 100644 --- a/server/src/http/index.js +++ b/server/src/http/index.js @@ -4,12 +4,14 @@ import Users from '@/http/controllers/Users'; import Items from '@/http/controllers/Items'; import ItemCategories from '@/http/controllers/ItemCategories'; import Accounts from '@/http/controllers/Accounts'; +import AccountOpeningBalance from '@/http/controllers/AccountOpeningBalance'; export default (app) => { // app.use('/api/oauth2', OAuth2.router()); app.use('/api/auth', Authentication.router()); app.use('/api/users', Users.router()); app.use('/api/accounts', Accounts.router()); + app.use('/api/accountOpeningBalance', AccountOpeningBalance.router()); app.use('/api/items', Items.router()); app.use('/api/item_categories', ItemCategories.router()); }; diff --git a/server/src/http/middleware/jwtAuth.js b/server/src/http/middleware/jwtAuth.js index a59adc9c7..c837b1804 100644 --- a/server/src/http/middleware/jwtAuth.js +++ b/server/src/http/middleware/jwtAuth.js @@ -1,13 +1,18 @@ +/* eslint-disable consistent-return */ import jwt from 'jsonwebtoken'; import User from '@/models/User'; +import Auth from '@/models/Auth'; const authMiddleware = (req, res, next) => { const token = req.headers['x-access-token'] || req.query.token; - const onError = () => res.status(401).send({ - success: false, - message: 'unauthorized', - }); + const onError = () => { + Auth.loggedOut(); + res.status(401).send({ + success: false, + message: 'unauthorized', + }); + } if (!token) { return onError(); @@ -19,7 +24,9 @@ const authMiddleware = (req, res, next) => { if (error) { reject(error); } else { + // eslint-disable-next-line no-underscore-dangle req.user = await User.where('id', decoded._id).fetch(); + Auth.setAuthenticatedUser(req.user); if (!req.user) { return onError(); diff --git a/server/src/models/Account.js b/server/src/models/Account.js index 08387f599..49f0a9ed6 100644 --- a/server/src/models/Account.js +++ b/server/src/models/Account.js @@ -1,7 +1,6 @@ import bookshelf from './bookshelf'; const Account = bookshelf.Model.extend({ - /** * Table name */ @@ -11,6 +10,17 @@ const Account = bookshelf.Model.extend({ * Timestamp columns. */ hasTimestamps: ['created_at', 'updated_at'], + + /** + * Account model may belongs to account type. + */ + type() { + return this.belongsTo('AccountType', 'account_type_id'); + }, + + balances() { + return this.hasMany('AccountBalance', 'accounnt_id'); + } }); export default bookshelf.model('Account', Account); diff --git a/server/src/models/AccountBalance.js b/server/src/models/AccountBalance.js index e69de29bb..64dfb1b17 100644 --- a/server/src/models/AccountBalance.js +++ b/server/src/models/AccountBalance.js @@ -0,0 +1,23 @@ +import bookshelf from './bookshelf'; + +const AccountBalance = bookshelf.Model.extend({ + + /** + * Table name + */ + tableName: 'account_balance', + + /** + * Timestamp columns. + */ + hasTimestamps: false, + + /** + * + */ + account() { + return this.belongsTo('Account', 'account_id'); + }, +}); + +export default bookshelf.model('AccountBalance', AccountBalance); diff --git a/server/src/models/AccountType.js b/server/src/models/AccountType.js index e69de29bb..87a98013d 100644 --- a/server/src/models/AccountType.js +++ b/server/src/models/AccountType.js @@ -0,0 +1,23 @@ +import bookshelf from './bookshelf'; + +const AccountType = bookshelf.Model.extend({ + + /** + * Table name + */ + tableName: 'accounts', + + /** + * Timestamp columns. + */ + hasTimestamps: false, + + /** + * Account type may has many associated accounts. + */ + accounts() { + return this.hasMany('Account', 'account_type_id'); + }, +}); + +export default bookshelf.model('AccountType', AccountType); diff --git a/server/src/models/Auth.js b/server/src/models/Auth.js new file mode 100644 index 000000000..93dc11103 --- /dev/null +++ b/server/src/models/Auth.js @@ -0,0 +1,38 @@ + +export default class Auth { + /** + * Retrieve the authenticated user. + */ + static get user() { + return null; + } + + /** + * Sets the authenticated user. + * @param {User} user + */ + static setAuthenticatedUser(user) { + this.user = user; + } + + /** + * Retrieve the authenticated user ID. + */ + static userId() { + if (!this.user) { + return false; + } + return this.user.id; + } + + /** + * Whether the user is logged or not. + */ + static isLogged() { + return !!this.user; + } + + static loggedOut() { + this.user = null; + } +} diff --git a/server/src/models/Metable.js b/server/src/models/Metable.js new file mode 100644 index 000000000..2b28f2943 --- /dev/null +++ b/server/src/models/Metable.js @@ -0,0 +1,290 @@ +import knex from '@/database/knex'; +// import cache from 'memory-cache'; + +// Metadata +export default { + METADATA_GROUP: 'default', + KEY_COLUMN: 'key', + VALUE_COLUMN: 'value', + TYPE_COLUMN: 'type', + + extraColumns: [], + metadata: [], + shouldReload: true, + extraMetadataQuery: () => {}, + + /** + * Set the value column key to query from. + * @param {String} name - + */ + setKeyColumnName(name) { + this.KEY_COLUMN = name; + }, + + /** + * Set the key column name to query from. + * @param {String} name - + */ + setValueColumnName(name) { + this.VALUE_COLUMN = name; + }, + + /** + * Set extra columns to be added to the rows. + * @param {Array} columns - + */ + setExtraColumns(columns) { + this.extraColumns = columns; + }, + + /** + * Retrieve the cache namespace. + */ + getCacheNamespace() { + const { metadataCacheNamespace: cacheName } = this; + return typeof cacheName === 'function' + ? cacheName() : cacheName; + }, + + /** + * Metadata database query. + * @param {Object} query - + * @param {String} groupName - + */ + whereQuery(query, key) { + const groupName = this.METADATA_GROUP; + + if (groupName) { + query.where('group', groupName); + } + if (key) { + if (Array.isArray(key)) { + query.whereIn('key', key); + } else { + query.where('key', key); + } + } + }, + + /** + * Loads the metadata from the storage. + * @param {String|Array} key - + * @param {Boolean} force - + */ + async load(force = false) { + if (this.shouldReload || force) { + const metadataCollection = await this.query((query) => { + this.whereQuery(query); + this.extraMetadataQuery(query); + }).fetchAll(); + + this.shouldReload = false; + this.metadata = []; + + const metadataArray = this.mapMetadataCollection(metadataCollection); + metadataArray.forEach((metadata) => { this.metadata.push(metadata); }); + } + }, + + /** + * Fetches all the metadata that associate with the current group. + */ + async allMeta(force = false) { + await this.load(force); + return this.metadata; + }, + + /** + * Find the given metadata key. + * @param {String} key - + * @return {object} - Metadata object. + */ + findMeta(key) { + return this.metadata.find((meta) => meta.key === key); + }, + + /** + * Fetch the metadata of the current group. + * @param {*} key - + */ + async getMeta(key, defaultValue, force = false) { + await this.load(force); + + const metadata = this.findMeta(key); + return metadata ? metadata.value : defaultValue || false; + }, + + /** + * Markes the metadata to should be deleted. + * @param {String} key - + */ + async removeMeta(key) { + await this.load(); + const metadata = this.findMeta(key); + + if (metadata) { + metadata.markAsDeleted = true; + } + this.shouldReload = true; + }, + + /** + * Remove all meta data of the given group. + * @param {*} group + */ + removeAllMeta(group = 'default') { + this.metdata.map((meta) => ({ + ...(meta.group !== group) ? { markAsDeleted: true } : {}, + ...meta, + })); + this.shouldReload = true; + }, + + /** + * Set the meta data to the stack. + * @param {String} key - + * @param {String} value - + */ + async setMeta(key, value, payload) { + if (Array.isArray(key)) { + const metadata = key; + metadata.forEach((meta) => { + this.setMeta(meta.key, meta.value); + }); + return; + } + + await this.load(); + const metadata = this.findMeta(key); + + if (metadata) { + metadata.value = value; + metadata.markAsUpdated = true; + } else { + this.metadata.push({ + value, key, ...payload, markAsInserted: true, + }); + } + }, + + /** + * Saved the modified metadata. + */ + async saveMeta() { + const inserted = this.metadata.filter((m) => (m.markAsInserted === true)); + const updated = this.metadata.filter((m) => (m.markAsUpdated === true)); + const deleted = this.metadata.filter((m) => (m.markAsDeleted === true)); + + const metadataDeletedKeys = deleted.map((m) => m.key); + const metadataInserted = inserted.map((m) => this.mapMetadata(m, 'format')); + const metadataUpdated = updated.map((m) => this.mapMetadata(m, 'format')); + + const batchUpdate = (collection) => knex.transaction((trx) => { + const queries = collection.map((tuple) => { + const query = knex(this.tableName); + this.whereQuery(query, tuple.key); + this.extraMetadataQuery(query); + return query.update(tuple).transacting(trx); + }); + return Promise.all(queries).then(trx.commit).catch(trx.rollback); + }); + + await Promise.all([ + knex.insert(metadataInserted).into(this.tableName), + batchUpdate(metadataUpdated), + metadataDeletedKeys.length > 0 + ? this.query('whereIn', this.KEY_COLUMN, metadataDeletedKeys).destroy({ + require: true, + }) : null, + ]); + this.shouldReload = true; + }, + + /** + * Purge all the cached metadata in the memory. + */ + purgeMetadata() { + this.metadata = []; + this.shouldReload = true; + }, + + /** + * Parses the metadata value. + * @param {String} value - + * @param {String} valueType - + */ + parseMetaValue(value, valueType) { + let parsedValue; + + switch (valueType) { + case 'integer': + parsedValue = parseInt(value, 10); + break; + case 'float': + parsedValue = parseFloat(value); + break; + case 'boolean': + parsedValue = Boolean(value); + break; + case 'json': + parsedValue = JSON.parse(parsedValue); + break; + default: + parsedValue = value; + break; + } + return parsedValue; + }, + + /** + * Format the metadata before saving to the database. + * @param {String|Number|Boolean} value - + * @param {String} valueType - + * @return {String|Number|Boolean} - + */ + formatMetaValue(value, valueType) { + let parsedValue; + + switch (valueType) { + case 'number': + parsedValue = `${value}`; + break; + case 'boolean': + parsedValue = value ? '1' : '0'; + break; + case 'json': + parsedValue = JSON.stringify(parsedValue); + break; + default: + parsedValue = value; + break; + } + return parsedValue; + }, + + mapMetadata(attr, parseType = 'parse') { + return { + key: attr[this.KEY_COLUMN], + value: (parseType === 'parse') + ? this.parseMetaValue( + attr[this.VALUE_COLUMN], + this.TYPE_COLUMN ? attr[this.TYPE_COLUMN] : false, + ) + : this.formatMetaValue( + attr[this.VALUE_COLUMN], + this.TYPE_COLUMN ? attr[this.TYPE_COLUMN] : false, + ), + ...this.extraColumns.map((extraCol) => ({ + [extraCol]: attr[extraCol] || null, + })), + }; + }, + + /** + * Parse the metadata collection. + * @param {Array} collection - + */ + mapMetadataCollection(collection, parseType = 'parse') { + return collection.map((model) => this.mapMetadata(model.attributes, parseType)); + }, +}; diff --git a/server/src/models/Setting.js b/server/src/models/Setting.js index dcdf06f48..7eae9c738 100644 --- a/server/src/models/Setting.js +++ b/server/src/models/Setting.js @@ -1,7 +1,8 @@ import bookshelf from './bookshelf'; +import Metable from './Metable'; +import Auth from './Auth'; const Setting = bookshelf.Model.extend({ - /** * Table name */ @@ -11,6 +12,23 @@ const Setting = bookshelf.Model.extend({ * Timestamp columns. */ hasTimestamps: false, + + /** + * Extra metadata query to query with the current authenticate user. + * @param {Object} query + */ + extraMetadataQuery(query) { + if (Auth.isLogged()) { + query.where('user_id', Auth.userId()); + } + }, +}, { + /** + * Table name + */ + tableName: 'settings', + + ...Metable, }); export default bookshelf.model('Setting', Setting); diff --git a/server/src/utils.js b/server/src/utils.js index bef53ade5..52274a9b4 100644 --- a/server/src/utils.js +++ b/server/src/utils.js @@ -6,9 +6,7 @@ const hashPassword = (password) => new Promise((resolve) => { }); }); -const origin = (request) => { - return `${request.protocol}://${request.hostname}`; -}; +const origin = (request) => `${request.protocol}://${request.hostname}`; export { hashPassword, diff --git a/server/tests/models/Account.test.js b/server/tests/models/Account.test.js new file mode 100644 index 000000000..ffdb7f4c7 --- /dev/null +++ b/server/tests/models/Account.test.js @@ -0,0 +1,16 @@ +import { create, expect } from '~/testInit'; +import Account from '@/models/Account'; +// eslint-disable-next-line no-unused-vars +import AccountType from '@/models/AccountType'; + +describe('Model: Account', () => { + it('Should account model belongs to the associated account type model.', async () => { + const accountType = await create('account_type'); + const account = await create('account', { account_type_id: accountType.id }); + + const accountModel = await Account.where('id', account.id).fetch(); + const accountTypeModel = await accountModel.type().fetch(); + + expect(accountTypeModel.attributes.id).equals(account.id); + }); +}); diff --git a/server/tests/models/AccountType.test.js b/server/tests/models/AccountType.test.js new file mode 100644 index 000000000..70f71ac3a --- /dev/null +++ b/server/tests/models/AccountType.test.js @@ -0,0 +1,16 @@ +import { create, expect } from '~/testInit'; +import '@/models/Account'; +import AccountType from '@/models/AccountType'; + +describe.only('Model: AccountType', () => { + it('Shoud account type model has many associated accounts.', async () => { + const accountType = await create('account_type'); + await create('account', { account_type_id: accountType.id }); + await create('account', { account_type_id: accountType.id }); + + const accountTypeModel = await AccountType.where('id', accountType.id).fetch(); + const typeAccounts = await accountTypeModel.accounts().fetch(); + + expect(typeAccounts.length).equals(2); + }); +}); diff --git a/server/tests/models/Setting.test.js b/server/tests/models/Setting.test.js new file mode 100644 index 000000000..365c20709 --- /dev/null +++ b/server/tests/models/Setting.test.js @@ -0,0 +1,197 @@ +import sinon from 'sinon'; +import { create, expect } from '~/testInit'; +import Setting from '@/models/Setting'; +import knex from '../../src/database/knex'; + +describe('Model: Setting', () => { + afterEach(() => { + Setting.purgeMetadata(); + }); + + describe('Setting.AllMeta()', async () => { + it('Should fetch all metadata from storage in the first call.', async () => { + await create('setting'); + const querySpy = sinon.spy(Setting, 'query'); + + const metadata = await Setting.allMeta(); + + expect(querySpy.calledOnce).equals(true); + expect(metadata).to.have.lengthOf(1); + + querySpy.restore(); + }); + + it('Should get all meta data from stored cache in the second call.', async () => { + await create('setting'); + const querySpy = sinon.spy(Setting, 'query'); + + await Setting.allMeta(); + await Setting.allMeta(); + + expect(querySpy.calledOnce).equals(true); + expect(Setting.metadata).to.have.lengthOf(1); + + querySpy.restore(); + }); + }); + + describe('Setting.getMeta()', () => { + it('Should fetch metadata of the given key from storage.', async () => { + const setting = await create('setting'); + const metadata = await Setting.getMeta(setting.key); + + expect(metadata).equals(setting.value); + }); + + it('Should retrieve the default value if the metadata key was not found.', async () => { + const metadata = await Setting.getMeta('setting', 'default'); + expect(metadata).equals('default'); + }); + + it('Should get the same metadata key from cache in the second call.', async () => { + const setting = await create('setting'); + await create('setting'); + + const querySpy = sinon.spy(Setting, 'query'); + + await Setting.getMeta(setting.key); + expect(querySpy.calledOnce).equals(true); + + await Setting.getMeta(setting.key); + expect(querySpy.calledOnce).equals(true); + + querySpy.restore(); + }); + + it('Should get the different metadata key from storage.', async () => { + const setting = await create('setting'); + const settingAnother = await create('setting'); + + const querySpy = sinon.spy(Setting, 'query'); + + await Setting.getMeta(setting.key); + expect(querySpy.calledOnce).equals(true); + + await Setting.getMeta(settingAnother.key); + expect(querySpy.calledOnce).equals(true); + + querySpy.restore(); + }); + + it('Should hard fetching the metadata from the storage when passing `force` parameter.', async () => { + const setting = await create('setting'); + await create('setting'); + + const querySpy = sinon.spy(Setting, 'query'); + + await Setting.allMeta(); + expect(querySpy.calledOnce).equals(true); + expect(Setting.metadata).to.have.lengthOf(2); + + await Setting.getMeta(setting.key, null, true); + expect(querySpy.calledTwice).equals(true); + expect(Setting.metadata).to.have.lengthOf(2); + + querySpy.restore(); + }); + }); + + describe('Setting.setMeta()', () => { + it('Should mark the given metadata as updated in the stack.', async () => { + const setting = await create('setting'); + await Setting.setMeta(setting.key, 'Ahmed'); + + const foundMeta = Setting.metadata.find((metadata) => ( + metadata.key === setting.key && metadata.markAsUpdated === true + && metadata.value === 'Ahmed' + )); + expect(!!foundMeta).equals(true); + }); + + it('Should mark the set metadata as inserted metadata in the stack.', async () => { + await create('setting'); + await Setting.setMeta('key', 'value'); + + const foundMeta = Setting.metadata.find((metadata) => ( + metadata.key === 'key' && metadata.markAsInserted === true + && metadata.value === 'value' + )); + expect(!!foundMeta).equals(true); + }); + + it('Should fetch the metadata from the storage in case the metadata was exist.', async () => { + const setting = await create('setting'); + const querySpy = sinon.spy(Setting, 'query'); + + await Setting.setMeta(setting.key, 'value'); + expect(querySpy.calledOnce).equals(true); + + await Setting.setMeta(setting.key, 'updated-value'); + expect(querySpy.calledOnce).equals(true); + }); + + it('Should mark the updated bluk metadata as updated in the stock.', async () => { + + }); + + it('Should mark the inserted bluk metadata as inserted in the stock.', async () => { + + }); + }); + + describe('Setting.removeMeta()', () => { + it('Should mark the given metadata as deleted', async () => { + const setting = await create('setting'); + await Setting.removeMeta(setting.key); + + const foundMeta = Setting.metadata.find((metadata) => ( + metadata.key === setting.key && metadata.markAsDeleted === true + )); + expect(!!foundMeta).equals(true); + }); + + it('Should not query the storage when found cached the metadata.', async () => { + const setting = await create('setting'); + await Setting.allMeta(); + + const querySpy = sinon.spy(Setting, 'query'); + + await Setting.removeMeta(setting.key); + expect(querySpy.calledOnce).equals(false); + + querySpy.restore(); + }); + }); + + describe('Setting.saveMeta()', () => { + it('Should insert the metadata that set to the stock.', async () => { + await Setting.setMeta('key', 'value'); + await Setting.saveMeta(); + + const storedMetadata = await knex('settings'); + expect(storedMetadata).to.have.lengthOf(1); + }); + + it('Should update the metadata that updated in the stock.', async () => { + const setting = await create('setting'); + + await Setting.setMeta(setting.key, 'value'); + await Setting.saveMeta(); + + const storedMetadata = await knex('settings'); + + expect(storedMetadata).to.have.lengthOf(1); + expect(storedMetadata[0].value).equals('value'); + }); + + it('Should delete the metadata that removed from the stock.', async () => { + const setting = await create('setting'); + + await Setting.removeMeta(setting.key); + await Setting.saveMeta(); + + const storedMetadata = await knex('settings'); + expect(storedMetadata).to.have.lengthOf(0); + }); + }); +}); diff --git a/server/tests/routes/accountOpeningBalance.test.js b/server/tests/routes/accountOpeningBalance.test.js new file mode 100644 index 000000000..6f03b9184 --- /dev/null +++ b/server/tests/routes/accountOpeningBalance.test.js @@ -0,0 +1,44 @@ +import { request, expect } from '~/testInit'; + +describe.only('routes: `/accountOpeningBalance`', () => { + describe('POST `/accountOpeningBalance`', () => { + it('Should `accounts` be array type.', async () => { + const res = await request().post('/api/accountOpeningBalance').send({ + accounts: 1000, + }); + + expect(res.status).equals(422); + expect(res.body.code).equals('VALIDATION_ERROR'); + }); + + it('Should `accounts.*.id` be integer', async () => { + const res = await request().post('/api/accountOpeningBalance').send({ + accounts: 1000, + }); + + expect(res.status).equals(422); + expect(res.body.code).equals('VALIDATION_ERROR'); + }); + + it('Should `accounts.*.debit` be numeric.', async () => { + const res = await request().post('/api/accountOpeningBalance').send({ + accounts: [{ id: 'id' }], + }); + + expect(res.status).equals(422); + }); + + it.only('Should `accounts.*.id` be exist in the storage.', async () => { + const res = await request().post('/api/accountOpeningBalance').send({ + accounts: [ + { id: 100, credit: 100, debit: 100 }, + ], + }); + + expect(res.status).equals(422); + expect(res.body.errors).include.something.that.deep.equals({ + type: 'NOT_FOUND_ACCOUNT', code: 100, ids: [100], + }); + }); + }); +}); diff --git a/server/tests/routes/accounts.test.js b/server/tests/routes/accounts.test.js new file mode 100644 index 000000000..db8920755 --- /dev/null +++ b/server/tests/routes/accounts.test.js @@ -0,0 +1,138 @@ +import { request, expect, create } from '~/testInit'; +import knex from '@/database/knex'; + +describe('routes: /accounts/', () => { + describe('POST `/accounts`', () => { + it('Should `name` be required.', async () => { + const res = await request().post('/api/accounts').send(); + + expect(res.status).equals(422); + expect(res.body.code).equals('validation_error'); + }); + + it('Should `account_type_id` be required.', async () => { + const res = await request().post('/api/accounts').send(); + + expect(res.status).equals(422); + expect(res.body.code).equals('validation_error'); + }); + + it('Should max length of `code` be limited.', async () => { + const res = await request().post('/api/accounts').send(); + + expect(res.status).equals(422); + expect(res.body.code).equals('validation_error'); + }); + + it('Should response type not found in case `account_type_id` was not exist.', async () => { + const res = await request().post('/api/accounts').send(); + + expect(res.status).equals(422); + }); + + it('Should account code be unique in the storage.', async () => { + const account = await create('account'); + const res = await request().post('/api/accounts').send({ + ...account, + }); + + expect(res.status).equals(400); + expect(res.body.errors).include.something.that.deep.equals({ + type: 'PARENT_CATEGORY_NOT_FOUND', code: 100, + }); + }); + + it('Should response success with correct data form.', async () => { + const account = await create('account'); + const res = await request().post('/api/accounts').send({ + name: 'Name', + description: 'description here', + account_type_id: account.account_type_id, + parent_account_id: account.id, + }); + + expect(res.status).equals(200); + }); + + it('Should store account data in the storage.', async () => { + + }); + }); + + describe('POST `/accounts/:id`', () => { + it('Should `name` be required.', async () => { + const account = await create('account'); + const res = await request().post(`/api/accounts/${account.id}`).send(); + + expect(res.status).equals(422); + expect(res.body.code).equals('validation_error'); + }); + + it('Should `account_type_id` be required.', async () => { + const account = await create('account'); + const res = await request().post(`/api/accounts/${account.id}`).send(); + + expect(res.status).equals(422); + expect(res.body.code).equals('validation_error'); + }); + + it('Should max length of `code` be limited.', async () => { + const account = await create('account'); + const res = await request().post(`/api/accounts/${account.id}`).send(); + + expect(res.status).equals(422); + expect(res.body.code).equals('validation_error'); + }); + + it('Should response type not found in case `account_type_id` was not exist.', async () => { + const res = await request().post('/api/accounts').send(); + + expect(res.status).equals(422); + }); + + it('Should account code be unique in the storage.', async () => { + const account = await create('account', { code: 'ABCD' }); + const res = await request().post(`/api/accounts/${account.id}`).send({ + // code: ', + ...account, + }); + + expect(res.status).equals(400); + expect(res.body.errors).include.something.that.deep.equals({ + type: 'NOT_UNIQUE_CODE', code: 100, + }); + }); + + it('Should response success with correct data form.', async () => { + const account = await create('account'); + const res = await request().post('/api/accounts').send({ + name: 'Name', + description: 'description here', + account_type_id: account.account_type_id, + parent_account_id: account.id, + }); + + expect(res.status).equals(200); + }); + }); + + describe('GET: `/accounts`', () => { + + }); + + describe('DELETE: `/accounts`', () => { + it('Should response not found in case account was not exist.', async () => { + const res = await request().delete('/api/accounts/10').send(); + + expect(res.status).equals(404); + }); + + it('Should delete the give account from the storage.', async () => { + const account = await create('account'); + await request().delete(`/api/accounts/${account.id}`); + + const foundAccounts = await knex('accounts').where('id', account.id); + expect(foundAccounts).to.have.lengthOf(1); + }); + }); +}); diff --git a/server/tests/routes/auth.test.js b/server/tests/routes/auth.test.js index 8490f6310..734a9492f 100644 --- a/server/tests/routes/auth.test.js +++ b/server/tests/routes/auth.test.js @@ -1,5 +1,6 @@ import { request, expect, create } from '~/testInit'; import { hashPassword } from '@/utils'; +import knex from '@/database/knex'; describe('routes: /auth/', () => { describe('POST `/api/auth/login`', () => { @@ -110,61 +111,136 @@ describe('routes: /auth/', () => { }); }); - // describe('POST: `auth/send_reset_password`', () => { + describe('POST: `/auth/send_reset_password`', () => { + it('Should `email` be required.', async () => { + const res = await request().post('/api/auth/send_reset_password').send(); - // it('Should `email` be required.', () => { + expect(res.status).equals(422); + expect(res.body.code).equals('validation_error'); + }); - // }); + it('Should response unproccessable if the email address was invalid.', async () => { + const res = await request().post('/api/auth/send_reset_password').send({ + email: 'invalid_email', + }); - // it('Should response unproccessable if the email address was invalid.', () => { + expect(res.status).equals(422); + expect(res.body.code).equals('validation_error'); + }); - // }); + it('Should response unproccessable if the email address was not exist.', async () => { + const res = await request().post('/api/auth/send_reset_password').send({ + email: 'admin@admin.com', + }); - // it('Should response unproccessable if the email address was not exist.', () => { + expect(res.status).equals(422); + expect(res.body.errors).include.something.that.deep.equals({ + type: 'EMAIL_NOT_FOUND', code: 100, + }); + }); - // }); + it('Should delete all already tokens that associate to the given email.', async () => { + const user = await create('user'); + const token = '123123'; - // it('Should delete all already tokens that associate to the given email.', () => { + await knex('password_resets').insert({ email: user.email, token }); + await request().post('/api/auth/send_reset_password').send({ + email: user.email, + }); - // }); + const oldPasswordToken = await knex('password_resets').where('token', token); - // it('Should store new token associate with the given email.', () => { + expect(oldPasswordToken).to.have.lengthOf(0); + }); - // }); + it('Should store new token associate with the given email.', async () => { + const user = await create('user'); + await request().post('/api/auth/send_reset_password').send({ + email: user.email, + }); - // it('Should response success if the email was exist.', () => { + const token = await knex('password_resets').where('email', user.email); - // }); + expect(token).to.have.lengthOf(1); + }); - // it('Should token be stored to the table after success request.', () => { + it('Should response success if the email was exist.', async () => { + const user = await create('user'); + const res = await request().post('/api/auth/send_reset_password').send({ + email: user.email, + }); - // }); - // }); + expect(res.status).equals(200); + }); + }); - // describe('POST: `/auth/reset/:token`', () => { + describe('POST: `/auth/reset/:token`', () => { + // it('Should response forbidden if the token was invalid.', () => { - // it('Should response forbidden if the token was invalid.', () => { + // }); - // }); + it('Should response forbidden if the token was expired.', () => { - // it('Should response forbidden if the token was expired.', () => { + }); - // }); + it('Should `password` be required.', async () => { + const passwordReset = await create('password_reset'); + const res = await request().post(`/api/reset/${passwordReset.token}`).send(); - // it('Should password be required.', () => { + expect(res.status).equals(422); + expect(res.body.code).equals('VALIDATION_ERROR'); - // }); + const paramsErrors = res.body.errors.map((error) => error.param); + expect(paramsErrors).to.include('password'); + }); - // it('Should password and confirm_password be equal.', () => { + it('Should password and confirm_password be equal.', async () => { + const passwordReset = await create('password_reset'); + const res = await request().post(`/api/reset/${passwordReset.token}`).send({ + password: '123123', + }); - // }); + expect(res.status).equals(422); + expect(res.body.code).equals('VALIDATION_ERROR'); - // it('Should token be deleted after success response.', () => { + const paramsErrors = res.body.errors.map((error) => error.param); + expect(paramsErrors).to.include('password'); + }); - // }); + it('Should response success with correct data form.', async () => { + const passwordReset = await create('password_reset'); + const res = await request().post(`/api/reset/${passwordReset.token}`).send({ + password: '123123', + confirm_password: '123123', + }); - // it('Should password be updated after success response.', () => { + expect(res.status).equals(200); + }); - // }) - // }); + it('Should token be deleted after success response.', async () => { + const passwordReset = await create('password_reset'); + await request().post(`/api/reset/${passwordReset.token}`).send({ + password: '123123', + confirm_password: '123123', + }); + + const foundTokens = await knex('password_resets').where('email', passwordReset.email); + expect(foundTokens).to.have.lengthOf(0); + }); + + it('Should password be updated after success response.', async () => { + const user = await create('user'); + const passwordReset = await create('password_reset', { user_id: user.id }); + + await request().post(`/api/reset/${passwordReset.token}`).send({ + password: '123123', + confirm_password: '123123', + }); + + const foundUser = await knex('users').where('id', user.id); + + expect(foundUser.id).equals(user.id); + expect(foundUser.password).not.equals(user.password); + }); + }); }); diff --git a/server/tests/routes/roles.test.js b/server/tests/routes/roles.test.js index e69de29bb..60173431f 100644 --- a/server/tests/routes/roles.test.js +++ b/server/tests/routes/roles.test.js @@ -0,0 +1,26 @@ + +describe('routes: `/roles/`', () => { + describe('POST: `/roles/`', () => { + it('Should name be required.', () => { + + }); + + it('Should `permissions` be ', () => { + + }); + + it('Should response success with correct data format.', async () => { + + }); + + it('Should save the given role details in the storage.', () => { + + }); + }); + + describe('DELETE: `/roles/:id`', () => { + it('Should not delete the predefined role.', () => { + + }); + }); +}); diff --git a/server/tests/routes/users.test.js b/server/tests/routes/users.test.js index 74bd22703..87ac59cd8 100644 --- a/server/tests/routes/users.test.js +++ b/server/tests/routes/users.test.js @@ -1,3 +1,10 @@ +import knex from '@/database/knex'; +import { + request, + expect, + create, + make, +} from '~/testInit'; describe('routes: `/routes`', () => { describe('POST: `/routes`', () => { @@ -5,16 +12,266 @@ describe('routes: `/routes`', () => { }); - it('Should `email` be required.', () => { + it('Should `first_name` be required.', async () => { + const res = await request().post('/api/users'); + + expect(res.status).equals(422); + + const foundFirstNameParam = res.body.errors.find((error) => error.param === 'first_name'); + expect(!!foundFirstNameParam).equals(true); + }); + + it('Should `last_name` be required.', async () => { + const res = await request().post('/api/users'); + + expect(res.status).equals(422); + + const foundFirstNameParam = res.body.errors.find((error) => error.param === 'last_name'); + expect(!!foundFirstNameParam).equals(true); + }); + + it('Should `email` be required.', async () => { + const res = await request().post('/api/users'); + + expect(res.status).equals(422); + + const foundEmailParam = res.body.errors.find((error) => error.param === 'email'); + expect(!!foundEmailParam).equals(true); + }); + + it('Should be `email` be valid format.', async () => { + const user = make('user'); + const res = await request().post('/api/users').send({ + first_name: user.first_name, + last_name: user.last_name, + email: 'email', + phone_number: user.phone_number, + status: 1, + }); + + expect(res.status).equals(422); + + const foundEmailParam = res.body.errors.find((error) => error.param === 'email'); + expect(!!foundEmailParam).equals(true); + }); + + it('Should `phone_number` be valid format.', async () => { + const user = make('user'); + const res = await request().post('/api/users').send({ + first_name: user.first_name, + last_name: user.last_name, + email: user.email, + phone_number: 'phone_number', + status: 1, + }); + + expect(res.status).equals(422); + + const phoneNumberParam = res.body.errors.find((error) => error.param === 'phone_number'); + expect(!!phoneNumberParam).equals(true); + }); + + it('Should `password` be required.', async () => { + const res = await request().post('/api/users').send(); + + expect(res.status).equals(422); + + const passwordParam = res.body.errors.find((error) => error.param === 'password'); + expect(!!passwordParam).equals(true); + }); + + it('Should password be equals confirm_password.', async () => { + const res = await request().post('/api/users').send({ + password: '123123', + }); + + expect(res.status).equals(422); + + const passwordParam = res.body.errors.find((error) => error.param === 'password'); + expect(!!passwordParam).equals(true); + }); + + it('Should `status` be boolean', async () => { + const res = await request().post('/api/users').send({ + status: 'not_boolean', + }); + + expect(res.status).equals(422); + + const statusParam = res.body.errors.find((error) => error.param === 'status'); + expect(!!statusParam).equals(true); + }); + + it('Should response bad request in case email was already exist.', async () => { + const user = await create('user'); + const res = await request().post('/api/users').send({ + first_name: user.first_name, + last_name: user.last_name, + email: user.email, + phone_number: user.phone_number, + password: '123123123', + confirm_password: '123123123', + status: 1, + }); + + expect(res.status).equals(400); + expect(res.body.errors).include.something.that.deep.equals({ + type: 'EMAIL_ALREADY_EXIST', code: 100, + }); + }); + + it('Should response bad request in case phone number was already exist.', async () => { + const user = await create('user'); + const res = await request().post('/api/users').send({ + first_name: user.first_name, + last_name: user.last_name, + email: user.email, + phone_number: user.phone_number, + password: user.password, + confirm_password: user.password, + status: 1, + }); + + expect(res.status).equals(400); + expect(res.body.errors).include.something.that.deep.equals({ + type: 'PHONE_NUMBER_ALREADY_EXIST', code: 120, + }); + }); + + it('Should response success with correct data type.', async () => { + const user = await make('user'); + const res = await request().post('/api/users').send({ + first_name: user.first_name, + last_name: user.last_name, + email: user.email, + phone_number: user.phone_number, + password: user.password, + confirm_password: user.password, + status: 1, + }); + + expect(res.status).equals(200); + }); + }); + + describe('POST: `/users/:id`', () => { + it('Should create a new user if the user was not authorized.', () => { }); - it('Should `password` be required.', () => { + it('Should `first_name` be required.', async () => { + const user = await create('user'); + const res = await request().post(`/api/users/${user.id}`); + + expect(res.status).equals(422); + + const foundFirstNameParam = res.body.errors.find((error) => error.param === 'first_name'); + expect(!!foundFirstNameParam).equals(true); + }); + + it('Should `last_name` be required.', async () => { + const user = await create('user'); + const res = await request().post(`/api/users/${user.id}`); + + expect(res.status).equals(422); + + const foundFirstNameParam = res.body.errors.find((error) => error.param === 'last_name'); + expect(!!foundFirstNameParam).equals(true); + }); + + it('Should `email` be required.', async () => { + const user = await create('user'); + const res = await request().post(`/api/users/${user.id}`); + + expect(res.status).equals(422); + + const foundEmailParam = res.body.errors.find((error) => error.param === 'email'); + expect(!!foundEmailParam).equals(true); + }); + + it('Should be `email` be valid format.', async () => { + const user = await create('user'); + const res = await request().post(`/api/users/${user.id}`).send({ + first_name: user.first_name, + last_name: user.last_name, + email: 'email', + phone_number: user.phone_number, + status: 1, + }); + + expect(res.status).equals(422); + + const foundEmailParam = res.body.errors.find((error) => error.param === 'email'); + expect(!!foundEmailParam).equals(true); + }); + + it('Should `phone_number` be valid format.', async () => { + const user = create('user'); + const res = await request().post(`/api/users/${user.id}`).send({ + first_name: user.first_name, + last_name: user.last_name, + email: user.email, + phone_number: 'phone_number', + status: 1, + }); + + expect(res.status).equals(422); + + const phoneNumberParam = res.body.errors.find((error) => error.param === 'phone_number'); + expect(!!phoneNumberParam).equals(true); + }); + + it('Should `password` be required.', async () => { + const user = create('user'); + const res = await request().post(`/api/users/${user.id}`).send(); + + expect(res.status).equals(422); + + const passwordParam = res.body.errors.find((error) => error.param === 'password'); + expect(!!passwordParam).equals(true); + }); + + it('Should password be equals confirm_password.', async () => { + const user = create('user'); + const res = await request().post(`/api/users/${user.id}`).send({ + password: '123123', + }); + + expect(res.status).equals(422); + + const passwordParam = res.body.errors.find((error) => error.param === 'password'); + expect(!!passwordParam).equals(true); + }); + + it('Should `status` be boolean', async () => { + const user = create('user'); + const res = await request().post(`/api/users/${user.id}`).send({ + status: 'not_boolean', + }); + + expect(res.status).equals(422); + + const statusParam = res.body.errors.find((error) => error.param === 'status'); + expect(!!statusParam).equals(true); + }); + }); + + describe('GET: `/users/:id`', () => { + it('Should not success if the user was not authorized.', () => { }); - it('Should `status` be boolean', () => { + it('Should response not found if the user was not exist.', async () => { + const res = await request().get('/api/users/10').send(); + expect(res.status).equals(404); + }); + + it('Should response success if the user was exist.', async () => { + const user = await create('user'); + const res = await request().get(`/api/users/${user.id}`).send(); + + expect(res.status).equals(200); }); }); @@ -23,20 +280,26 @@ describe('routes: `/routes`', () => { }); - it('Should not success if the user was pre-defined user.', () => { + it('Should response not found if the user was not exist.', async () => { + const res = await request().delete('/api/users/10').send(); + expect(res.status).equals(404); }); - it('Should response not found if the user was not exist.', () => { + it('Should response success if the user was exist.', async () => { + const user = await create('user'); + const res = await request().delete(`/api/users/${user.id}`).send(); + expect(res.status).equals(200); }); - it('Should response success if the user was exist.', () => { + it('Should delete the give user from the storage.', async () => { + const user = await create('user'); + await request().delete(`/api/users/${user.id}`).send(); - }); - - it('Should delete the give user after success response.', () => { + const storedUsers = await knex('users').where('id', user.id); + expect(storedUsers).to.have.lengthOf(0); }); }); });