From 126f2a706fe7a624c49816d9da3738114969af01 Mon Sep 17 00:00:00 2001 From: Kmario19 Date: Thu, 3 Apr 2025 13:28:32 -0500 Subject: [PATCH 01/10] feat: implement transaction handling with session management for credit and debit operations --- src/routes/account/credit/controller.test.ts | 51 ++++-- src/routes/account/credit/controller.ts | 45 +++-- src/routes/account/debit/controller.test.ts | 65 +++++--- src/routes/account/debit/controller.ts | 54 ++++-- .../transaction/create/controller.test.ts | 157 +++++++++++++++++- src/routes/transaction/create/controller.ts | 56 ++++++- .../transaction/edit/controller.test.ts | 15 ++ src/routes/transaction/edit/controller.ts | 94 ++++++----- 8 files changed, 415 insertions(+), 122 deletions(-) diff --git a/src/routes/account/credit/controller.test.ts b/src/routes/account/credit/controller.test.ts index 8ce122e..711aad9 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().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,6 +72,7 @@ describe('creditAccountController', () => { account: expect.objectContaining({ balance: newBalance, }), + transaction: mockTransaction, }) ); }); @@ -78,4 +89,10 @@ describe('creditAccountController', () => { }) ); }); + + it('should handle errors and abort transaction', async () => { + Account.findById = 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..96001f1 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); + 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..c862961 100644 --- a/src/routes/account/debit/controller.test.ts +++ b/src/routes/account/debit/controller.test.ts @@ -8,10 +8,19 @@ 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(() => { 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 +28,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().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,6 +72,7 @@ describe('debitAccountController', () => { account: expect.objectContaining({ balance: newBalance, }), + transaction: mockTransaction, }) ); }); @@ -80,18 +91,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 }; await debitAccountController(req as Request, res as Response); @@ -102,4 +110,17 @@ describe('debitAccountController', () => { }) ); }); + + 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(), + }); + + Account.findById = 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..b79e6f6 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); + 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/transaction/create/controller.test.ts b/src/routes/transaction/create/controller.test.ts index a6ce78f..d08b291 100644 --- a/src/routes/transaction/create/controller.test.ts +++ b/src/routes/transaction/create/controller.test.ts @@ -1,30 +1,171 @@ 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().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().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().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().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().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..83f86c8 100644 --- a/src/routes/transaction/create/controller.ts +++ b/src/routes/transaction/create/controller.ts @@ -1,9 +1,59 @@ 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); + 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/edit/controller.test.ts b/src/routes/transaction/edit/controller.test.ts index a9ebcfc..0f1a399 100644 --- a/src/routes/transaction/edit/controller.test.ts +++ b/src/routes/transaction/edit/controller.test.ts @@ -8,6 +8,15 @@ 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(() => { req = { params: { transactionId: 'txn123' }, @@ -257,4 +266,10 @@ describe('editTransactionController', () => { }) ); }); + + it('should handle errors and abort transaction', async () => { + Transaction.findById = 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..51a7225 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); + 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); + 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(); + } } From 3e86b23a0cf240a21707e46cb2c4a34f2b91e739 Mon Sep 17 00:00:00 2001 From: Kmario19 Date: Thu, 3 Apr 2025 14:55:30 -0500 Subject: [PATCH 02/10] feat: update account retrieval to use session in transaction controllers --- src/routes/account/credit/controller.test.ts | 8 ++-- src/routes/account/credit/controller.ts | 2 +- src/routes/account/debit/controller.test.ts | 18 ++++----- src/routes/account/debit/controller.ts | 2 +- .../transaction/create/controller.test.ts | 12 +++--- src/routes/transaction/create/controller.ts | 2 +- .../transaction/delete/controller.test.ts | 37 ++++++++----------- src/routes/transaction/delete/controller.ts | 4 +- .../transaction/edit/controller.test.ts | 31 +++++++++------- src/routes/transaction/edit/controller.ts | 4 +- 10 files changed, 59 insertions(+), 61 deletions(-) diff --git a/src/routes/account/credit/controller.test.ts b/src/routes/account/credit/controller.test.ts index 711aad9..474d16a 100644 --- a/src/routes/account/credit/controller.test.ts +++ b/src/routes/account/credit/controller.test.ts @@ -50,7 +50,7 @@ describe('creditAccountController', () => { amount: creditAmount, }; - Account.findById = jest.fn().mockResolvedValueOnce(mockAccount); + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); Transaction.create = jest.fn().mockResolvedValueOnce(mockTransaction); await creditAccountController(req as Request, res as Response); @@ -78,7 +78,7 @@ describe('creditAccountController', () => { }); 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); @@ -91,7 +91,9 @@ describe('creditAccountController', () => { }); it('should handle errors and abort transaction', async () => { - Account.findById = jest.fn().mockRejectedValueOnce(new Error('Database error')); + 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 96001f1..077f687 100644 --- a/src/routes/account/credit/controller.ts +++ b/src/routes/account/credit/controller.ts @@ -15,7 +15,7 @@ export default async (req: Request, res: Response) => { session.startTransaction(); try { - const account = await Account.findById(accountId); + const account = await Account.findById(accountId).session(session); if (!account) { await session.abortTransaction(); res.status(StatusCodes.NOT_FOUND).json({ error: 'Account not found' }); diff --git a/src/routes/account/debit/controller.test.ts b/src/routes/account/debit/controller.test.ts index c862961..5f5bc53 100644 --- a/src/routes/account/debit/controller.test.ts +++ b/src/routes/account/debit/controller.test.ts @@ -18,6 +18,7 @@ describe('debitAccountController', () => { }); beforeEach(() => { + jest.clearAllMocks(); req = { params: { accountId: 'acc123' }, body: { cost: 200, date: '2025-04-02' }, @@ -50,7 +51,7 @@ describe('debitAccountController', () => { cost: debitCost, }; - Account.findById = jest.fn().mockResolvedValueOnce(mockAccount); + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); Transaction.create = jest.fn().mockResolvedValueOnce(mockTransaction); await debitAccountController(req as Request, res as Response); @@ -78,7 +79,7 @@ describe('debitAccountController', () => { }); 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); @@ -99,7 +100,7 @@ describe('debitAccountController', () => { balance: currentBalance, }; - Account.findById = jest.fn().mockResolvedValueOnce(mockAccount); + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); await debitAccountController(req as Request, res as Response); @@ -112,14 +113,9 @@ describe('debitAccountController', () => { }); 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(), - }); - - Account.findById = jest.fn().mockRejectedValueOnce(new Error('Database error')); + 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 b79e6f6..ed91dde 100644 --- a/src/routes/account/debit/controller.ts +++ b/src/routes/account/debit/controller.ts @@ -11,7 +11,7 @@ export default async (req: Request, res: Response) => { session.startTransaction(); try { - const account = await Account.findById(accountId); + const account = await Account.findById(accountId).session(session); if (!account) { await session.abortTransaction(); res.status(StatusCodes.NOT_FOUND).json({ error: 'Account not found' }); diff --git a/src/routes/transaction/create/controller.test.ts b/src/routes/transaction/create/controller.test.ts index d08b291..2ec3fca 100644 --- a/src/routes/transaction/create/controller.test.ts +++ b/src/routes/transaction/create/controller.test.ts @@ -49,7 +49,7 @@ describe('createTransactionController', () => { amount: creditAmount, }; - Account.findById = jest.fn().mockResolvedValueOnce(mockAccount); + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); Transaction.create = jest.fn().mockResolvedValueOnce(mockTransaction); await createTransactionController(req as Request, res as Response); @@ -100,7 +100,7 @@ describe('createTransactionController', () => { cost: debitCost, }; - Account.findById = jest.fn().mockResolvedValueOnce(mockAccount); + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); Transaction.create = jest.fn().mockResolvedValueOnce(mockTransaction); await createTransactionController(req as Request, res as Response); @@ -128,7 +128,7 @@ describe('createTransactionController', () => { }); 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 createTransactionController(req as Request, res as Response); @@ -151,7 +151,7 @@ describe('createTransactionController', () => { balance: currentBalance, }; - Account.findById = jest.fn().mockResolvedValueOnce(mockAccount); + Account.findById = jest.fn().mockReturnValueOnce({ session: jest.fn().mockResolvedValueOnce(mockAccount) }); await createTransactionController(req as Request, res as Response); @@ -164,7 +164,9 @@ describe('createTransactionController', () => { }); it('should handle errors and abort transaction', async () => { - Account.findById = jest.fn().mockRejectedValueOnce(new Error('Database error')); + 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 83f86c8..cf40dbb 100644 --- a/src/routes/transaction/create/controller.ts +++ b/src/routes/transaction/create/controller.ts @@ -13,7 +13,7 @@ export default async (req: Request, res: Response) => { let account: IAccount | null = null; if (accountId) { - account = await Account.findById(accountId); + account = await Account.findById(accountId).session(session); if (!account) { await session.abortTransaction(); res.status(StatusCodes.NOT_FOUND).json({ error: 'Account not found' }); 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 0f1a399..a571e3d 100644 --- a/src/routes/transaction/edit/controller.test.ts +++ b/src/routes/transaction/edit/controller.test.ts @@ -18,6 +18,7 @@ describe('editTransactionController', () => { }); beforeEach(() => { + jest.clearAllMocks(); req = { params: { transactionId: 'txn123' }, body: { date: '2025-04-02', amount: 500 }, @@ -59,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); @@ -109,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); @@ -124,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); @@ -144,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); @@ -176,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); @@ -207,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); @@ -249,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); @@ -268,7 +269,9 @@ describe('editTransactionController', () => { }); it('should handle errors and abort transaction', async () => { - Transaction.findById = jest.fn().mockRejectedValueOnce(new Error('Database error')); + 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 51a7225..2d70737 100644 --- a/src/routes/transaction/edit/controller.ts +++ b/src/routes/transaction/edit/controller.ts @@ -11,7 +11,7 @@ export default async function editTransactionController(req: Request, res: Respo session.startTransaction(); try { - 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 editTransactionController(req: Request, res: Respo 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({ From 8a476f735f984cd99dbe9ce32dca9af355d78177 Mon Sep 17 00:00:00 2001 From: Kmario19 Date: Thu, 3 Apr 2025 15:39:49 -0500 Subject: [PATCH 03/10] feat: add account creation conflict handling for duplicate names --- src/routes/account/create/controller.test.ts | 18 +++++++++++++++++- src/routes/account/create/controller.ts | 8 ++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) 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); From fb029ce7117f91be6914b75f573396b8eba89241 Mon Sep 17 00:00:00 2001 From: Kmario19 Date: Thu, 3 Apr 2025 15:40:08 -0500 Subject: [PATCH 04/10] feat: refactor delete account controller to use accountId in request parameters --- src/routes/account/delete/controller.test.ts | 12 ++++++------ src/routes/account/delete/controller.ts | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) 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', From fff6269358815bf0951c4f2cd4424f728b5b16f9 Mon Sep 17 00:00:00 2001 From: Kmario19 Date: Thu, 3 Apr 2025 15:40:28 -0500 Subject: [PATCH 05/10] feat: update transaction schema to swap 'amount' and 'cost' fields for debit and credit transactions --- src/routes/transaction/create/schema.test.ts | 12 ++++++------ src/routes/transaction/create/schema.ts | 16 +++++++++++----- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/routes/transaction/create/schema.test.ts b/src/routes/transaction/create/schema.test.ts index f8d1c21..3ab68ff 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', () => { 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`, }); } }), From aba977a7054a95e9ca9084707df61b983d0c191a Mon Sep 17 00:00:00 2001 From: Kmario19 Date: Thu, 3 Apr 2025 15:40:41 -0500 Subject: [PATCH 06/10] feat: update accountId validation to use mongoose ObjectId format in transaction list schema --- src/routes/transaction/list/schema.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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'), From 3f5b64c11812c3027a20308483332d30e62371a0 Mon Sep 17 00:00:00 2001 From: Kmario19 Date: Thu, 3 Apr 2025 15:40:49 -0500 Subject: [PATCH 07/10] feat: add conflict response for duplicate account names in Swagger documentation --- swagger.json | 3 +++ 1 file changed, 3 insertions(+) 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." }, From 80431b3cf3b245372472562b0f0ef5233597e516 Mon Sep 17 00:00:00 2001 From: Kmario19 Date: Thu, 3 Apr 2025 15:42:10 -0500 Subject: [PATCH 08/10] feat: update error message for invalid accountId to reflect MongoDB document id format --- src/routes/transaction/create/schema.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/transaction/create/schema.test.ts b/src/routes/transaction/create/schema.test.ts index 3ab68ff..7b52093 100644 --- a/src/routes/transaction/create/schema.test.ts +++ b/src/routes/transaction/create/schema.test.ts @@ -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); }); From 086f6fc09c1c7ec421fe41491b137928e54cca90 Mon Sep 17 00:00:00 2001 From: Kmario19 Date: Thu, 3 Apr 2025 15:49:13 -0500 Subject: [PATCH 09/10] feat: update editAccountController to use accountId in request parameters and handle existing account name conflict --- src/routes/account/edit/controller.test.ts | 10 +++-- src/routes/account/edit/controller.ts | 46 +++++++++++----------- 2 files changed, 29 insertions(+), 27 deletions(-) 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, + }); } From 93309dc95d92069a107ce3bf0d2d2be8bf1fb5a4 Mon Sep 17 00:00:00 2001 From: Kmario19 Date: Thu, 3 Apr 2025 15:55:16 -0500 Subject: [PATCH 10/10] feat: refactor createTransactionController to simplify transaction creation and improve code readability --- .../transaction/create/controller.test.ts | 24 ++++++++----------- src/routes/transaction/create/controller.ts | 16 ++++++------- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/routes/transaction/create/controller.test.ts b/src/routes/transaction/create/controller.test.ts index 2ec3fca..1763763 100644 --- a/src/routes/transaction/create/controller.test.ts +++ b/src/routes/transaction/create/controller.test.ts @@ -56,13 +56,11 @@ describe('createTransactionController', () => { 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); @@ -107,13 +105,11 @@ describe('createTransactionController', () => { 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); diff --git a/src/routes/transaction/create/controller.ts b/src/routes/transaction/create/controller.ts index cf40dbb..a72e14e 100644 --- a/src/routes/transaction/create/controller.ts +++ b/src/routes/transaction/create/controller.ts @@ -36,15 +36,13 @@ export default async (req: Request, res: Response) => { } const transaction = await Transaction.create( - [ - { - account: accountId, - amount, - cost, - date, - type, - }, - ], + { + account: accountId, + amount, + cost, + date, + type, + }, { session } );