diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4d84325..a2c9d74 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -3,11 +3,11 @@ name: "CI pipeline" on: push: branches: - - main + - 'main' pull_request: branches: - - main + - '**' jobs: build: @@ -15,34 +15,23 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 - name: Set up Node.js - uses: actions/setup-node@v5 - with: node-version: "18" - cache: "npm" - name: Install dependencies - run: npm ci --audit false - name: Lint code - - run: npm run lint || echo "Lint check failed, but continuing..." + run: npm run lint - name: Build application - run: npm run build - - name: Run security audit - - run: npm audit || echo "Security vulnerabilities found, but continuing..." - - name: Cache node_modules uses: actions/cache@v4 @@ -58,10 +47,66 @@ jobs: ${{ runner.os }}-modules- - name: Upload build artifacts - uses: actions/upload-artifact@v4 + with: + name: build + path: dist/ + test: + runs-on: ubuntu-latest + needs: build + env: + DB_HOST: localhost + DB_PORT: 5432 + DB_USERNAME: postgres + DB_PASSWORD: postgres + DB_DATABASE: payment_records + PORT: 3000 + FRONTEND_URL: http://localhost:5173 + SMTP_HOST: smtp.example.com + EMAIL_USER: email@gmail.com + EMAIL_PASS: your-email-password + ACCESS_SECRET: secret1 + ACCESS_EXPIRE: 15m + REFRESH_SECRET: secret2 + REFRESH_EXPIRE: 7d + RESET_SECRET: secret3 + RESET_EXPIRE: 15m + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Set up Node.js + uses: actions/setup-node@v5 + with: + node-version: "18" + cache: "npm" + + - name: Install dependencies + run: npm ci --audit false + + - name: Download build artifacts + uses: actions/download-artifact@v4 with: name: build - path: dist/ + - name: Up docker services + run: docker compose up -d --build + + - name: Wait for Database + run: | + until docker exec payment_records_db pg_isready; do + echo "Waiting for database..." + sleep 2 + done + + - name: 'Run unit tests' + run: npm run test:unit + + - name: 'Run integration tests' + run: npm run test:integration + + - name: Stop containers + if: always() + run: docker compose down diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..9297a2f --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,11 @@ +{ + "servers": { + "atlassian/atlassian-mcp-server": { + "type": "http", + "url": "https://mcp.atlassian.com/v1/sse", + "gallery": "https://api.mcp.github.com", + "version": "1.0.0" + } + }, + "inputs": [] +} \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index c5744d5..4356279 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,12 +1,16 @@ services: postgres: image: postgres:latest - container_name: payment_records + container_name: payment_records_db ports: - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 environment: - POSTGRES_HOST: localhost - POSTGRES_PORT: 5432 POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres - POSTGRES_DB: payment_records + volumes: + - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql \ No newline at end of file diff --git a/init-db.sql b/init-db.sql new file mode 100644 index 0000000..45b22ab --- /dev/null +++ b/init-db.sql @@ -0,0 +1,2 @@ +CREATE DATABASE payment_records; +CREATE DATABASE payment_records_test; \ No newline at end of file diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..e6e0324 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,17 @@ +const { pathsToModuleNameMapper } = require("ts-jest"); +const { compilerOptions } = require("./tsconfig.json"); + +/** @type {import("jest").Config} **/ +module.exports = { + testEnvironment: "node", + preset: "ts-jest", + moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: "/src" }), + + transformIgnorePatterns: [ + "node_modules/(?!@faker-js/faker)" + ], + + transform: { + "^.+\\.(t|j)sx?$": ["ts-jest", { useESM: true }], + }, +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e480609..b58f1b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@asteasolutions/zod-to-openapi": "^8.4.0", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", + "@types/faker": "^6.6.8", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.3.0", "@types/nodemailer": "^7.0.1", @@ -35,7 +36,7 @@ }, "devDependencies": { "@eslint/js": "^9.34.0", - "@faker-js/faker": "^10.0.0", + "@faker-js/faker": "^10.2.0", "@types/jest": "^30.0.0", "eslint": "^9.34.0", "globals": "^16.3.0", @@ -1950,9 +1951,9 @@ } }, "node_modules/@faker-js/faker": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.1.0.tgz", - "integrity": "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.2.0.tgz", + "integrity": "sha512-rTXwAsIxpCqzUnZvrxVh3L0QA0NzToqWBLAhV+zDV3MIIwiQhAZHMdPCIaj5n/yADu/tyk12wIPgL6YHGXJP+g==", "dev": true, "funding": [ { @@ -3438,6 +3439,15 @@ "@types/send": "*" } }, + "node_modules/@types/faker": { + "version": "6.6.8", + "resolved": "https://registry.npmjs.org/@types/faker/-/faker-6.6.8.tgz", + "integrity": "sha512-9moiFKhmFMlI/7v5jVsPS8bbtIN1Rfo03hTPf1HPgWnZCksDup2xDTyBVC6xzjmUL/i6N6ecOJQIj5LrVJbYcg==", + "license": "MIT", + "dependencies": { + "faker": "*" + } + }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -5655,6 +5665,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/faker": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/faker/-/faker-6.6.6.tgz", + "integrity": "sha512-9tCqYEDHI5RYFQigXFwF1hnCwcWCOJl/hmll0lr5D2Ljjb0o4wphb69wikeJDz5qCEzXCoPvG6ss5SDP6IfOdg==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/package.json b/package.json index f24a581..99272a3 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@asteasolutions/zod-to-openapi": "^8.4.0", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", + "@types/faker": "^6.6.8", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.3.0", "@types/nodemailer": "^7.0.1", @@ -41,7 +42,7 @@ }, "devDependencies": { "@eslint/js": "^9.34.0", - "@faker-js/faker": "^10.0.0", + "@faker-js/faker": "^10.2.0", "@types/jest": "^30.0.0", "eslint": "^9.34.0", "globals": "^16.3.0", diff --git a/src/app.ts b/src/app.ts index 03d0b87..6290343 100644 --- a/src/app.ts +++ b/src/app.ts @@ -46,12 +46,10 @@ app.get("/health", (_req, res) => { AppDataSource.initialize() .then(() => { - console.log("Data Source has been initialized!"); app.listen(port, async () => { - console.log(`Server is running on port ${port}`); await runSeeds(); }); }) .catch((err) => { - console.error("Error during Data Source initialization:", err); + console.error("Error during Data Source initialization", err); }); diff --git a/src/lib/enums.ts b/src/lib/enums.ts index 19a58ea..058118b 100644 --- a/src/lib/enums.ts +++ b/src/lib/enums.ts @@ -46,8 +46,8 @@ export const ErrorEnum = { NOT_FOUND: { message: "Not Found", status: 404 }, CONFLICT: { message: "Conflict", status: 409 }, INTERNAL_SERVER_ERROR: { message: "Internal Server Error", status: 500 }, - USER_ALREADY_EXISTS: { message: "User Already Exists", status: 409 }, - INVALID_CREDENTIALS: { message: "Invalid Credentials", status: 401 }, + USER_ALREADY_EXISTS: { message: "User already exists", status: 409 }, + INVALID_CREDENTIALS: { message: "Invalid credentials", status: 401 }, VALIDATION_ERROR: { message: "Validation Error", status: 400 }, INSUFFICIENT_FUNDS: { message: "Insufficient Funds", status: 400 }, ACCOUNT_BLOCKED: { message: "Account Blocked", status: 403 }, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index dfe2568..0f107a8 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,9 @@ import bcrypt from "bcryptjs"; import { IncomingHttpHeaders } from "http"; +import { faker } from "@faker-js/faker"; // search for an alternative with ESM support +import { CreateUserSchema, CreateAccountSchema, CreateBankSchema } from "./schema"; +import { Account as accountEnum } from "./enums"; +import z from 'zod'; export const hashPassword = async (password: string): Promise => { const salt = await bcrypt.genSalt(10); @@ -33,3 +37,31 @@ export const extractTokenFromHeader = ( return config; }; + +export function generateRandomUser(): z.infer { + return { + name: faker.person.fullName(), + age: faker.number.int({ min: 18, max: 80 }), + email: faker.internet.email(), + password: faker.internet.password() + }; +} + +export function generateRandomBank(): z.infer { + return { + name: faker.company.name() + " Bank", + code: faker.number.int({ min: 1000, max: 9999 }).toString() + }; +} + +export function generateRandomAccount(bankId: string): z.infer { + return { + accountNumber: faker.finance.accountNumber({ length: 10 }), + agency: faker.number.int({ min: 1000, max: 9999 }).toString(), + accountType: faker.helpers.arrayElement([accountEnum.CHECKING, accountEnum.SAVINGS]), + name: faker.finance.accountName(), + bankId + }; +} + +export const getRandomFromArray = (length: number): number => Math.floor(Math.random() * length); diff --git a/src/modules/Account/account.service.ts b/src/modules/Account/account.service.ts index 32b228c..2470b26 100644 --- a/src/modules/Account/account.service.ts +++ b/src/modules/Account/account.service.ts @@ -117,6 +117,9 @@ export default class AccountService { accountNumber: string, userId: string ): Promise { + if (!accountNumber || !userId) { + throw new Error(ErrorEnum.BAD_REQUEST.message); + } return this.accountRepository.findOne({ where: { accountNumber, user: { id: userId } }, relations: ["bank"], @@ -150,19 +153,20 @@ export default class AccountService { accountId: string, userId: string ): Promise { - const account = await this.accountRepository.findOne({ + const account = await this.accountRepository.find({ where: { id: accountId, user: { id: userId } }, select: ["balance"], }); - if (!account) { + + if (!account || account.length === 0) { throw new Error(ErrorEnum.NOT_FOUND.message); } return { success: true, message: `Account balance retrieved successfully`, - data: { balance: account.balance }, + data: { balance: account[0].balance }, }; } } diff --git a/src/modules/Auth/auth.service.ts b/src/modules/Auth/auth.service.ts index 724e360..773622e 100644 --- a/src/modules/Auth/auth.service.ts +++ b/src/modules/Auth/auth.service.ts @@ -1,28 +1,28 @@ import { User } from "@modules/User/entity/user.entity"; import { CreateUserDto } from "./dto/create-user.dto"; import { LoginUserDto } from "./dto/login.dto"; -import {hashPassword, comparePasswords} from "@lib/utils" +import { hashPassword, comparePasswords } from "@lib/utils" import { ErrorEnum } from "@lib/enums"; import { env } from "@shared/env"; import jwt from "jsonwebtoken" import { Email } from "@core/abstractions/email"; import { MailOptions } from "@lib/types"; -import {resetPasswordTemplate,welcomeTemplate} from "./email/template"; +import { resetPasswordTemplate, welcomeTemplate } from "./email/template"; import { Repository } from "typeorm"; -export class AuthService{ +export class AuthService { constructor( private readonly authRepository: Repository, private readonly emailService: Email - ){} + ) { } async register(createUserDTO: CreateUserDto): Promise { const isAlreadyRegistered = await this.authRepository.findOne({ where: { email: createUserDTO.email } }); if (isAlreadyRegistered) { throw new Error(ErrorEnum.USER_ALREADY_EXISTS.message); } - createUserDTO.password = await hashPassword(createUserDTO.password); - const user = await this.authRepository.save(createUserDTO); + const password_digest = await hashPassword(createUserDTO.password); + const user = await this.authRepository.save({...createUserDTO, password: password_digest }); await this.emailService.send({ to: user.email, subject: "Welcome to Payment Records", @@ -33,30 +33,30 @@ export class AuthService{ return user; } - async login(LoginUserDto: LoginUserDto){ + async login(LoginUserDto: LoginUserDto) { const user = await this.authRepository.findOne({ where: { email: LoginUserDto.email } }); - if(!user){ + if (!user) { throw new Error(ErrorEnum.INVALID_CREDENTIALS.message); } const isValidPassword = await comparePasswords(LoginUserDto.password, user.password); - if(!isValidPassword){ + if (!isValidPassword) { throw new Error(ErrorEnum.INVALID_CREDENTIALS.message); }; - const access_token = jwt.sign( + const access_token = jwt.sign( { - email: user.email, - id: user.id - }, env.ACCESS_SECRET, - {expiresIn: env.ACCESS_EXPIRE as number}) + email: user.email, + id: user.id + }, env.ACCESS_SECRET, + { expiresIn: env.ACCESS_EXPIRE as number }) const refresh_token = jwt.sign({ id: user.id }, env.REFRESH_SECRET, - { - expiresIn: env.REFRESH_EXPIRE as number - }) + { + expiresIn: env.REFRESH_EXPIRE as number + }) return { ...user, @@ -65,33 +65,33 @@ export class AuthService{ } } - async refreshToken( refreshToken: string) { + async refreshToken(refreshToken: string) { - const decoded = jwt.verify(refreshToken, env.REFRESH_SECRET); + const decoded = jwt.verify(refreshToken, env.REFRESH_SECRET); - if(!decoded) throw new Error(ErrorEnum.UNAUTHORIZED.message); + if (!decoded) throw new Error(ErrorEnum.UNAUTHORIZED.message); - const newAccessToken = jwt.sign( - { - id: (decoded as {id: string}).id - }, - env.ACCESS_SECRET, - { expiresIn: env.ACCESS_EXPIRE as number } - ); + const newAccessToken = jwt.sign( + { + id: (decoded as { id: string }).id + }, + env.ACCESS_SECRET, + { expiresIn: env.ACCESS_EXPIRE as number } + ); - return { - access_token: newAccessToken - }; + return { + access_token: newAccessToken + }; } - logout(){ + logout() { return; } - async forgotPassword(email: string){ - const user = await this.authRepository.findOne({where: {email}}); + async forgotPassword(email: string) { + const user = await this.authRepository.findOne({ where: { email } }); - if(!user) throw new Error(ErrorEnum.NOT_FOUND.message); + if (!user) throw new Error(ErrorEnum.NOT_FOUND.message); const resetToken = jwt.sign( { id: user.id }, @@ -100,28 +100,28 @@ export class AuthService{ ); try { - await this.emailService.send({ - to: user.email, - subject: "Password Reset", - from: process.env.EMAIL_USER, - html: resetPasswordTemplate(resetToken, user.name) - }) - - return {message: "Password reset email sent"} + await this.emailService.send({ + to: user.email, + subject: "Password Reset", + from: process.env.EMAIL_USER, + html: resetPasswordTemplate(resetToken, user.name), + messageId: resetToken + }) + return { message: "Password reset email sent" } } catch { throw new Error(ErrorEnum.INTERNAL_SERVER_ERROR.message); } } - async resetPassword(token: string, newPassword: string){ + async resetPassword(token: string, newPassword: string) { const decoded = jwt.verify(token, env.RESET_SECRET); - if(!decoded) throw new Error(ErrorEnum.UNAUTHORIZED.message); + if (!decoded) throw new Error(ErrorEnum.UNAUTHORIZED.message); - const userId = (decoded as {id: string}).id; + const userId = (decoded as { id: string }).id; - const user = await this.authRepository.findOne({where: {id: userId}}); + const user = await this.authRepository.findOne({ where: { id: userId } }); - if(!user) throw new Error(ErrorEnum.NOT_FOUND.message); + if (!user) throw new Error(ErrorEnum.NOT_FOUND.message); const hashedPassword = await hashPassword(newPassword); await this.authRepository.update(userId, { password: hashedPassword }); diff --git a/src/tests/integration/account.service.spec.ts b/src/tests/integration/account.service.spec.ts new file mode 100644 index 0000000..a60ff28 --- /dev/null +++ b/src/tests/integration/account.service.spec.ts @@ -0,0 +1,294 @@ +import AccountService from '@modules/Account/account.service'; +import { Account } from '@modules/Account/entity/account.entity'; +import { User } from '@modules/User/entity/user.entity'; +import { Bank } from '@modules/Bank/entity/bank.entity'; +import { Repository } from 'typeorm'; +import { TestAppDataSource as dataSource } from './db'; +import { BankService } from '@modules/Bank/bank.service'; +import { generateRandomUser, generateRandomBank, generateRandomAccount, getRandomFromArray } from '../../lib/utils'; +import { AuthService } from '@modules/Auth/auth.service'; +import { MockEmailService } from './auth.service.spec'; +import { Email } from '@core/abstractions/email'; +import { MailOptions } from '@lib/types'; +import { CreateUserDto } from '@modules/Auth/dto/create-user.dto'; +import { simpleFaker } from '@faker-js/faker'; + +describe('Test account serice', () => { + let accountService: AccountService; + let bankService: BankService; + let authService: AuthService; + let emailService: Email + + let userRepository: Repository; + let bankRepository: Repository; + let accountRepository: Repository; + + const users: Array> = []; + const banks: Array = []; + const accounts: Array> = []; + + beforeAll(async () => { + await dataSource.initialize(); + emailService = new MockEmailService(); + + userRepository = dataSource.getRepository(User); + bankRepository = dataSource.getRepository(Bank); + accountRepository = dataSource.getRepository(Account); + + authService = new AuthService( + userRepository, + emailService + ) + + accountService = new AccountService( + accountRepository, + userRepository, + bankRepository + ) + + bankService = new BankService( + bankRepository + ) + + let i = 4; + + while (i--) { + const user = generateRandomUser(); + users.push(user); + + const bank = generateRandomBank(); + banks.push(bank as Bank); + } + const processUsers = users.map(async (user) => await authService.register(user as CreateUserDto)) + users.map(async (_, index) => { + users[index] = await processUsers[index]; + }) + await Promise.all(processUsers); + + const processBanks = banks.map(async (bank) => await bankService.registerBank(bank)); + banks.map(async (_, index) => { + banks[index] = await processBanks[index]; + }) + await Promise.all(processBanks); + }) + + afterAll(async () => { + await dataSource.destroy(); + }) + + describe('Create account', () => { + it('should create an account successfully', async () => { + const user = users[getRandomFromArray(users.length)]; + const bank = banks[getRandomFromArray(banks.length)]; + + const account = generateRandomAccount(bank.id); + + const createdAccount = await accountService.create(user.id!, account); + + expect(createdAccount).toHaveProperty('id'); + expect(createdAccount).toHaveProperty('accountNumber', account.accountNumber) + expect(createdAccount).toHaveProperty('accountType', account.accountType) + expect(createdAccount).toHaveProperty('agency', account.agency) + expect(createdAccount).toHaveProperty('name', account.name) + expect(createdAccount).toHaveProperty('balance', '0.00') + expect(createdAccount).toHaveProperty('isActive', true) + + }) + + it('If try to create account with existing account number, should throw error', async () => { + const user = users[getRandomFromArray(users.length)]; + const bank = banks[getRandomFromArray(banks.length)]; + + const account = generateRandomAccount(bank.id); + + const createdAccount = await accountService.create(user.id!, account); + accounts.push(createdAccount); + + await expect(accountService.create(user.id!, account)).rejects.toThrow('Conflict'); + }) + + it('If try to create account with non existing bank, should throw error', async () => { + const user = users[getRandomFromArray(users.length)]; + + const account = generateRandomAccount(simpleFaker.string.uuid()); + + await expect(accountService.create(user.id!, account)).rejects.toThrow('Not Found'); + }) + + it('If try to create account with non existing user, should throw error', async () => { + const bank = banks[getRandomFromArray(banks.length)]; + + const account = generateRandomAccount(bank.id); + + await expect(accountService.create(simpleFaker.string.uuid(), account)).rejects.toThrow('Not Found'); + }) + }) + + describe('Update account', () => { + + it('should update an account successfully', async () => { + const account = accounts[getRandomFromArray(accounts.length)]; + const userId = account.user?.id; + + const newAccountData = generateRandomAccount(account.bank!.id); + + const updatedAccount = await accountService.update(account.id!, userId!, { + accountNumber: newAccountData.accountNumber, + agency: newAccountData.agency, + name: newAccountData.name, + accountType: newAccountData.accountType + }); + + expect(updatedAccount).toHaveProperty('id', account.id); + expect(updatedAccount).toHaveProperty('accountNumber', newAccountData.accountNumber) + expect(updatedAccount).toHaveProperty('accountType', newAccountData.accountType) + expect(updatedAccount).toHaveProperty('agency', newAccountData.agency) + expect(updatedAccount).toHaveProperty('name', newAccountData.name) + }) + + it('If try to update non existing account, should throw error', async () => { + const account = accounts[getRandomFromArray(accounts.length)]; + const userId = account.user?.id; + + const newAccountData = generateRandomAccount(account.bank!.id); + + await expect(accountService.update(simpleFaker.string.uuid(), userId!, { + accountNumber: newAccountData.accountNumber, + agency: newAccountData.agency, + name: newAccountData.name, + accountType: newAccountData.accountType + })).rejects.toThrow('Not Found'); + }) + + it('If try to update account with existing account number, should throw error', async () => { + let account1; + let account2; + + if (accounts.length < 2) { + const user = users[getRandomFromArray(users.length)]; + const bank = banks[getRandomFromArray(banks.length)]; + + const acc1 = generateRandomAccount(bank.id); + const acc2 = generateRandomAccount(bank.id); + + account1 = await accountService.create(user.id!, acc1); + account2 = await accountService.create(user.id!, acc2); + + accounts.push(account1, account2); + } else { + account1 = accounts[0]; + account2 = accounts[1]; + } + + const userId = account2.user?.id; + + await expect(accountService.update(account2.id!, userId!, { + accountNumber: account1.accountNumber, + })).rejects.toThrow('Conflict'); + }) + }) + + describe('Retrieve accounts', () => { + it('should list all accounts from a specific user', async () => { + const account = accounts[0]; + const userId = account.user?.id; + + const userAccounts = await accountService.listUserAccounts(userId!); + + expect(Array.isArray(userAccounts)).toBe(true); + expect(userAccounts.length).toBeGreaterThan(0); + expect(userAccounts[0]).toHaveProperty('bank'); + }); + + it('should get an account by ID', async () => { + const account = accounts[0]; + const userId = account.user?.id; + + const result = await accountService.getAccountById(account.id!, userId!); + + expect(result).not.toBeNull(); + expect(result?.id).toBe(account.id); + expect(result).toHaveProperty('transactions'); + }); + + it('should get an account by account number', async () => { + + const user = await authService.register(generateRandomUser() as CreateUserDto);; + + + const bankData = generateRandomBank(); + const bank = await bankService.registerBank(bankData); + + const accountData = generateRandomAccount(bank.id); + const account = await accountService.create(user.id!, accountData); + accounts.push(account); + + const userId = account.user?.id; + const result = await accountService.getAccountByAccountNumber(account.accountNumber!, userId!); + + expect(result).not.toBeNull(); + + expect(result?.accountNumber).toBe(account.accountNumber); + }); + + it('should return the correct balance of an account', async () => { + const account = accounts[0]; + const userId = account.user?.id; + + const response = await accountService.getAccountBalance(account.id!, userId!); + + expect(response.success).toBe(true); + expect(response.data).toHaveProperty('balance', account.balance); + }); + + it('should throw error when getting balance of non-existing account', async () => { + const userId = users[0].id!; + await expect(accountService.getAccountBalance(simpleFaker.string.uuid(), userId)) + .rejects.toThrow(); + }); + }); + + describe('Account Status (Alive or Dead)', () => { + it('should toggle account activation status', async () => { + const account = accounts[0]; + const userId = account.user?.id; + const initialStatus = account.isActive; + + const response = await accountService.aliveOrDeadAccount(account.id!, userId!); + + expect(response.success).toBe(true); + + const updatedAccount = await accountService.getAccountById(account.id!, userId!); + expect(updatedAccount?.isActive).toBe(!initialStatus); + }); + + it('should throw error when toggling status of non-existing account', async () => { + const userId = users[0].id!; + await expect(accountService.aliveOrDeadAccount(simpleFaker.string.uuid(), userId)) + .rejects.toThrow(); + }); + }); + + describe('Delete account', () => { + it('should delete an account successfully', async () => { + const user = users[0]; + const bank = banks[0]; + const tempAccData = generateRandomAccount(bank.id); + const tempAccount = await accountService.create(user.id!, tempAccData); + + const response = await accountService.delete(tempAccount.id!, user.id!); + + expect(response.success).toBe(true); + expect(response.message).toContain('successfully'); + + const check = await accountService.getAccountById(tempAccount.id!, user.id!); + expect(check).toBeNull(); + }); + + it('should throw error when trying to delete non-existing account', async () => { + const userId = users[0].id!; + await expect(accountService.delete(simpleFaker.string.uuid(), userId)) + .rejects.toThrow(); + }); + }); +}) \ No newline at end of file diff --git a/src/tests/integration/auth.service.spec.ts b/src/tests/integration/auth.service.spec.ts new file mode 100644 index 0000000..2b2bc71 --- /dev/null +++ b/src/tests/integration/auth.service.spec.ts @@ -0,0 +1,128 @@ +import { Email } from "@core/abstractions/email"; +import { AuthService } from "@modules/Auth/auth.service"; +import { User } from "@modules/User/entity/user.entity"; +import { TestAppDataSource as dataSource } from "./db"; +import { MailOptions } from "@lib/types"; +import { CreateUserDto } from "@modules/Auth/dto/create-user.dto"; + + +export class MockEmailService extends Email { + constructor() { + super({ sendEmail: async () => true }); + } + + send = jest.fn().mockResolvedValue(true); +} + +let authService: AuthService; +let emailService: Email; +let validUser; + +describe("Auth Service Integration Tests", () => { + beforeAll(async () => { + await dataSource.initialize(); + + dataSource.query( + ` + TRUNCATE TABLE "users" CASCADE + ` + ) + + emailService = new MockEmailService() + authService = new AuthService(dataSource.getRepository(User), emailService); + validUser = { + name: 'John Doe', + age: 30, + email: `john.doe@example.com`, + password: 'strongPassword123' + } + }) + + beforeEach(() => { + jest.clearAllMocks(); + }) + + afterAll(() => { + dataSource.destroy(); + }) + + test('Register user', async () => { + + const response = await authService.register(validUser); + + expect(response).toHaveProperty('id'); + expect(response).toHaveProperty('name', validUser.name); + expect(response).toHaveProperty('email', validUser.email); + }) + + test('Register user with existing email should fail', async () => { + await expect(authService.register(validUser)).rejects.toThrow('User already exists'); + }) + + test('Register user with missing attributes should fail', async () => { + const user = { + name: 'John Doe', + age: 30, + password: 'strongPassword123' + } + + await expect(authService.register(user as CreateUserDto)).rejects.toThrow(); + }) + + test('Valid user login should return tokens and the user info', async () => { + const response = await authService.login({ + email: validUser.email, + password: validUser.password + }) + + expect(response).toHaveProperty('access_token'); + expect(response).toHaveProperty('refresh_token'); + expect(response).toHaveProperty('email', validUser.email); + expect(response).toHaveProperty('password'); + }) + + test('Invalid user login should fail', async () => { + await expect(authService.login({ + email: `john.doe@example.com`, + password: 'wrongPassword123' + })).rejects.toThrow('Invalid credentials'); + }) + + test('Reset password should send a email for the user with the html template and return a message', async () => { + // for test all flow first we call the forgotPassword service to generate the token and send the email + const response = await authService.forgotPassword(validUser.email); + expect(response).toHaveProperty('message', 'Password reset email sent'); + expect(emailService.send).toHaveBeenCalledTimes(1); + expect(emailService.send).toHaveBeenCalledWith( + expect.objectContaining({ + to: validUser.email, + subject: 'Password Reset', + html: expect.any(String), + messageId: expect.any(String), + from: expect.any(String) + }) + ) + + const emailOptions = (emailService.send as jest.Mock).mock.calls[0][0]; + const resetToken = emailOptions.messageId; + const html = emailOptions.html; + + + // now we can test the reset password with the token generated + const newPassword = 'newStrongPassword123'; + await expect(authService.resetPassword(resetToken, newPassword)).resolves.toEqual({ message: 'Password updated successfully' }); + + // now we can try to login with the new password + const loginResponse = await authService.login({ + email: validUser.email, + password: newPassword + }) + + expect(html).toContain(resetToken); + expect(loginResponse).toHaveProperty('access_token'); + expect(loginResponse).toHaveProperty('refresh_token'); + expect(loginResponse).toHaveProperty('email', validUser.email); + expect(loginResponse).toHaveProperty('password'); + + }) +}) \ No newline at end of file diff --git a/src/tests/integration/bank.service.spec.ts b/src/tests/integration/bank.service.spec.ts new file mode 100644 index 0000000..af53411 --- /dev/null +++ b/src/tests/integration/bank.service.spec.ts @@ -0,0 +1,263 @@ +import { BankService } from '@modules/Bank/bank.service'; +import { Bank } from '@modules/Bank/entity/bank.entity'; +import { Repository } from 'typeorm'; +import { ErrorEnum } from '@lib/enums'; +import { TestAppDataSource as dataSource } from './db'; + +describe('BankService', () => { + let bankService: BankService; + let bankRepository: Repository; + + beforeAll(async () => { + await dataSource.initialize(); + bankRepository = dataSource.getRepository(Bank); + bankService = new BankService(bankRepository); + }); + + afterAll(async () => { + await dataSource.destroy(); + }); + + beforeEach(async () => { + await dataSource.query( + ` + TRUNCATE TABLE "banks" CASCADE + ` + ) + }); + + describe('getBankDetailsById', () => { + it('should return bank details when bank exists', async () => { + const bank = bankRepository.create({ + name: 'Test Bank', + code: '001', + }); + const savedBank = await bankRepository.save(bank); + + const result = await bankService.getBankDetailsById(savedBank.id); + + expect(result).toBeDefined(); + expect(result?.id).toBe(savedBank.id); + expect(result?.name).toBe('Test Bank'); + expect(result?.code).toBe('001'); + expect(result).toHaveProperty('accounts'); + }); + + it('should return null when bank does not exist', async () => { + const mockUuid = '5428d368-ee42-4c86-a390-69623fb67770'; + const result = await bankService.getBankDetailsById(mockUuid); + + expect(result).toBeNull(); + }); + }); + + describe('getBankDetailsByName', () => { + it('should return bank details when bank exists', async () => { + const bank = bankRepository.create({ + name: 'Test Bank', + code: '001', + }); + await bankRepository.save(bank); + + const result = await bankService.getBankDetailsByName('Test Bank'); + + expect(result).toBeDefined(); + expect(result?.name).toBe('Test Bank'); + expect(result?.code).toBe('001'); + expect(result).toHaveProperty('accounts'); + }); + + it('should return null when bank does not exist', async () => { + const result = await bankService.getBankDetailsByName('Non-existent Bank'); + + expect(result).toBeNull(); + }); + }); + + describe('registerBank', () => { + it('should successfully register a new bank', async () => { + const bankData = { name: 'New Bank', code: '002' }; + + const result = await bankService.registerBank(bankData); + + expect(result).toBeDefined(); + expect(result.id).toBeDefined(); + expect(result.name).toBe('New Bank'); + expect(result.code).toBe('002'); + + const savedBank = await bankRepository.findOneBy({ id: result.id }); + expect(savedBank).toBeDefined(); + expect(savedBank?.name).toBe('New Bank'); + }); + + it('should throw conflict error when bank name already exists', async () => { + const existingBank = bankRepository.create({ + name: 'Existing Bank', + code: '003', + }); + await bankRepository.save(existingBank); + + const bankData = { name: 'Existing Bank', code: '004' }; + + await expect(bankService.registerBank(bankData)).rejects.toThrow( + ErrorEnum.CONFLICT.message + ); + + const banks = await bankRepository.find(); + expect(banks).toHaveLength(1); + }); + + it('should throw conflict error when bank code already exists', async () => { + const existingBank = bankRepository.create({ + name: 'Existing Bank', + code: '001', + }); + await bankRepository.save(existingBank); + + const bankData = { name: 'New Bank', code: '001' }; + + await expect(bankService.registerBank(bankData)).rejects.toThrow( + ErrorEnum.CONFLICT.message + ); + + const banks = await bankRepository.find(); + expect(banks).toHaveLength(1); + }); + }); + + describe('getAllBanks', () => { + it('should return all banks sorted by name', async () => { + const bank1 = bankRepository.create({ name: 'Zebra Bank', code: '001' }); + const bank2 = bankRepository.create({ name: 'Alpha Bank', code: '002' }); + const bank3 = bankRepository.create({ name: 'Beta Bank', code: '003' }); + + await bankRepository.save([bank1, bank2, bank3]); + + const result = await bankService.getAllBanks(); + + expect(result).toHaveLength(3); + expect(result[0].name).toBe('Alpha Bank'); + expect(result[1].name).toBe('Beta Bank'); + expect(result[2].name).toBe('Zebra Bank'); + }); + + it('should return empty array when no banks exist', async () => { + const result = await bankService.getAllBanks(); + + expect(result).toEqual([]); + }); + }); + + describe('updateBankDetails', () => { + it('should successfully update bank details', async () => { + const bank = bankRepository.create({ + name: 'Old Name', + code: '001', + }); + const savedBank = await bankRepository.save(bank); + + const updateData = { name: 'New Name' }; + + const result = await bankService.updateBankDetails(savedBank.id, updateData); + + expect(result.id).toBe(savedBank.id); + expect(result.name).toBe('New Name'); + expect(result.code).toBe('001'); + + const updatedBank = await bankRepository.findOneBy({ id: savedBank.id }); + expect(updatedBank?.name).toBe('New Name'); + }); + + it('should successfully update bank code', async () => { + const bank = bankRepository.create({ + name: 'Test Bank', + code: '001', + }); + const savedBank = await bankRepository.save(bank); + + const updateData = { code: '999' }; + + const result = await bankService.updateBankDetails(savedBank.id, updateData); + + expect(result.code).toBe('999'); + expect(result.name).toBe('Test Bank'); + }); + + it('should throw not found error when bank does not exist', async () => { + const mockUuid = '44d86002-2757-4237-9cb9-eca599d7532e' + await expect( + bankService.updateBankDetails(mockUuid, { name: 'Test' }) + ).rejects.toThrow(ErrorEnum.NOT_FOUND.message); + }); + + it('should throw conflict error when new name already exists', async () => { + const bank1 = bankRepository.create({ name: 'Bank One', code: '001' }); + const bank2 = bankRepository.create({ name: 'Bank Two', code: '002' }); + await bankRepository.save([bank1, bank2]); + + await expect( + bankService.updateBankDetails(bank1.id, { name: 'Bank Two' }) + ).rejects.toThrow(ErrorEnum.CONFLICT.message); + }); + + it('should throw conflict error when new code already exists', async () => { + const bank1 = bankRepository.create({ name: 'Bank One', code: '001' }); + const bank2 = bankRepository.create({ name: 'Bank Two', code: '002' }); + await bankRepository.save([bank1, bank2]); + + await expect( + bankService.updateBankDetails(bank1.id, { code: '002' }) + ).rejects.toThrow(ErrorEnum.CONFLICT.message); + }); + + it('should allow updating with same name and code', async () => { + const bank = bankRepository.create({ name: 'Test Bank', code: '001' }); + const savedBank = await bankRepository.save(bank); + + const result = await bankService.updateBankDetails(savedBank.id, { + name: 'Test Bank', + code: '001', + }); + + expect(result.name).toBe('Test Bank'); + expect(result.code).toBe('001'); + }); + + it('should update multiple fields at once', async () => { + const bank = bankRepository.create({ name: 'Old Bank', code: '001' }); + const savedBank = await bankRepository.save(bank); + + const result = await bankService.updateBankDetails(savedBank.id, { + name: 'New Bank', + code: '999', + }); + + expect(result.name).toBe('New Bank'); + expect(result.code).toBe('999'); + }); + }); + + describe('removeBank', () => { + it('should successfully remove a bank', async () => { + const bank = bankRepository.create({ name: 'Test Bank', code: '001' }); + const savedBank = await bankRepository.save(bank); + + const result = await bankService.removeBank(savedBank.id); + + expect(result).toEqual({ + success: true, + message: 'Bank deleted successfully', + }); + + const deletedBank = await bankRepository.findOneBy({ id: savedBank.id }); + expect(deletedBank).toBeNull(); + }); + + it('should throw not found error when bank does not exist', async () => { + const mockUuid = 'a46400af-3d6e-4003-b7ed-0840eb3c941b' + await expect(bankService.removeBank(mockUuid)).rejects.toThrow( + ErrorEnum.NOT_FOUND.message + ); + }); + }); +}); \ No newline at end of file diff --git a/src/tests/integration/db.ts b/src/tests/integration/db.ts new file mode 100644 index 0000000..cf6b9a2 --- /dev/null +++ b/src/tests/integration/db.ts @@ -0,0 +1,23 @@ +import * as typeorm from "typeorm"; +import { Bank } from "@modules/Bank/entity/bank.entity"; +import { User } from "@modules/User/entity/user.entity"; +import { Transaction } from "@modules/Transaction/entity/trasaction.entity"; +import { Account } from "@modules/Account/entity/account.entity"; +import { env } from "@shared/env"; +import * as dotenv from "dotenv"; + +dotenv.config(); + +export const TestAppDataSource = new typeorm.DataSource({ + type: "postgres", + database: env.DB_DATABASE + "_test", + host: env.DB_HOST, + port: 5432, + username: env.DB_USERNAME, + password: env.DB_PASSWORD, + synchronize: true, + logging: false, + entities: [User, Bank, Transaction, Account], + subscribers: [], + migrations: [], +}); \ No newline at end of file diff --git a/src/tests/integration/user.service.spec.ts b/src/tests/integration/user.service.spec.ts new file mode 100644 index 0000000..d8ee112 --- /dev/null +++ b/src/tests/integration/user.service.spec.ts @@ -0,0 +1,55 @@ +import { UserService } from "@modules/User/user.service"; +import { AuthService } from "@modules/Auth/auth.service"; +import { TestAppDataSource as dataSource} from "./db" +import { User } from "@modules/User/entity/user.entity"; +import { MockEmailService } from "./auth.service.spec"; + +describe("UserService Integration Tests", () => { + let userService: UserService; + let emailService: MockEmailService; + let authService: AuthService; + let user; + + beforeAll(async () => { + await dataSource.initialize(); + userService = new UserService(dataSource.getRepository(User)); + emailService = new MockEmailService(); + authService = new AuthService(dataSource.getRepository(User), emailService); + + user = { + name: 'Jane Doe', + email: 'janedoe@gmail.com', + password: "securePassword456", + age: 28 + } + + const registeredUser = await authService.register(user); + user.id = registeredUser.id; + }) + + afterAll(() => { + dataSource.destroy().then(() => { + }) + }) + + test('Get User Info', async () => { + const userInfo = await userService.getUserInfo(user.id); + + expect(userInfo).toHaveProperty('id', user.id); + expect(userInfo).toHaveProperty('name', user.name); + expect(userInfo).toHaveProperty('email', user.email); + }) + + test('Update User info', async () => { + const updateData = { + name: 'Jane Smith', + age: 29 + }; + + const updateUser = await userService.updateUser(user.id, updateData); + + expect(updateUser).toHaveProperty('id', user.id); + expect(updateUser).toHaveProperty('name', updateData.name); + expect(updateUser).toHaveProperty('age', updateData.age); + }) +}) \ No newline at end of file diff --git a/src/tests/unit/middlewares.spec.ts b/src/tests/unit/middlewares.spec.ts new file mode 100644 index 0000000..726ffe5 --- /dev/null +++ b/src/tests/unit/middlewares.spec.ts @@ -0,0 +1,63 @@ +import { CreateAccountSchema } from "@lib/schema"; +import { bodyParser } from "@middlewares/bodyparser"; +import { randomUUID } from "crypto"; +import { Request, Response } from "express"; +import { ErrorEnum } from "@lib/enums"; +import { Account as accountEnum } from "@lib/enums"; +import z from "zod"; + + +describe("bodyParser Middleware", () => { + type AccountBody = z.infer; + + const mockAccount: AccountBody = { + accountNumber: "1234567890", + agency: '00001', + accountType: accountEnum.BUSINESS, + bankId: randomUUID(), + name: 'mock account', + balance: 0 + }; + + let req: Partial; + let res: Partial; + let next: jest.Mock; + const mid = bodyParser(CreateAccountSchema); + + beforeEach(() => { + req = { body: { ...mockAccount } }; + + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + next = jest.fn(); + }); + + + test("should call next if body is valid", () => { + mid(req as Request, res as Response, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + test("should return 400 if an attribute has invalid type", () => { + req.body.bankId = 123; + + mid(req as Request, res as Response, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(ErrorEnum.MISSING_PROPERTIES.status); + }); + + test("should return 400 if a required attribute is missing", () => { + delete req.body.bankId; + + mid(req as Request, res as Response, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(ErrorEnum.MISSING_PROPERTIES.status); + }); +}); \ No newline at end of file diff --git a/src/tests/unit/utils.spec.ts b/src/tests/unit/utils.spec.ts new file mode 100644 index 0000000..7de6bd9 --- /dev/null +++ b/src/tests/unit/utils.spec.ts @@ -0,0 +1,16 @@ +import { comparePasswords } from "../../lib/utils"; +import bcrypt from "bcryptjs"; + +test("comparePasswords return true for matching passwords", () => { + const password = 'mypassword'; + const digest = bcrypt.hashSync(password, 10); + + comparePasswords(password, digest).then((res) => { + expect(res).toBe(true); + }) + + const wrongPassword = 'wrongpassword'; + comparePasswords(wrongPassword, digest).then((res) => { + expect(res).toBe(false); + }) +}) \ No newline at end of file