diff --git a/src/routes/account/create/controller.test.ts b/src/routes/account/create/controller.test.ts index 7d4ae13..7bddf93 100644 --- a/src/routes/account/create/controller.test.ts +++ b/src/routes/account/create/controller.test.ts @@ -1,4 +1,5 @@ import type { Request, Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; import { Account } from '@/models/Account'; import createAccountController from './controller'; @@ -16,15 +17,30 @@ describe('createAccountController', () => { it('should create a new account and return 201 status', async () => { req.body = { name: 'Test Account' }; + Account.findOne = jest.fn().mockResolvedValueOnce(null); Account.create = jest.fn().mockResolvedValueOnce(req.body); await createAccountController(req as Request, res as Response); - expect(res.status).toHaveBeenCalledWith(201); + expect(res.status).toHaveBeenCalledWith(StatusCodes.CREATED); expect(res.json).toHaveBeenCalledWith( expect.objectContaining({ name: 'Test Account', }) ); }); + + it('should return 409 if account with the same name already exists', async () => { + req.body = { name: 'Existing Account' }; + Account.findOne = jest.fn().mockResolvedValueOnce({ name: 'Existing Account' }); + + await createAccountController(req as Request, res as Response); + + expect(res.status).toHaveBeenCalledWith(StatusCodes.CONFLICT); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Account with this name already exists', + }) + ); + }); }); diff --git a/src/routes/account/create/controller.ts b/src/routes/account/create/controller.ts index c7b6014..609939b 100644 --- a/src/routes/account/create/controller.ts +++ b/src/routes/account/create/controller.ts @@ -3,6 +3,14 @@ import { StatusCodes } from 'http-status-codes'; import { Account } from '@/models/Account'; export default async (req: Request, res: Response) => { + const existingAccount = await Account.findOne({ name: req.body.name }); + + if (existingAccount) { + return res.status(StatusCodes.CONFLICT).json({ + message: 'Account with this name already exists', + }); + } + const account = await Account.create(req.body); res.status(StatusCodes.CREATED).json(account); diff --git a/src/routes/account/credit/controller.test.ts b/src/routes/account/credit/controller.test.ts index 8ce122e..474d16a 100644 --- a/src/routes/account/credit/controller.test.ts +++ b/src/routes/account/credit/controller.test.ts @@ -8,10 +8,19 @@ describe('creditAccountController', () => { let req: Partial; let res: Partial; + beforeAll(() => { + Account.startSession = jest.fn().mockReturnValue({ + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + abortTransaction: jest.fn(), + endSession: jest.fn(), + }); + }); + beforeEach(() => { req = { - params: { accountId: '123' }, - body: { amount: 500, date: '2025-04-02' }, + params: { accountId: 'acc123' }, + body: { amount: 200, date: '2025-04-02' }, }; res = { status: jest.fn().mockReturnThis(), @@ -19,42 +28,43 @@ describe('creditAccountController', () => { }; }); - it('should credit the account and return 200', async () => { - const accountId = '123'; - const creditAmount = 500; + it('should credit the account and return the updated account and transaction', async () => { + const accountId = 'acc123'; const currentBalance = 1000; + const creditAmount = 200; const newBalance = currentBalance + creditAmount; const mockAccount = { _id: accountId, - name: 'Test Account', balance: currentBalance, save: jest.fn().mockResolvedValueOnce({ _id: accountId, - name: 'Test Account', balance: newBalance, }), }; - Account.findById = jest.fn().mockResolvedValueOnce(mockAccount); - Transaction.create = jest.fn().mockResolvedValueOnce({ + const mockTransaction = { _id: 'txn123', account: accountId, type: TransactionType.credit, amount: creditAmount, - balance: newBalance, - description: 'Account credit', - }); + }; + + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); + Transaction.create = jest.fn().mockResolvedValueOnce(mockTransaction); await creditAccountController(req as Request, res as Response); expect(mockAccount.save).toHaveBeenCalled(); expect(Transaction.create).toHaveBeenCalledWith( - expect.objectContaining({ - account: accountId, - type: TransactionType.credit, - amount: creditAmount, - }) + [ + expect.objectContaining({ + account: accountId, + type: TransactionType.credit, + amount: creditAmount, + }), + ], + expect.any(Object) ); expect(res.status).toHaveBeenCalledWith(StatusCodes.CREATED); expect(res.json).toHaveBeenCalledWith( @@ -62,12 +72,13 @@ describe('creditAccountController', () => { account: expect.objectContaining({ balance: newBalance, }), + transaction: mockTransaction, }) ); }); it('should return 404 if account is not found', async () => { - Account.findById = jest.fn().mockResolvedValueOnce(null); + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(null) }); await creditAccountController(req as Request, res as Response); @@ -78,4 +89,12 @@ describe('creditAccountController', () => { }) ); }); + + it('should handle errors and abort transaction', async () => { + Account.findById = jest + .fn() + .mockReturnValueOnce({ session: jest.fn().mockRejectedValueOnce(new Error('Database error')) }); + + await expect(creditAccountController(req as Request, res as Response)).rejects.toThrow('Database error'); + }); }); diff --git a/src/routes/account/credit/controller.ts b/src/routes/account/credit/controller.ts index 29bc815..077f687 100644 --- a/src/routes/account/credit/controller.ts +++ b/src/routes/account/credit/controller.ts @@ -11,21 +11,38 @@ export default async (req: Request, res: Response) => { const { accountId } = req.params; const { amount, date } = req.body; - const account = await Account.findById(accountId); - if (!account) { - res.status(StatusCodes.NOT_FOUND).json({ error: 'Account not found' }); - return; - } + const session = await Account.startSession(); + session.startTransaction(); + + try { + const account = await Account.findById(accountId).session(session); + if (!account) { + await session.abortTransaction(); + res.status(StatusCodes.NOT_FOUND).json({ error: 'Account not found' }); + return; + } - const transaction = await Transaction.create({ - amount, - date, - type: TransactionType.credit, - account: accountId, - }); + const transaction = await Transaction.create( + [ + { + amount, + date, + type: TransactionType.credit, + account: accountId, + }, + ], + { session } + ); - account.balance += amount; - await account.save(); + account.balance += amount; + await account.save({ session }); - res.status(StatusCodes.CREATED).json({ account, transaction }); + await session.commitTransaction(); + res.status(StatusCodes.CREATED).json({ account, transaction }); + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + session.endSession(); + } }; diff --git a/src/routes/account/debit/controller.test.ts b/src/routes/account/debit/controller.test.ts index 5881025..5f5bc53 100644 --- a/src/routes/account/debit/controller.test.ts +++ b/src/routes/account/debit/controller.test.ts @@ -8,10 +8,20 @@ describe('debitAccountController', () => { let req: Partial; let res: Partial; + beforeAll(() => { + Account.startSession = jest.fn().mockReturnValue({ + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + abortTransaction: jest.fn(), + endSession: jest.fn(), + }); + }); + beforeEach(() => { + jest.clearAllMocks(); req = { - params: { accountId: '123' }, - body: { cost: 300, date: '2025-04-02' }, + params: { accountId: 'acc123' }, + body: { cost: 200, date: '2025-04-02' }, }; res = { status: jest.fn().mockReturnThis(), @@ -19,42 +29,43 @@ describe('debitAccountController', () => { }; }); - it('should debit the account and return 200', async () => { - const accountId = '123'; - const debitCost = 300; + it('should debit the account and return the updated account and transaction', async () => { + const accountId = 'acc123'; const currentBalance = 1000; + const debitCost = 200; const newBalance = currentBalance - debitCost; const mockAccount = { _id: accountId, - name: 'Test Account', balance: currentBalance, save: jest.fn().mockResolvedValueOnce({ _id: accountId, - name: 'Test Account', balance: newBalance, }), }; - Account.findById = jest.fn().mockResolvedValueOnce(mockAccount); - Transaction.create = jest.fn().mockResolvedValueOnce({ + const mockTransaction = { _id: 'txn123', account: accountId, type: TransactionType.debit, cost: debitCost, - balance: newBalance, - description: 'Account debit', - }); + }; + + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); + Transaction.create = jest.fn().mockResolvedValueOnce(mockTransaction); await debitAccountController(req as Request, res as Response); expect(mockAccount.save).toHaveBeenCalled(); expect(Transaction.create).toHaveBeenCalledWith( - expect.objectContaining({ - account: accountId, - type: TransactionType.debit, - cost: debitCost, - }) + [ + expect.objectContaining({ + account: accountId, + type: TransactionType.debit, + cost: debitCost, + }), + ], + expect.any(Object) ); expect(res.status).toHaveBeenCalledWith(StatusCodes.CREATED); expect(res.json).toHaveBeenCalledWith( @@ -62,12 +73,13 @@ describe('debitAccountController', () => { account: expect.objectContaining({ balance: newBalance, }), + transaction: mockTransaction, }) ); }); it('should return 404 if account is not found', async () => { - Account.findById = jest.fn().mockResolvedValueOnce(null); + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(null) }); await debitAccountController(req as Request, res as Response); @@ -80,18 +92,15 @@ describe('debitAccountController', () => { }); it('should return 400 if insufficient funds', async () => { - const accountId = '123'; - const debitCost = 1500; - const currentBalance = 1000; + const accountId = 'acc123'; + const currentBalance = 100; const mockAccount = { _id: accountId, - name: 'Test Account', balance: currentBalance, }; - Account.findById = jest.fn().mockResolvedValueOnce(mockAccount); - req.body = { cost: debitCost }; + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); await debitAccountController(req as Request, res as Response); @@ -102,4 +111,12 @@ describe('debitAccountController', () => { }) ); }); + + it('should handle errors and abort transaction', async () => { + Account.findById = jest + .fn() + .mockReturnValueOnce({ session: jest.fn().mockRejectedValueOnce(new Error('Database error')) }); + + await expect(debitAccountController(req as Request, res as Response)).rejects.toThrow('Database error'); + }); }); diff --git a/src/routes/account/debit/controller.ts b/src/routes/account/debit/controller.ts index 2ac3d54..ed91dde 100644 --- a/src/routes/account/debit/controller.ts +++ b/src/routes/account/debit/controller.ts @@ -7,26 +7,44 @@ export default async (req: Request, res: Response) => { const { accountId } = req.params; const { cost, date } = req.body; - const account = await Account.findById(accountId); - if (!account) { - res.status(StatusCodes.NOT_FOUND).json({ error: 'Account not found' }); - return; - } + const session = await Account.startSession(); + session.startTransaction(); - if (account.balance < cost) { - res.status(StatusCodes.BAD_REQUEST).json({ error: 'Insufficient funds' }); - return; - } + try { + const account = await Account.findById(accountId).session(session); + if (!account) { + await session.abortTransaction(); + res.status(StatusCodes.NOT_FOUND).json({ error: 'Account not found' }); + return; + } - const transaction = await Transaction.create({ - cost, - date, - type: TransactionType.debit, - account: accountId, - }); + if (account.balance < cost) { + await session.abortTransaction(); + res.status(StatusCodes.BAD_REQUEST).json({ error: 'Insufficient funds' }); + return; + } - account.balance -= cost; - await account.save(); + const transaction = await Transaction.create( + [ + { + cost, + date, + type: TransactionType.debit, + account: accountId, + }, + ], + { session } + ); - res.status(StatusCodes.CREATED).json({ account, transaction }); + account.balance -= cost; + await account.save({ session }); + + await session.commitTransaction(); + res.status(StatusCodes.CREATED).json({ account, transaction }); + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + session.endSession(); + } }; diff --git a/src/routes/account/delete/controller.test.ts b/src/routes/account/delete/controller.test.ts index 2f7975e..31fea65 100644 --- a/src/routes/account/delete/controller.test.ts +++ b/src/routes/account/delete/controller.test.ts @@ -25,7 +25,7 @@ describe('deleteAccountController', () => { it('should delete the account and return 200 if no transactions exist', async () => { const accountId = '123'; - req.params = { id: accountId }; + req.params = { accountId }; Account.findById = jest.fn().mockResolvedValueOnce({ _id: accountId, @@ -50,7 +50,7 @@ describe('deleteAccountController', () => { }); it('should return 404 if account is not found', async () => { - req.params = { id: 'nonexistent' }; + req.params = { accountId: 'nonexistent' }; Account.findById = jest.fn().mockResolvedValueOnce(null); @@ -66,7 +66,7 @@ describe('deleteAccountController', () => { it('should return 400 if account has existing transactions', async () => { const accountId = '123'; - req.params = { id: accountId }; + req.params = { accountId }; Account.findById = jest.fn().mockResolvedValueOnce({ _id: accountId, @@ -87,7 +87,7 @@ describe('deleteAccountController', () => { it('should delete transactions if TRANSACTION_DELETE_POLICY is set to "cascade"', async () => { const accountId = '123'; - req.params = { id: accountId }; + req.params = { accountId }; process.env.TRANSACTION_DELETE_POLICY = 'cascade'; Account.findById = jest.fn().mockResolvedValueOnce({ @@ -104,7 +104,7 @@ describe('deleteAccountController', () => { it('should not delete transactions if TRANSACTION_DELETE_POLICY is set to "keep"', async () => { const accountId = '123'; - req.params = { id: accountId }; + req.params = { accountId }; process.env.TRANSACTION_DELETE_POLICY = 'keep'; Account.findById = jest.fn().mockResolvedValueOnce({ @@ -121,7 +121,7 @@ describe('deleteAccountController', () => { it('should return 400 if TRANSACTION_DELETE_POLICY is invalid', async () => { const accountId = '123'; - req.params = { id: accountId }; + req.params = { accountId }; // @ts-ignore process.env.TRANSACTION_DELETE_POLICY = 'invalid_policy'; diff --git a/src/routes/account/delete/controller.ts b/src/routes/account/delete/controller.ts index 2d99225..e766882 100644 --- a/src/routes/account/delete/controller.ts +++ b/src/routes/account/delete/controller.ts @@ -4,9 +4,9 @@ import { Account } from '@/models/Account'; import { Transaction } from '@/models/Transaction'; export default async function deleteAccountController(req: Request, res: Response) { - const { id } = req.params; + const { accountId } = req.params; - const account = await Account.findById(id); + const account = await Account.findById(accountId); if (!account) { res.status(StatusCodes.NOT_FOUND).json({ error: 'Account not found', @@ -16,10 +16,10 @@ export default async function deleteAccountController(req: Request, res: Respons switch (process.env.TRANSACTION_DELETE_POLICY) { case 'cascade': - await Transaction.deleteMany({ account: id }); + await Transaction.deleteMany({ account: accountId }); break; case 'deny': { - const hasTransactions = await Transaction.exists({ account: id }); + const hasTransactions = await Transaction.exists({ account: accountId }); if (hasTransactions) { res.status(StatusCodes.BAD_REQUEST).json({ error: 'Cannot delete account with existing transactions', @@ -38,7 +38,7 @@ export default async function deleteAccountController(req: Request, res: Respons return; } - await Account.findByIdAndDelete(id); + await Account.findByIdAndDelete(accountId); res.status(StatusCodes.OK).json({ message: 'Account deleted successfully', diff --git a/src/routes/account/edit/controller.test.ts b/src/routes/account/edit/controller.test.ts index 6948cac..715b013 100644 --- a/src/routes/account/edit/controller.test.ts +++ b/src/routes/account/edit/controller.test.ts @@ -30,6 +30,7 @@ describe('editAccountController', () => { balance: 100, }), }); + Account.findOne = jest.fn().mockResolvedValueOnce(null); await editAccountController(req as Request, res as Response); @@ -66,9 +67,12 @@ describe('editAccountController', () => { _id: '123', name: 'Old Account', balance: 100, - save: jest.fn().mockRejectedValueOnce({ - code: 11000, - }), + save: jest.fn(), + }); + + Account.findOne = jest.fn().mockResolvedValueOnce({ + _id: '456', + name: 'Existing Account', }); await editAccountController(req as Request, res as Response); diff --git a/src/routes/account/edit/controller.ts b/src/routes/account/edit/controller.ts index d394896..0f4904d 100644 --- a/src/routes/account/edit/controller.ts +++ b/src/routes/account/edit/controller.ts @@ -3,32 +3,30 @@ import { StatusCodes } from 'http-status-codes'; import { Account } from '@/models/Account'; export default async function editAccountController(req: Request, res: Response) { - try { - const { id } = req.params; - const { name } = req.body; + const { accountId } = req.params; + const { name } = req.body; - const account = await Account.findById(id); - if (!account) { - res.status(StatusCodes.NOT_FOUND).json({ - error: 'Account not found', - }); - return; - } - - account.name = name; - await account.save(); + const account = await Account.findById(accountId); + if (!account) { + res.status(StatusCodes.NOT_FOUND).json({ + error: 'Account not found', + }); + return; + } - res.json({ - message: 'Account updated successfully', - account, + const existingAccount = await Account.findOne({ name, _id: { $ne: accountId } }); + if (existingAccount) { + res.status(StatusCodes.CONFLICT).json({ + error: 'Account name already exists', }); - } catch (error) { - if ((error as { code: number }).code === 11000) { - res.status(StatusCodes.CONFLICT).json({ - error: 'Account name already exists', - }); - return; - } - throw error; + return; } + + account.name = name; + await account.save(); + + res.json({ + message: 'Account updated successfully', + account, + }); } diff --git a/src/routes/transaction/create/controller.test.ts b/src/routes/transaction/create/controller.test.ts index a6ce78f..1763763 100644 --- a/src/routes/transaction/create/controller.test.ts +++ b/src/routes/transaction/create/controller.test.ts @@ -1,30 +1,169 @@ import type { Request, Response } from 'express'; import { StatusCodes } from 'http-status-codes'; -import { Transaction } from '@/models/Transaction'; +import { Account } from '@/models/Account'; +import { Transaction, TransactionType } from '@/models/Transaction'; import createTransactionController from './controller'; describe('createTransactionController', () => { let req: Partial; let res: Partial; + beforeAll(() => { + Transaction.startSession = jest.fn().mockReturnValue({ + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + abortTransaction: jest.fn(), + endSession: jest.fn(), + }); + }); + beforeEach(() => { - req = { body: {} }; + req = { + body: { accountId: 'acc123', amount: 200, date: '2025-04-02', type: TransactionType.credit }, + }; res = { status: jest.fn().mockReturnThis(), json: jest.fn(), }; }); - it('should create a transaction and return it with status 201', async () => { - const mockTransaction = { id: 1, amount: 100, description: 'Test transaction' }; - Transaction.create = jest.fn().mockResolvedValue(mockTransaction); + it('should create a credit transaction and update the account balance', async () => { + const accountId = 'acc123'; + const currentBalance = 1000; + const creditAmount = 200; + const newBalance = currentBalance + creditAmount; + + const mockAccount = { + _id: accountId, + balance: currentBalance, + save: jest.fn().mockResolvedValueOnce({ + _id: accountId, + balance: newBalance, + }), + }; + + const mockTransaction = { + _id: 'txn123', + account: accountId, + type: TransactionType.credit, + amount: creditAmount, + }; + + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); + Transaction.create = jest.fn().mockResolvedValueOnce(mockTransaction); + + await createTransactionController(req as Request, res as Response); + + expect(mockAccount.save).toHaveBeenCalled(); + expect(Transaction.create).toHaveBeenCalledWith( + expect.objectContaining({ + account: accountId, + type: TransactionType.credit, + amount: creditAmount, + }), + expect.any(Object) + ); + expect(res.status).toHaveBeenCalledWith(StatusCodes.CREATED); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + account: expect.objectContaining({ + balance: newBalance, + }), + transaction: mockTransaction, + }) + ); + }); + + it('should create a debit transaction and update the account balance', async () => { + req.body = { accountId: 'acc123', cost: 200, date: '2025-04-02', type: TransactionType.debit }; + + const accountId = 'acc123'; + const currentBalance = 1000; + const debitCost = 200; + const newBalance = currentBalance - debitCost; + + const mockAccount = { + _id: accountId, + balance: currentBalance, + save: jest.fn().mockResolvedValueOnce({ + _id: accountId, + balance: newBalance, + }), + }; + + const mockTransaction = { + _id: 'txn123', + account: accountId, + type: TransactionType.debit, + cost: debitCost, + }; - req.body = { amount: 100, description: 'Test transaction' }; + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); + Transaction.create = jest.fn().mockResolvedValueOnce(mockTransaction); await createTransactionController(req as Request, res as Response); - expect(Transaction.create).toHaveBeenCalledWith({ amount: 100, description: 'Test transaction' }); + expect(mockAccount.save).toHaveBeenCalled(); + expect(Transaction.create).toHaveBeenCalledWith( + expect.objectContaining({ + account: accountId, + type: TransactionType.debit, + cost: debitCost, + }), + expect.any(Object) + ); expect(res.status).toHaveBeenCalledWith(StatusCodes.CREATED); - expect(res.json).toHaveBeenCalledWith(mockTransaction); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + account: expect.objectContaining({ + balance: newBalance, + }), + transaction: mockTransaction, + }) + ); + }); + + it('should return 404 if account is not found', async () => { + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(null) }); + + await createTransactionController(req as Request, res as Response); + + expect(res.status).toHaveBeenCalledWith(StatusCodes.NOT_FOUND); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'Account not found', + }) + ); + }); + + it('should return 400 if insufficient funds for debit transaction', async () => { + req.body = { accountId: 'acc123', cost: 2000, date: '2025-04-02', type: TransactionType.debit }; + + const accountId = 'acc123'; + const currentBalance = 1000; + + const mockAccount = { + _id: accountId, + balance: currentBalance, + }; + + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); + + await createTransactionController(req as Request, res as Response); + + expect(res.status).toHaveBeenCalledWith(StatusCodes.BAD_REQUEST); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'Insufficient funds', + }) + ); + }); + + it('should handle errors and abort transaction', async () => { + Account.findById = jest + .fn() + .mockReturnValueOnce({ session: jest.fn().mockRejectedValueOnce(new Error('Database error')) }); + + await expect(createTransactionController(req as Request, res as Response)).rejects.toThrow('Database error'); }); }); diff --git a/src/routes/transaction/create/controller.ts b/src/routes/transaction/create/controller.ts index 6bd5d3f..a72e14e 100644 --- a/src/routes/transaction/create/controller.ts +++ b/src/routes/transaction/create/controller.ts @@ -1,9 +1,57 @@ import type { Request, Response } from 'express'; import { StatusCodes } from 'http-status-codes'; -import { Transaction } from '@/models/Transaction'; +import { Account, type IAccount } from '@/models/Account'; +import { Transaction, TransactionType } from '@/models/Transaction'; export default async (req: Request, res: Response) => { - const transaction = await Transaction.create(req.body); + const { accountId, amount, cost, date, type } = req.body; - res.status(StatusCodes.CREATED).json(transaction); + const session = await Transaction.startSession(); + session.startTransaction(); + + try { + let account: IAccount | null = null; + + if (accountId) { + account = await Account.findById(accountId).session(session); + if (!account) { + await session.abortTransaction(); + res.status(StatusCodes.NOT_FOUND).json({ error: 'Account not found' }); + return; + } + + if (type === TransactionType.debit && account.balance < cost) { + await session.abortTransaction(); + res.status(StatusCodes.BAD_REQUEST).json({ error: 'Insufficient funds' }); + return; + } + + if (type === TransactionType.debit) { + account.balance -= cost; + } else if (type === TransactionType.credit) { + account.balance += amount; + } + + await account.save({ session }); + } + + const transaction = await Transaction.create( + { + account: accountId, + amount, + cost, + date, + type, + }, + { session } + ); + + await session.commitTransaction(); + res.status(StatusCodes.CREATED).json({ transaction, account }); + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + session.endSession(); + } }; diff --git a/src/routes/transaction/create/schema.test.ts b/src/routes/transaction/create/schema.test.ts index f8d1c21..7b52093 100644 --- a/src/routes/transaction/create/schema.test.ts +++ b/src/routes/transaction/create/schema.test.ts @@ -8,7 +8,7 @@ describe('Transaction Create Schema', () => { const validDebit = { type: 'debit', date: '2023-01-01', - amount: 100, + cost: 100, description: 'Test debit transaction', }; expect(() => body.parse(validDebit)).not.toThrow(z.ZodError); @@ -18,28 +18,28 @@ describe('Transaction Create Schema', () => { const validCredit = { type: 'credit', date: '2023-01-01', - cost: 50, + amount: 50, description: 'Test credit transaction', }; expect(() => body.parse(validCredit)).not.toThrow(z.ZodError); }); - it('should throw an error if amount is missing for debit transaction', () => { + it('should throw an error if cost is missing for debit transaction', () => { const invalidDebit = { type: 'debit', date: '2023-01-01', description: 'Invalid debit transaction', }; - expect(() => body.parse(invalidDebit)).toThrow("'amount' is required for debit transactions"); + expect(() => body.parse(invalidDebit)).toThrow("'cost' is required for debit transactions"); }); - it('should throw an error if cost is missing for credit transaction', () => { + it('should throw an error if amount is missing for credit transaction', () => { const invalidCredit = { type: 'credit', date: '2023-01-01', description: 'Invalid credit transaction', }; - expect(() => body.parse(invalidCredit)).toThrow("'cost' is required for credit transactions"); + expect(() => body.parse(invalidCredit)).toThrow("'amount' is required for credit transactions"); }); it('should throw an error if date is not in ISO format', () => { @@ -62,13 +62,13 @@ describe('Transaction Create Schema', () => { expect(() => body.parse(longDescription)).toThrow(); }); - it('should throw an error if accountId is not a valid UUID', () => { + it('should throw an error if accountId is not a valid document id', () => { const invalidAccountId = { type: 'debit', date: '2023-01-01', amount: 100, description: 'Invalid accountId', - accountId: 'not-a-uuid', + accountId: 'not-a-mongo-id', }; expect(() => body.parse(invalidAccountId)).toThrow(z.ZodError); }); diff --git a/src/routes/transaction/create/schema.ts b/src/routes/transaction/create/schema.ts index 88a446d..b493bf9 100644 --- a/src/routes/transaction/create/schema.ts +++ b/src/routes/transaction/create/schema.ts @@ -1,3 +1,4 @@ +import mongoose from 'mongoose'; import { z } from 'zod'; export default { @@ -8,19 +9,24 @@ export default { amount: z.number().positive().optional(), cost: z.number().positive().optional(), description: z.string().max(255), - accountId: z.string().uuid().optional(), + accountId: z + .string() + .refine(val => { + return mongoose.Types.ObjectId.isValid(val); + }) + .optional(), }) .superRefine((data, ctx) => { - if (data.type === 'debit' && data.amount === undefined) { + if (data.type === 'debit' && data.cost === undefined) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `'amount' is required for debit transactions`, + message: `'cost' is required for debit transactions`, }); } - if (data.type === 'credit' && data.cost === undefined) { + if (data.type === 'credit' && data.amount === undefined) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `'cost' is required for credit transactions`, + message: `'amount' is required for credit transactions`, }); } }), diff --git a/src/routes/transaction/delete/controller.test.ts b/src/routes/transaction/delete/controller.test.ts index 50ec716..d84b645 100644 --- a/src/routes/transaction/delete/controller.test.ts +++ b/src/routes/transaction/delete/controller.test.ts @@ -50,8 +50,8 @@ describe('deleteTransactionController', () => { }), }; - Transaction.findById = jest.fn().mockResolvedValueOnce(mockTransaction); - Account.findById = jest.fn().mockResolvedValueOnce(mockAccount); + Transaction.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockTransaction) }); + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); Transaction.findByIdAndDelete = jest.fn().mockReturnValue({ session: jest.fn().mockResolvedValueOnce(null), }); @@ -90,8 +90,8 @@ describe('deleteTransactionController', () => { }), }; - Transaction.findById = jest.fn().mockResolvedValueOnce(mockTransaction); - Account.findById = jest.fn().mockResolvedValueOnce(mockAccount); + Transaction.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockTransaction) }); + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); Transaction.findByIdAndDelete = jest.fn().mockReturnValue({ session: jest.fn().mockResolvedValueOnce(null), }); @@ -130,8 +130,8 @@ describe('deleteTransactionController', () => { }), }; - Transaction.findById = jest.fn().mockResolvedValueOnce(mockTransaction); - Account.findById = jest.fn().mockResolvedValueOnce(mockAccount); + Transaction.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockTransaction) }); + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); Transaction.findByIdAndDelete = jest.fn().mockReturnValue({ session: jest.fn().mockResolvedValueOnce(null), }); @@ -170,8 +170,8 @@ describe('deleteTransactionController', () => { }), }; - Transaction.findById = jest.fn().mockResolvedValueOnce(mockTransaction); - Account.findById = jest.fn().mockResolvedValueOnce(mockAccount); + Transaction.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockTransaction) }); + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); Transaction.findByIdAndDelete = jest.fn().mockReturnValue({ session: jest.fn().mockResolvedValueOnce(null), }); @@ -188,7 +188,7 @@ describe('deleteTransactionController', () => { }); it('should return 404 if transaction is not found', async () => { - Transaction.findById = jest.fn().mockResolvedValueOnce(null); + Transaction.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(null) }); await deleteTransactionController(req as Request, res as Response); @@ -208,8 +208,8 @@ describe('deleteTransactionController', () => { account: 'acc123', }; - Transaction.findById = jest.fn().mockResolvedValueOnce(mockTransaction); - Account.findById = jest.fn().mockResolvedValueOnce(null); + Transaction.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockTransaction) }); + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(null) }); await deleteTransactionController(req as Request, res as Response); @@ -239,8 +239,8 @@ describe('deleteTransactionController', () => { balance: currentBalance, }; - Transaction.findById = jest.fn().mockResolvedValueOnce(mockTransaction); - Account.findById = jest.fn().mockResolvedValueOnce(mockAccount); + Transaction.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockTransaction) }); + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); await deleteTransactionController(req as Request, res as Response); @@ -253,14 +253,9 @@ describe('deleteTransactionController', () => { }); it('should handle errors and abort transaction', async () => { - Transaction.startSession = jest.fn().mockReturnValue({ - startTransaction: jest.fn(), - commitTransaction: jest.fn(), - abortTransaction: jest.fn(), - endSession: jest.fn(), - }); - - Transaction.findById = jest.fn().mockRejectedValueOnce(new Error('Database error')); + Transaction.findById = jest + .fn() + .mockReturnValueOnce({ session: jest.fn().mockRejectedValueOnce(new Error('Database error')) }); await expect(deleteTransactionController(req as Request, res as Response)).rejects.toThrow('Database error'); }); diff --git a/src/routes/transaction/delete/controller.ts b/src/routes/transaction/delete/controller.ts index 7fffaf2..531233c 100644 --- a/src/routes/transaction/delete/controller.ts +++ b/src/routes/transaction/delete/controller.ts @@ -11,7 +11,7 @@ export default async function deleteTransactionController(req: Request, res: Res const { transactionId } = req.params; - const transaction = await Transaction.findById(transactionId); + const transaction = await Transaction.findById(transactionId).session(session); if (!transaction) { await session.abortTransaction(); res.status(StatusCodes.NOT_FOUND).json({ @@ -20,7 +20,7 @@ export default async function deleteTransactionController(req: Request, res: Res return; } - const account = await Account.findById(transaction.account); + const account = await Account.findById(transaction.account).session(session); if (!account) { await session.abortTransaction(); res.status(StatusCodes.NOT_FOUND).json({ diff --git a/src/routes/transaction/edit/controller.test.ts b/src/routes/transaction/edit/controller.test.ts index a9ebcfc..a571e3d 100644 --- a/src/routes/transaction/edit/controller.test.ts +++ b/src/routes/transaction/edit/controller.test.ts @@ -8,7 +8,17 @@ describe('editTransactionController', () => { let req: Partial; let res: Partial; + beforeAll(() => { + Transaction.startSession = jest.fn().mockReturnValue({ + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + abortTransaction: jest.fn(), + endSession: jest.fn(), + }); + }); + beforeEach(() => { + jest.clearAllMocks(); req = { params: { transactionId: 'txn123' }, body: { date: '2025-04-02', amount: 500 }, @@ -50,8 +60,8 @@ describe('editTransactionController', () => { }), }; - Transaction.findById = jest.fn().mockResolvedValueOnce(mockTransaction); - Account.findById = jest.fn().mockResolvedValueOnce(mockAccount); + Transaction.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockTransaction) }); + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); await editTransactionController(req as Request, res as Response); @@ -100,8 +110,8 @@ describe('editTransactionController', () => { }), }; - Transaction.findById = jest.fn().mockResolvedValueOnce(mockTransaction); - Account.findById = jest.fn().mockResolvedValueOnce(mockAccount); + Transaction.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockTransaction) }); + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); await editTransactionController(req as Request, res as Response); @@ -115,7 +125,7 @@ describe('editTransactionController', () => { }); it('should return 404 if transaction is not found', async () => { - Transaction.findById = jest.fn().mockResolvedValueOnce(null); + Transaction.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(null) }); await editTransactionController(req as Request, res as Response); @@ -135,8 +145,8 @@ describe('editTransactionController', () => { account: 'acc123', }; - Transaction.findById = jest.fn().mockResolvedValueOnce(mockTransaction); - Account.findById = jest.fn().mockResolvedValueOnce(null); + Transaction.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockTransaction) }); + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(null) }); await editTransactionController(req as Request, res as Response); @@ -167,8 +177,8 @@ describe('editTransactionController', () => { balance: currentBalance, }; - Transaction.findById = jest.fn().mockResolvedValueOnce(mockTransaction); - Account.findById = jest.fn().mockResolvedValueOnce(mockAccount); + Transaction.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockTransaction) }); + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); req.body.cost = newTransactionAmount; await editTransactionController(req as Request, res as Response); @@ -198,8 +208,8 @@ describe('editTransactionController', () => { balance: currentBalance, }; - Transaction.findById = jest.fn().mockResolvedValueOnce(mockTransaction); - Account.findById = jest.fn().mockResolvedValueOnce(mockAccount); + Transaction.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockTransaction) }); + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); req.body.cost = newTransactionAmount; await editTransactionController(req as Request, res as Response); @@ -240,8 +250,8 @@ describe('editTransactionController', () => { }), }; - Transaction.findById = jest.fn().mockResolvedValueOnce(mockTransaction); - Account.findById = jest.fn().mockResolvedValueOnce(mockAccount); + Transaction.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockTransaction) }); + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); req.body.cost = newTransactionAmount; await editTransactionController(req as Request, res as Response); @@ -257,4 +267,12 @@ describe('editTransactionController', () => { }) ); }); + + it('should handle errors and abort transaction', async () => { + Transaction.findById = jest + .fn() + .mockReturnValueOnce({ session: jest.fn().mockRejectedValueOnce(new Error('Database error')) }); + + await expect(editTransactionController(req as Request, res as Response)).rejects.toThrow('Database error'); + }); }); diff --git a/src/routes/transaction/edit/controller.ts b/src/routes/transaction/edit/controller.ts index ae1126b..2d70737 100644 --- a/src/routes/transaction/edit/controller.ts +++ b/src/routes/transaction/edit/controller.ts @@ -7,52 +7,66 @@ export default async function editTransactionController(req: Request, res: Respo const { transactionId } = req.params; const { date, amount, cost } = req.body; - const transaction = await Transaction.findById(transactionId); - if (!transaction) { - res.status(StatusCodes.NOT_FOUND).json({ - error: 'Transaction not found', - }); - return; - } + const session = await Transaction.startSession(); + session.startTransaction(); - const account = await Account.findById(transaction.account); - if (!account) { - res.status(StatusCodes.NOT_FOUND).json({ - error: 'Associated account not found', - }); - return; - } + try { + const transaction = await Transaction.findById(transactionId).session(session); + if (!transaction) { + await session.abortTransaction(); + res.status(StatusCodes.NOT_FOUND).json({ + error: 'Transaction not found', + }); + return; + } - // Calculate balance adjustment - let balanceAdjustment = 0; + const account = await Account.findById(transaction.account).session(session); + if (!account) { + await session.abortTransaction(); + res.status(StatusCodes.NOT_FOUND).json({ + error: 'Associated account not found', + }); + return; + } - if (transaction.type === TransactionType.credit && amount !== undefined) { - balanceAdjustment = amount - (transaction.amount ?? 0); - } else if (transaction.type === TransactionType.debit && cost !== undefined) { - balanceAdjustment = (transaction.cost ?? 0) - cost; - } + // Calculate balance adjustment + let balanceAdjustment = 0; - // Check if new balance would be negative - if (account.balance + balanceAdjustment < 0) { - res.status(StatusCodes.BAD_REQUEST).json({ - error: 'Insufficient funds', - }); - return; - } + if (transaction.type === TransactionType.credit && amount !== undefined) { + balanceAdjustment = amount - (transaction.amount ?? 0); + } else if (transaction.type === TransactionType.debit && cost !== undefined) { + balanceAdjustment = (transaction.cost ?? 0) - cost; + } - if (date !== undefined) transaction.date = new Date(date); - if (amount !== undefined) transaction.amount = amount; - if (cost !== undefined) transaction.cost = cost; + // Check if new balance would be negative + if (account.balance + balanceAdjustment < 0) { + await session.abortTransaction(); + res.status(StatusCodes.BAD_REQUEST).json({ + error: 'Insufficient funds', + }); + return; + } - await transaction.save(); + if (date !== undefined) transaction.date = new Date(date); + if (amount !== undefined) transaction.amount = amount; + if (cost !== undefined) transaction.cost = cost; - if (balanceAdjustment !== 0) { - account.balance += balanceAdjustment; - await account.save(); - } + await transaction.save({ session }); + + if (balanceAdjustment !== 0) { + account.balance += balanceAdjustment; + await account.save({ session }); + } - res.json({ - transaction, - account: balanceAdjustment !== 0 ? account : undefined, - }); + await session.commitTransaction(); + res.json({ + transaction, + account: balanceAdjustment !== 0 ? account : undefined, + }); + } catch (error) { + await session.abortTransaction(); + throw error; + } finally { + session.endSession(); + } } diff --git a/src/routes/transaction/list/schema.ts b/src/routes/transaction/list/schema.ts index 419d459..d7e8491 100644 --- a/src/routes/transaction/list/schema.ts +++ b/src/routes/transaction/list/schema.ts @@ -1,3 +1,4 @@ +import mongoose from 'mongoose'; import { z } from 'zod'; export default { @@ -7,7 +8,12 @@ export default { limit: z.coerce.number().min(1).max(100).default(10), startDate: z.string().optional(), endDate: z.string().optional(), - accountId: z.string().uuid().optional(), + accountId: z + .string() + .refine(val => { + return mongoose.Types.ObjectId.isValid(val); + }) + .optional(), type: z.enum(['debit', 'credit']).optional(), sortBy: z.enum(['date', 'amount', 'cost']).default('date'), sortOrder: z.enum(['asc', 'desc']).default('desc'), diff --git a/swagger.json b/swagger.json index 2682108..7c116e2 100644 --- a/swagger.json +++ b/swagger.json @@ -252,6 +252,9 @@ "201": { "description": "Account created successfully." }, + "409": { + "description": "Conflict - Account with this name already exists." + }, "400": { "description": "Bad request - Invalid input data." },