diff --git a/.github/workflows/test.yml-template b/.github/workflows/test.yml-template new file mode 100644 index 000000000..03d2b7ff2 --- /dev/null +++ b/.github/workflows/test.yml-template @@ -0,0 +1,44 @@ +name: Test + +on: + pull_request: + branches: [master] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.x] + services: + postgres: + image: postgres:latest + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: students + POSTGRES_PORT: 5432 + POSTGRES_HOST: localhost + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v2 + - name: Set up Node.js + uses: actions/setup-node@v1 + with: + node-version: '20' + - name: Install dependencies + run: npm install + - name: Run tests + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: students + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + run: npm test diff --git a/.gitignore b/.gitignore index ed48a299d..bd6a178a8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,3 @@ node_modules # MacOS .DS_Store - -# env files -*.env -.env* diff --git a/package-lock.json b/package-lock.json index cd25728ae..03a0a7224 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ }, "devDependencies": { "@mate-academy/eslint-config": "latest", - "@mate-academy/scripts": "^1.8.6", + "@mate-academy/scripts": "^2.1.3", "axios": "^1.7.2", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", @@ -1474,10 +1474,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.6.tgz", - "integrity": "sha512-b4om/whj4G9emyi84ORE3FRZzCRwRIesr8tJHXa8EvJdOaAPDpzcJ8A0sFfMsWH9NUOVmOwkBtOXDu5eZZ00Ig==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/package.json b/package.json index bdf079250..e7103013c 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "devDependencies": { "@mate-academy/eslint-config": "latest", - "@mate-academy/scripts": "^1.8.6", + "@mate-academy/scripts": "^2.1.3", "axios": "^1.7.2", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", diff --git a/src/controllers/categories.controller.js b/src/controllers/categories.controller.js new file mode 100644 index 000000000..0ecb56d70 --- /dev/null +++ b/src/controllers/categories.controller.js @@ -0,0 +1,81 @@ +const categoriesService = require('../services/categories.service'); + +const get = () => async (req, res) => { + res.json(await categoriesService.getAll()); +}; + +const getOne = () => async (req, res) => { + const id = Number(req.params.id); + + if (Number.isNaN(id)) { + return res.status(400).send('Bad Request'); + } + + const reqId = await categoriesService.getById(id); + + if (!reqId) { + return res.status(404).send('Not Found'); + } + + res.json(reqId); +}; + +const create = () => async (req, res) => { + const { name } = req.body; + + if (!name) { + return res.status(400).send('Bad Request'); + } + + const user = await categoriesService.create(name); + + return res.status(201).json(user); +}; + +const remove = () => async (req, res) => { + const id = Number(req.params.id); + + if (Number.isNaN(id)) { + return res.status(400).send('Bad Request'); + } + + const deleted = await categoriesService.remove(id); + + if (!deleted) { + return res.status(404).send('Not Found'); + } + + res.status(204).send(); +}; + +const update = () => async (req, res) => { + const id = Number(req.params.id); + + if (Number.isNaN(id)) { + return res.status(400).send('Bad Request'); + } + + const { name } = req.body; + + if (typeof name !== 'string' || name.trim() === '') { + return res.status(400).send('Bad Request'); + } + + const userIdToUpdate = await categoriesService.getById(id); + + if (!userIdToUpdate) { + return res.status(404).send('Not Found'); + } + + const updatedUser = await categoriesService.update({ id, name }); + + res.status(200).json(updatedUser); +}; + +module.exports = { + get, + getOne, + create, + remove, + update, +}; diff --git a/src/controllers/expenses.controller.js b/src/controllers/expenses.controller.js new file mode 100644 index 000000000..07bef807a --- /dev/null +++ b/src/controllers/expenses.controller.js @@ -0,0 +1,182 @@ +const { Expense } = require('../models/Expense.model'); +const { User } = require('../models/User.model'); + +const expensesService = require('../services/expenses.service'); + +const get = () => async (req, res) => { + res.json(await expensesService.getAll(req.query)); +}; + +const getOne = () => async (req, res) => { + const id = Number(req.params.id); + + if (Number.isNaN(id)) { + return res.status(400).send('Bad Request'); + } + + const reqId = await expensesService.getById(id); + + if (!reqId) { + return res.status(404).send('Not Found'); + } + + res.json(reqId); +}; + +const create = () => async (req, res) => { + const { userId, spentAt, title, amount, category, note } = req.body; + + const parsedUserId = Number(userId); + const parsedAmount = Number(amount); + + if (Number.isNaN(parsedUserId)) { + return res.status(400).send('Bad Request'); + } + + if (Number.isNaN(parsedAmount)) { + return res.status(400).send('Bad Request'); + } + + if (typeof title !== 'string' || title.trim() === '') { + return res.status(400).send('Bad Request'); + } + + const userExists = await User.findByPk(parsedUserId); + + if (!userExists) { + return res.status(400).send('Bad Request'); + } + + let spentAtValue; + + if (spentAt !== undefined) { + const date = new Date(spentAt); + + if (Number.isNaN(date.getTime())) { + return res.status(400).send('Bad Request'); + } + + spentAtValue = date.toISOString(); + } else { + spentAtValue = new Date().toISOString(); + } + + const expense = await Expense.create({ + userId: parsedUserId, + spentAt: spentAtValue, + title, + amount: parsedAmount, + category, + note, + }); + + return res.status(201).json(expense); +}; + +const update = () => async (req, res) => { + const id = Number(req.params.id); + + if (Number.isNaN(id)) { + return res.status(400).send('Bad Request'); + } + + const { userId, title, spentAt, amount, category, note } = req.body; + + const updates = {}; + + if (userId !== undefined) { + const parsedUserId = Number(userId); + + if (Number.isNaN(parsedUserId)) { + return res.status(400).send('Bad Request'); + } + + const userExists = await User.findByPk(parsedUserId); + + if (!userExists) { + return res.status(400).send('Bad Request'); + } + + updates.userId = parsedUserId; + } + + if (amount !== undefined) { + const parsedAmount = Number(amount); + + if (Number.isNaN(parsedAmount)) { + return res.status(400).send('Bad Request'); + } + + updates.amount = amount; + } + + if (title !== undefined) { + if (typeof title !== 'string' || title.trim() === '') { + return res.status(400).send('Bad Request'); + } + + updates.title = title; + } + + if (category !== undefined) { + if (typeof category !== 'string' || category.trim() === '') { + return res.status(400).send('Bad Request'); + } + + updates.category = category; + } + + if (note !== undefined) { + if (typeof note !== 'string') { + return res.status(400).send('Bad Request'); + } + + updates.note = note; + } + + if (spentAt !== undefined) { + const date = new Date(spentAt); + + if (Number.isNaN(date.getTime())) { + return res.status(400).send('Bad Request'); + } + + updates.spentAt = date.toISOString(); + } + + const expense = await expensesService.getById(id); + + if (!expense) { + return res.status(404).send('Not Found'); + } + + await Expense.update(updates, { where: { id } }); + + const updatedExpense = await expensesService.getById(id); + + return res.status(200).json(updatedExpense); +}; + +const remove = () => async (req, res) => { + const id = Number(req.params.id); + + if (Number.isNaN(id)) { + return res.status(400).send('Bad Request'); + } + + const deleted = await expensesService.remove(id); + + if (!deleted) { + return res.status(404).send('Not Found'); + } + + res.status(204).send(); +}; + +module.exports = { + get, + getOne, + create, + remove, + update, +}; diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js new file mode 100644 index 000000000..bbaec21a3 --- /dev/null +++ b/src/controllers/users.controller.js @@ -0,0 +1,81 @@ +const usersService = require('../services/users.service'); + +const get = () => async (req, res) => { + res.json(await usersService.getAll()); +}; + +const getOne = () => async (req, res) => { + const id = Number(req.params.id); + + if (Number.isNaN(id)) { + return res.status(400).send('Bad Request'); + } + + const reqId = await usersService.getById(id); + + if (!reqId) { + return res.status(404).send('Not Found'); + } + + res.json(reqId); +}; + +const create = () => async (req, res) => { + const { firstName } = req.body; + + if (!firstName) { + return res.status(400).send('Bad Request'); + } + + const user = await usersService.create(firstName); + + return res.status(201).json(user); +}; + +const remove = () => async (req, res) => { + const id = Number(req.params.id); + + if (Number.isNaN(id)) { + return res.status(400).send('Bad Request'); + } + + const deleted = await usersService.remove(id); + + if (!deleted) { + return res.status(404).send('Not Found'); + } + + res.status(204).send(); +}; + +const update = () => async (req, res) => { + const id = Number(req.params.id); + + if (Number.isNaN(id)) { + return res.status(400).send('Bad Request'); + } + + const { firstName } = req.body; + + if (typeof firstName !== 'string' || firstName.trim() === '') { + return res.status(400).send('Bad Request'); + } + + const userIdToUpdate = await usersService.getById(id); + + if (!userIdToUpdate) { + return res.status(404).send('Not Found'); + } + + const updatedUser = await usersService.update({ id, firstName }); + + res.status(200).json(updatedUser); +}; + +module.exports = { + get, + getOne, + create, + remove, + update, +}; diff --git a/src/createServer.js b/src/createServer.js index 1ea5542d6..33bcc868e 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -1,8 +1,24 @@ 'use strict'; -const createServer = () => { - // your code goes here -}; +const express = require('express'); + +const userRouter = require('./routes/users.route'); +const expensesRouter = require('./routes/expenses.route'); +const categoriesRouter = require('./routes/categories.route'); + +function createServer() { + const app = express(); + + app.use(express.json()); + + app.use('/users', userRouter()); + + app.use('/expenses', expensesRouter()); + + app.use('/categories', categoriesRouter()); + + return app; +} module.exports = { createServer, diff --git a/src/models/Category.model.js b/src/models/Category.model.js new file mode 100644 index 000000000..bdcbc4bd6 --- /dev/null +++ b/src/models/Category.model.js @@ -0,0 +1,23 @@ +'use strict'; + +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../db.js'); + +const Category = sequelize.define( + 'Category', + { + name: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + { + tableName: 'categories', + createdAt: false, + updatedAt: false, + }, +); + +module.exports = { + Category, +}; diff --git a/src/models/Expense.model.js b/src/models/Expense.model.js index 567e1c3e7..08ae612f7 100644 --- a/src/models/Expense.model.js +++ b/src/models/Expense.model.js @@ -1,9 +1,43 @@ 'use strict'; +const { DataTypes } = require('sequelize'); const { sequelize } = require('../db.js'); const Expense = sequelize.define( - // your code goes here + 'Expense', + { + userId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + spentAt: { + type: DataTypes.DATE, + field: 'spent_at', + allowNull: false, + defaultValue: DataTypes.NOW, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + amount: { + type: DataTypes.INTEGER, + allowNull: false, + }, + category: { + type: DataTypes.STRING, + allowNull: true, + }, + note: { + type: DataTypes.STRING, + allowNull: true, + }, + }, + { + tableName: 'expenses', + createdAt: false, + updatedAt: false, + }, ); module.exports = { diff --git a/src/models/User.model.js b/src/models/User.model.js index 61861c9e4..d3769d820 100644 --- a/src/models/User.model.js +++ b/src/models/User.model.js @@ -1,9 +1,21 @@ 'use strict'; +const { DataTypes } = require('sequelize'); const { sequelize } = require('../db.js'); const User = sequelize.define( - // your code goes here + 'User', + { + firstName: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + { + tableName: 'users', + createdAt: false, + updatedAt: false, + }, ); module.exports = { diff --git a/src/routes/categories.route.js b/src/routes/categories.route.js new file mode 100644 index 000000000..2cb1a9a3e --- /dev/null +++ b/src/routes/categories.route.js @@ -0,0 +1,19 @@ +const categoriesController = require('../controllers/categories.controller'); + +const express = require('express'); + +module.exports = function () { + const router = express.Router(); + + router.get('/', categoriesController.get()); + + router.get('/:id', categoriesController.getOne()); + + router.post('/', categoriesController.create()); + + router.patch('/:id', categoriesController.update()); + + router.delete('/:id', categoriesController.remove()); + + return router; +}; diff --git a/src/routes/expenses.route.js b/src/routes/expenses.route.js new file mode 100644 index 000000000..aaa1b5ac9 --- /dev/null +++ b/src/routes/expenses.route.js @@ -0,0 +1,19 @@ +const expensesController = require('../controllers/expenses.controller'); + +const express = require('express'); + +module.exports = function (users, expenses) { + const router = express.Router(); + + router.get('/', expensesController.get(expenses)); + + router.get('/:id', expensesController.getOne(expenses)); + + router.post('/', expensesController.create(users, expenses)); + + router.patch('/:id', expensesController.update(users, expenses)); + + router.delete('/:id', expensesController.remove(expenses)); + + return router; +}; diff --git a/src/routes/users.route.js b/src/routes/users.route.js new file mode 100644 index 000000000..6166706a2 --- /dev/null +++ b/src/routes/users.route.js @@ -0,0 +1,19 @@ +const usersController = require('../controllers/users.controller'); + +const express = require('express'); + +module.exports = function (users) { + const router = express.Router(); + + router.get('/', usersController.get(users)); + + router.get('/:id', usersController.getOne(users)); + + router.post('/', usersController.create(users)); + + router.patch('/:id', usersController.update(users)); + + router.delete('/:id', usersController.remove(users)); + + return router; +}; diff --git a/src/services/categories.service.js b/src/services/categories.service.js new file mode 100644 index 000000000..77f98adfa --- /dev/null +++ b/src/services/categories.service.js @@ -0,0 +1,37 @@ +const { Category } = require('../models/Category.model'); + +const getAll = async () => { + const categories = await Category.findAll(); + + return categories; +}; + +const getById = async (id) => { + return Category.findByPk(id); +}; + +const create = (name) => { + return Category.create({ name }); +}; + +const update = async ({ id, name }) => { + await Category.update({ name }, { where: { id } }); + + return getById(id); +}; + +const remove = async (id) => { + return Category.destroy({ + where: { + id: id, + }, + }); +}; + +module.exports = { + getAll, + getById, + create, + update, + remove, +}; diff --git a/src/services/expenses.service.js b/src/services/expenses.service.js new file mode 100644 index 000000000..90994ee59 --- /dev/null +++ b/src/services/expenses.service.js @@ -0,0 +1,48 @@ +const { Op } = require('sequelize'); +const { Expense } = require('../models/Expense.model'); + +const getAll = async (filters) => { + const { userId, categories, from, to } = filters; + + const options = { where: {}, order: [] }; + + if (userId) { + options.where.userId = userId; + } + + if (categories) { + options.where.category = categories; + } + + if (from || to) { + options.where.spentAt = {}; + } + + if (from) { + options.where.spentAt[Op.gte] = new Date(from); + } + + if (to) { + options.where.spentAt[Op.lte] = new Date(to); + } + + const expenses = await Expense.findAll(options); + + return expenses; +}; + +const getById = async (id) => { + return Expense.findByPk(id); +}; + +const remove = async (id) => { + return Expense.destroy({ + where: { id: id }, + }); +}; + +module.exports = { + getAll, + getById, + remove, +}; diff --git a/src/services/users.service.js b/src/services/users.service.js new file mode 100644 index 000000000..234016cc2 --- /dev/null +++ b/src/services/users.service.js @@ -0,0 +1,37 @@ +const { User } = require('../models/User.model'); + +const getAll = async () => { + const users = await User.findAll(); + + return users; +}; + +const getById = async (id) => { + return User.findByPk(id); +}; + +const create = (firstName) => { + return User.create({ firstName }); +}; + +const update = async ({ id, firstName }) => { + await User.update({ firstName }, { where: { id } }); + + return getById(id); +}; + +const remove = async (id) => { + return User.destroy({ + where: { + id: id, + }, + }); +}; + +module.exports = { + getAll, + getById, + create, + update, + remove, +}; diff --git a/src/setup.js b/src/setup.js new file mode 100644 index 000000000..f2b7415e2 --- /dev/null +++ b/src/setup.js @@ -0,0 +1,7 @@ +const { + models: { User }, +} = require('./models/models'); +const { Category } = require('./models/Category.model'); + +User.sync({ force: true }); +Category.sync({ force: true }); diff --git a/tests/expense.test.js b/tests/expense.test.js index 2dde0c89f..54802c915 100644 --- a/tests/expense.test.js +++ b/tests/expense.test.js @@ -48,8 +48,8 @@ describe('Expense', () => { }); [user, secondUser] = await Promise.all([ - User.create({ name: 'John Doe' }), - User.create({ name: 'Jane Doe' }), + User.create({ firstName: 'John Doe' }), + User.create({ firstName: 'Jane Doe' }), ]); }); diff --git a/tests/user.test.js b/tests/user.test.js index c03ae3541..d0c4a6ed0 100644 --- a/tests/user.test.js +++ b/tests/user.test.js @@ -58,9 +58,9 @@ describe('User', () => { describe('createUser', () => { it('should create a new user', async () => { - const name = 'John Doe'; + const firstName = 'John Doe'; - const res = await api.post('users', { name }); + const res = await api.post('users', { firstName }); expect(res.status).toBe(201); @@ -71,12 +71,12 @@ describe('User', () => { expect(res.data).toEqual( expect.objectContaining({ id: expect.any(Number), - name, + firstName, }), ); }); - it('should return 400 if name is not provided', async () => { + it('should return 400 if firstName is not provided', async () => { expect.assertions(1); await api @@ -101,10 +101,10 @@ describe('User', () => { it('should return all users', async () => { const users = [ { - name: 'John Doe', + firstName: 'John Doe', }, { - name: 'Jane Doe', + firstName: 'Jane Doe', }, ]; @@ -139,7 +139,7 @@ describe('User', () => { it('should return user', async () => { const { data: createdUser } = await api.post('users', { - name: 'John Doe', + firstName: 'John Doe', }); const response = await api.get(`users/${createdUser.id}`); @@ -159,17 +159,17 @@ describe('User', () => { expect.assertions(1); await api - .put('users/1', { name: 'John Doe' }) + .put('users/1', { firstName: 'John Doe' }) .catch((err) => expect(err.response.status).toBe(404)); }); it('should update user', async () => { const { data: createdUser } = await api.post('/users', { - name: 'John Doe', + firstName: 'John Doe', }); const response = await api.patch(`users/${createdUser.id}`, { - name: 'Jane Doe', + firstName: 'Jane Doe', }); expect(response.status).toBe(200); @@ -180,7 +180,7 @@ describe('User', () => { expect(response.data).toEqual({ ...createdUser, - name: 'Jane Doe', + firstName: 'Jane Doe', }); }); }); @@ -195,7 +195,7 @@ describe('User', () => { }); it('should delete user', async () => { - const createdUser = await api.post('users', { name: 'John Doe' }); + const createdUser = await api.post('users', { firstName: 'John Doe' }); const res = await api.delete(`users/${createdUser.data.id}`);