From 0970134417b18a6a072ab1cec8facb900772237f Mon Sep 17 00:00:00 2001 From: lyannne Date: Wed, 18 Mar 2026 23:10:49 -0400 Subject: [PATCH 1/8] default values get and patch --- .../default-values.controller.ts | 77 ++++++++++++ .../default-values/default-values.module.ts | 9 ++ .../default-values/default-values.service.ts | 113 ++++++++++++++++++ .../types/default-values.types.ts | 10 ++ 4 files changed, 209 insertions(+) create mode 100644 backend/src/default-values/default-values.controller.ts create mode 100644 backend/src/default-values/default-values.module.ts create mode 100644 backend/src/default-values/default-values.service.ts create mode 100644 backend/src/default-values/types/default-values.types.ts diff --git a/backend/src/default-values/default-values.controller.ts b/backend/src/default-values/default-values.controller.ts new file mode 100644 index 00000000..b988c605 --- /dev/null +++ b/backend/src/default-values/default-values.controller.ts @@ -0,0 +1,77 @@ +import { Body, Controller, Get, Patch, Logger } from '@nestjs/common'; +import { ApiBody, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { DefaultValuesService } from './default-values.service'; +import { + DefaultValuesResponse, + UpdateDefaultValueBody, +} from './types/default-values.types'; +import { UseGuards } from '@nestjs/common'; +import { VerifyAdminRoleGuard } from '../guards/auth.guard'; +import { ApiBearerAuth } from '@nestjs/swagger'; + + +@ApiTags('default-values') +@Controller('default-values') +export class DefaultValuesController { + private readonly logger = new Logger(DefaultValuesController.name); + + constructor(private readonly defaultValuesService: DefaultValuesService) {} + + /** + * Gets the default values for starting cash, benefits increase, and salary increase for cash flow + * @returns DefaultValuesResponse containing the default values + */ + @Get() + @ApiResponse({ + status: 200, + description: 'Default values retrieved successfully', + }) + @ApiResponse({ + status: 404, + description: 'Default values not found', + }) + @ApiResponse({ + status: 500, + description: 'Internal Server Error', + }) + async getDefaultValues(): Promise { + this.logger.log('GET /default-values - Retrieving default values'); + return await this.defaultValuesService.getDefaultValues(); + } + + /** + * Edits a default value for cash flow based on the provided key and value + * @param body - UpdateDefaultValueBody containing the key of the default value to update and the new value + * @returns new DefaultValuesResponse with the updated default values + */ + @Patch() + @ApiBody({ schema: { + type: 'object', + properties: { + key: { type: 'string', enum: ['startingCash', 'benefitsIncrease', 'salaryIncrease'] }, + value: { type: 'number' } + } + }}) + @ApiResponse({ + status: 200, + description: 'Default value updated successfully', + }) + @ApiResponse({ + status: 400, + description: 'Bad Request - Invalid key or value', + }) + @ApiResponse({ + status: 500, + description: 'Internal Server Error', + }) + @UseGuards(VerifyAdminRoleGuard) + @ApiBearerAuth() + async updateDefaultValue( + @Body() body: UpdateDefaultValueBody, + ): Promise { + this.logger.log(`PATCH /default-values - Updating default value for key: ${body.key}`); + const updatedValues = await this.defaultValuesService.updateDefaultValue(body.key, body.value); + this.logger.log(`PATCH /default-values - Successfully updated default value for key: ${body.key}`); + return updatedValues; + } +} diff --git a/backend/src/default-values/default-values.module.ts b/backend/src/default-values/default-values.module.ts new file mode 100644 index 00000000..69ba001c --- /dev/null +++ b/backend/src/default-values/default-values.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { DefaultValuesController } from './default-values.controller'; +import { DefaultValuesService } from './default-values.service'; + +@Module({ + controllers: [DefaultValuesController], + providers: [DefaultValuesService], +}) +export class DefaultValuesModule {} diff --git a/backend/src/default-values/default-values.service.ts b/backend/src/default-values/default-values.service.ts new file mode 100644 index 00000000..037a01d5 --- /dev/null +++ b/backend/src/default-values/default-values.service.ts @@ -0,0 +1,113 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, +} from '@nestjs/common'; +import * as AWS from 'aws-sdk'; +import { DefaultValuesResponse } from './types/default-values.types'; + +@Injectable() +export class DefaultValuesService { + private readonly logger = new Logger(DefaultValuesService.name); + private dynamoDb = new AWS.DynamoDB.DocumentClient(); + + async getDefaultValues(): Promise { + const tableName = process.env.CASHFLOW_DEFAULT_VALUE_TABLE_NAME; + + if (!tableName) { + this.logger.error('CASHFLOW_DEFAULT_VALUE_TABLE_NAME is not defined'); + throw new InternalServerErrorException('Server configuration error'); + } + + try { + const result = await this.dynamoDb + .scan({ + TableName: tableName, + }) + .promise(); + + const items = (result.Items ?? []) as { name: string; value: number }[]; + + const startingCash = items.find((item) => item.name === 'startingCash')?.value || -1; + const benefitsIncrease = items.find((item) => item.name === 'benefitsIncrease')?.value || -1; + const salaryIncrease = items.find((item) => item.name === 'salaryIncrease')?.value || -1; + + if ( + !Number.isFinite(startingCash) || + !Number.isFinite(benefitsIncrease) || + !Number.isFinite(salaryIncrease) + ) { + this.logger.error('Default values table is missing required fields'); + throw new NotFoundException('Default values not found'); + } + + const defaultValues: DefaultValuesResponse = { + startingCash, + benefitsIncrease, + salaryIncrease, + }; + + return defaultValues; + + } catch (error) { + if ( + error instanceof InternalServerErrorException || + error instanceof NotFoundException + ) { + throw error; + } + + this.logger.error('Failed to retrieve default values', error as Error); + throw new InternalServerErrorException('Failed to retrieve default values'); + } + } + + async updateDefaultValue( + key: string, + value: number, + ): Promise { + const tableName = process.env.CASHFLOW_DEFAULT_VALUE_TABLE_NAME; + + if (!tableName) { + this.logger.error('CASHFLOW_DEFAULT_VALUE_TABLE_NAME is not defined'); + throw new InternalServerErrorException('Server configuration error'); + } + + if (!Number.isFinite(value)) { + throw new BadRequestException('Value must be a valid number'); + } + + if (!(key === 'startingCash' || key === 'benefitsIncrease' || key === 'salaryIncrease')) { + throw new BadRequestException( + 'Default value must be one of: startingCash, benefitsIncrease, salaryIncrease', + ); + } + + try { + await this.dynamoDb + .put({ + TableName: tableName, + Item: { + name: key, + value, + }, + }) + .promise(); + + return await this.getDefaultValues(); + } catch (error) { + if ( + error instanceof BadRequestException || + error instanceof NotFoundException || + error instanceof InternalServerErrorException + ) { + throw error; + } + + this.logger.error(`Failed to update default value '${key}'`, error as Error); + throw new InternalServerErrorException('Failed to update default value'); + } + } +} \ No newline at end of file diff --git a/backend/src/default-values/types/default-values.types.ts b/backend/src/default-values/types/default-values.types.ts new file mode 100644 index 00000000..93b3d485 --- /dev/null +++ b/backend/src/default-values/types/default-values.types.ts @@ -0,0 +1,10 @@ +export interface DefaultValuesResponse { + startingCash: number; + benefitsIncrease: number; + salaryIncrease: number; +} + +export interface UpdateDefaultValueBody { + key: string; + value: number; +} From d8de1d4d08f16853a0116f2cc723a353201e6fd9 Mon Sep 17 00:00:00 2001 From: lyannne Date: Thu, 19 Mar 2026 16:14:41 -0400 Subject: [PATCH 2/8] updated test for default values --- .../__test__/default-values.service.spec.ts | 303 ++++++++++++++++++ .../default-values/default-values.service.ts | 14 +- 2 files changed, 308 insertions(+), 9 deletions(-) create mode 100644 backend/src/default-values/__test__/default-values.service.spec.ts diff --git a/backend/src/default-values/__test__/default-values.service.spec.ts b/backend/src/default-values/__test__/default-values.service.spec.ts new file mode 100644 index 00000000..fa7947de --- /dev/null +++ b/backend/src/default-values/__test__/default-values.service.spec.ts @@ -0,0 +1,303 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DefaultValuesService } from '../default-values.service'; +import { + BadRequestException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const mockDefaultValues = [ + { name: 'startingCash', value: 10000 }, + { name: 'benefitsIncrease', value: 3.5 }, + { name: 'salaryIncrease', value: 2.0 }, +]; + +const mockPromise = vi.fn(); +const mockScan = vi.fn(); +const mockPut = vi.fn(); + +const mockDocumentClient = { + scan: mockScan, + put: mockPut, +}; + +vi.mock('aws-sdk', () => ({ + DynamoDB: { + DocumentClient: vi.fn(function () { + return mockDocumentClient; + }), + }, +})); + +describe('DefaultValuesService', () => { + let service: DefaultValuesService; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Setup DynamoDB mocks to return chainable objects with .promise() + mockScan.mockReturnValue({ promise: mockPromise }); + mockPut.mockReturnValue({ promise: mockPromise }); + + // Set the environment variable for the table name + process.env.CASHFLOW_DEFAULT_VALUE_TABLE_NAME = 'DefaultValues'; + + const module: TestingModule = await Test.createTestingModule({ + providers: [DefaultValuesService], + }).compile(); + + service = module.get(DefaultValuesService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getDefaultValues()', () => { + it('should return default values when all three values exist in the database', async () => { + mockPromise.mockResolvedValue({ Items: mockDefaultValues }); + + const result = await service.getDefaultValues(); + + expect(result).toEqual({ + startingCash: 10000, + benefitsIncrease: 3.5, + salaryIncrease: 2.0, + }); + expect(mockDocumentClient.scan).toHaveBeenCalledWith({ + TableName: 'DefaultValues', + }); + }); + + it('should throw NotFoundException if startingCash is missing', async () => { + const incompleteValues = [ + { name: 'benefitsIncrease', value: 3.5 }, + { name: 'salaryIncrease', value: 2.0 }, + ]; + mockPromise.mockResolvedValue({ Items: incompleteValues }); + + await expect(service.getDefaultValues()).rejects.toThrow( + NotFoundException, + ); + await expect(service.getDefaultValues()).rejects.toThrow( + 'Default values not found', + ); + }); + + it('should throw NotFoundException if benefitsIncrease is missing', async () => { + const incompleteValues = [ + { name: 'startingCash', value: 10000 }, + { name: 'salaryIncrease', value: 2.0 }, + ]; + mockPromise.mockResolvedValue({ Items: incompleteValues }); + + await expect(service.getDefaultValues()).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw NotFoundException if salaryIncrease is missing', async () => { + const incompleteValues = [ + { name: 'startingCash', value: 10000 }, + { name: 'benefitsIncrease', value: 3.5 }, + ]; + mockPromise.mockResolvedValue({ Items: incompleteValues }); + + await expect(service.getDefaultValues()).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw NotFoundException if all values are missing', async () => { + mockPromise.mockResolvedValue({ Items: [] }); + + await expect(service.getDefaultValues()).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw InternalServerErrorException if table name is not configured', async () => { + delete process.env.CASHFLOW_DEFAULT_VALUE_TABLE_NAME; + + await expect(service.getDefaultValues()).rejects.toThrow( + InternalServerErrorException, + ); + await expect(service.getDefaultValues()).rejects.toThrow( + 'Server configuration error', + ); + + // Restore for other tests + process.env.CASHFLOW_DEFAULT_VALUE_TABLE_NAME = 'DefaultValues'; + }); + + it('should throw InternalServerErrorException on general DynamoDB error', async () => { + const dbError = new Error('DynamoDB connection failed'); + mockPromise.mockRejectedValue(dbError); + + await expect(service.getDefaultValues()).rejects.toThrow( + InternalServerErrorException, + ); + await expect(service.getDefaultValues()).rejects.toThrow( + 'Failed to retrieve default values', + ); + }); + + it('should allow negative values', async () => { + const negativeValues = [ + { name: 'startingCash', value: -5000 }, + { name: 'benefitsIncrease', value: -1.5 }, + { name: 'salaryIncrease', value: -0.5 }, + ]; + mockPromise.mockResolvedValue({ Items: negativeValues }); + + const result = await service.getDefaultValues(); + + expect(result).toEqual({ + startingCash: -5000, + benefitsIncrease: -1.5, + salaryIncrease: -0.5, + }); + }); + }); + + describe('updateDefaultValue()', () => { + it('should successfully update startingCash', async () => { + mockPromise.mockResolvedValue({ Items: mockDefaultValues }); + + const result = await service.updateDefaultValue('startingCash', 15000); + + expect(result).toEqual({ + startingCash: 10000, + benefitsIncrease: 3.5, + salaryIncrease: 2.0, + }); + expect(mockDocumentClient.put).toHaveBeenCalledWith({ + TableName: 'DefaultValues', + Item: { + name: 'startingCash', + value: 15000, + }, + }); + }); + + it('should successfully update benefitsIncrease', async () => { + mockPromise.mockResolvedValue({ Items: mockDefaultValues }); + + const result = await service.updateDefaultValue('benefitsIncrease', 5.0); + + expect(result).toEqual({ + startingCash: 10000, + benefitsIncrease: 3.5, + salaryIncrease: 2.0, + }); + expect(mockDocumentClient.put).toHaveBeenCalledWith({ + TableName: 'DefaultValues', + Item: { + name: 'benefitsIncrease', + value: 5.0, + }, + }); + }); + + it('should successfully update salaryIncrease', async () => { + mockPromise.mockResolvedValue({ Items: mockDefaultValues }); + + const result = await service.updateDefaultValue('salaryIncrease', 3.0); + + expect(result).toEqual({ + startingCash: 10000, + benefitsIncrease: 3.5, + salaryIncrease: 2.0, + }); + expect(mockDocumentClient.put).toHaveBeenCalledWith({ + TableName: 'DefaultValues', + Item: { + name: 'salaryIncrease', + value: 3.0, + }, + }); + }); + + it('should throw BadRequestException for non-numeric values', async () => { + await expect( + service.updateDefaultValue('startingCash', Number.NaN), + ).rejects.toThrow(BadRequestException); + await expect( + service.updateDefaultValue('startingCash', Number.NaN), + ).rejects.toThrow('Value must be a valid number'); + }); + + it('should throw BadRequestException for Infinity', async () => { + await expect( + service.updateDefaultValue('startingCash', Infinity), + ).rejects.toThrow(BadRequestException); + await expect( + service.updateDefaultValue('startingCash', Infinity), + ).rejects.toThrow('Value must be a valid number'); + }); + + it('should throw BadRequestException for invalid keys', async () => { + await expect( + service.updateDefaultValue('invalidKey' as any, 100), + ).rejects.toThrow(BadRequestException); + await expect( + service.updateDefaultValue('invalidKey' as any, 100), + ).rejects.toThrow('Default value must be one of'); + }); + + it('should throw InternalServerErrorException if table name is not configured', async () => { + delete process.env.CASHFLOW_DEFAULT_VALUE_TABLE_NAME; + + await expect( + service.updateDefaultValue('startingCash', 5000), + ).rejects.toThrow(InternalServerErrorException); + await expect( + service.updateDefaultValue('startingCash', 5000), + ).rejects.toThrow('Server configuration error'); + + // Restore for other tests + process.env.CASHFLOW_DEFAULT_VALUE_TABLE_NAME = 'DefaultValues'; + }); + + it('should successfully update with negative value', async () => { + mockPromise.mockResolvedValue({ Items: mockDefaultValues }); + + const result = await service.updateDefaultValue('startingCash', -1000); + + expect(result).toEqual({ + startingCash: 10000, + benefitsIncrease: 3.5, + salaryIncrease: 2.0, + }); + expect(mockDocumentClient.put).toHaveBeenCalledWith({ + TableName: 'DefaultValues', + Item: { + name: 'startingCash', + value: -1000, + }, + }); + }); + + it('should throw InternalServerErrorException on DynamoDB put error', async () => { + const dbError = new Error('DynamoDB write failed'); + mockPut.mockReturnValue({ promise: vi.fn().mockRejectedValue(dbError) }); + + await expect( + service.updateDefaultValue('startingCash', 5000), + ).rejects.toThrow(InternalServerErrorException); + await expect( + service.updateDefaultValue('startingCash', 5000), + ).rejects.toThrow('Failed to update default value'); + }); + + it('should throw InternalServerErrorException if getDefaultValues fails after update', async () => { + const dbError = new Error('DynamoDB read failed'); + mockPromise.mockRejectedValue(dbError); + + await expect( + service.updateDefaultValue('startingCash', 5000), + ).rejects.toThrow(InternalServerErrorException); + }); + }); +}); diff --git a/backend/src/default-values/default-values.service.ts b/backend/src/default-values/default-values.service.ts index 037a01d5..a5c6d54a 100644 --- a/backend/src/default-values/default-values.service.ts +++ b/backend/src/default-values/default-values.service.ts @@ -28,17 +28,13 @@ export class DefaultValuesService { }) .promise(); - const items = (result.Items ?? []) as { name: string; value: number }[]; + const items = (result.Items ?? []); - const startingCash = items.find((item) => item.name === 'startingCash')?.value || -1; - const benefitsIncrease = items.find((item) => item.name === 'benefitsIncrease')?.value || -1; - const salaryIncrease = items.find((item) => item.name === 'salaryIncrease')?.value || -1; + const startingCash = items.find((item) => item.name === 'startingCash')?.value || null; + const benefitsIncrease = items.find((item) => item.name === 'benefitsIncrease')?.value || null; + const salaryIncrease = items.find((item) => item.name === 'salaryIncrease')?.value || null; - if ( - !Number.isFinite(startingCash) || - !Number.isFinite(benefitsIncrease) || - !Number.isFinite(salaryIncrease) - ) { + if (startingCash === null || benefitsIncrease === null || salaryIncrease === null) { this.logger.error('Default values table is missing required fields'); throw new NotFoundException('Default values not found'); } From a232dc644d74c7aac4b65c7e7dc296c035e3fccc Mon Sep 17 00:00:00 2001 From: lyannne Date: Thu, 19 Mar 2026 19:11:51 -0400 Subject: [PATCH 3/8] cost routes and tests --- backend/src/app.module.ts | 4 +- .../src/cost/__test__/cost.service.spec.ts | 601 ++++++++++++++++++ backend/src/cost/cost.controller.ts | 153 +++++ backend/src/cost/cost.module.ts | 9 + backend/src/cost/cost.service.ts | 372 +++++++++++ middle-layer/types/CashflowCost.ts | 8 +- 6 files changed, 1141 insertions(+), 6 deletions(-) create mode 100644 backend/src/cost/__test__/cost.service.spec.ts create mode 100644 backend/src/cost/cost.controller.ts create mode 100644 backend/src/cost/cost.module.ts create mode 100644 backend/src/cost/cost.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 25449e13..75d769ae 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -4,8 +4,10 @@ import { UserModule } from './user/user.module'; import { GrantModule } from './grant/grant.module'; import { NotificationsModule } from './notifications/notification.module'; import { CashflowModule } from './cashflow/cashflow.module'; +import { CostModule } from './cost/cost.module'; +import { DefaultValuesModule } from './default-values/default-values.module'; @Module({ - imports: [AuthModule, UserModule, GrantModule, NotificationsModule,CashflowModule], + imports: [AuthModule, UserModule, GrantModule, NotificationsModule, CashflowModule, CostModule, DefaultValuesModule], }) export class AppModule {} \ No newline at end of file diff --git a/backend/src/cost/__test__/cost.service.spec.ts b/backend/src/cost/__test__/cost.service.spec.ts new file mode 100644 index 00000000..1b3370fd --- /dev/null +++ b/backend/src/cost/__test__/cost.service.spec.ts @@ -0,0 +1,601 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + BadRequestException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { CostService } from '../cost.service'; +import { CostType } from '../../../../middle-layer/types/CostType'; + +const mockScanPromise = vi.fn(); +const mockGetPromise = vi.fn(); +const mockPutPromise = vi.fn(); +const mockUpdatePromise = vi.fn(); +const mockDeletePromise = vi.fn(); +const mockTransactWritePromise = vi.fn(); + +const mockScan = vi.fn(() => ({ promise: mockScanPromise })); +const mockGet = vi.fn(() => ({ promise: mockGetPromise })); +const mockPut = vi.fn(() => ({ promise: mockPutPromise })); +const mockUpdate = vi.fn(() => ({ promise: mockUpdatePromise })); +const mockDelete = vi.fn(() => ({ promise: mockDeletePromise })); +const mockTransactWrite = vi.fn(() => ({ promise: mockTransactWritePromise })); + +const mockDocumentClient = { + scan: mockScan, + get: mockGet, + put: mockPut, + update: mockUpdate, + delete: mockDelete, + transactWrite: mockTransactWrite, +}; + +vi.mock('aws-sdk', () => ({ + DynamoDB: { + DocumentClient: vi.fn(function () { + return mockDocumentClient; + }), + }, +})); + +describe('CostService', () => { + let service: CostService; + + beforeEach(async () => { + vi.clearAllMocks(); + + process.env.CASHFLOW_COST_TABLE_NAME = 'Costs'; + + const module: TestingModule = await Test.createTestingModule({ + providers: [CostService], + }).compile(); + + service = module.get(CostService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getAllCosts()', () => { + it('returns all costs', async () => { + const items = [ + { name: 'Food', amount: 200, type: CostType.MealsFood }, + { name: 'Rent', amount: 500, type: CostType.RentAndSpace }, + ]; + mockScanPromise.mockResolvedValue({ Items: items }); + + const result = await service.getAllCosts(); + + expect(result).toEqual(items); + expect(mockScan).toHaveBeenCalledWith({ TableName: 'Costs' }); + }); + + it('returns empty list when no costs exist', async () => { + mockScanPromise.mockResolvedValue({ Items: [] }); + + const result = await service.getAllCosts(); + + expect(result).toEqual([]); + }); + + it('throws InternalServerErrorException when table name is missing', async () => { + delete process.env.CASHFLOW_COST_TABLE_NAME; + + await expect(service.getAllCosts()).rejects.toThrow( + InternalServerErrorException, + ); + await expect(service.getAllCosts()).rejects.toThrow( + 'Server configuration error', + ); + }); + + it('throws InternalServerErrorException on DynamoDB error', async () => { + mockScanPromise.mockRejectedValue(new Error('scan failed')); + + await expect(service.getAllCosts()).rejects.toThrow( + InternalServerErrorException, + ); + await expect(service.getAllCosts()).rejects.toThrow( + 'Failed to retrieve costs', + ); + }); + }); + + describe('getCostByName()', () => { + it('returns a cost by name and trims whitespace', async () => { + const item = { name: 'Food', amount: 200, type: CostType.MealsFood }; + mockGetPromise.mockResolvedValue({ Item: item }); + + const result = await service.getCostByName(' Food '); + + expect(result).toEqual(item); + expect(mockGet).toHaveBeenCalledWith({ + TableName: 'Costs', + Key: { name: 'Food' }, + }); + }); + + it('throws BadRequestException for empty name', async () => { + await expect(service.getCostByName(' ')).rejects.toThrow( + BadRequestException, + ); + await expect(service.getCostByName(' ')).rejects.toThrow( + 'name must be a non-empty string', + ); + }); + + it('throws NotFoundException when cost does not exist', async () => { + mockGetPromise.mockResolvedValue({ Item: undefined }); + + await expect(service.getCostByName('Food')).rejects.toThrow( + NotFoundException, + ); + await expect(service.getCostByName('Food')).rejects.toThrow( + 'Cost with name Food not found', + ); + }); + + it('throws InternalServerErrorException when table name is missing', async () => { + delete process.env.CASHFLOW_COST_TABLE_NAME; + + await expect(service.getCostByName('Food')).rejects.toThrow( + InternalServerErrorException, + ); + await expect(service.getCostByName('Food')).rejects.toThrow( + 'Server configuration error', + ); + }); + + it('throws InternalServerErrorException on DynamoDB error', async () => { + mockGetPromise.mockRejectedValue(new Error('get failed')); + + await expect(service.getCostByName('Food')).rejects.toThrow( + InternalServerErrorException, + ); + await expect(service.getCostByName('Food')).rejects.toThrow( + 'Failed to retrieve cost Food', + ); + }); + }); + + describe('getCostsByType()', () => { + it('returns costs filtered by type', async () => { + const items = [{ name: 'Food', amount: 200, type: CostType.MealsFood }]; + mockScanPromise.mockResolvedValue({ Items: items }); + + const result = await service.getCostsByType(CostType.MealsFood); + + expect(result).toEqual(items); + expect(mockScan).toHaveBeenCalledWith({ + TableName: 'Costs', + FilterExpression: '#type = :type', + ExpressionAttributeNames: { + '#type': 'type', + }, + ExpressionAttributeValues: { + ':type': CostType.MealsFood, + }, + }); + }); + + it('throws InternalServerErrorException when table name is missing', async () => { + delete process.env.CASHFLOW_COST_TABLE_NAME; + + await expect(service.getCostsByType(CostType.MealsFood)).rejects.toThrow( + InternalServerErrorException, + ); + }); + + it('throws InternalServerErrorException on DynamoDB error', async () => { + mockScanPromise.mockRejectedValue(new Error('scan failed')); + + await expect(service.getCostsByType(CostType.MealsFood)).rejects.toThrow( + InternalServerErrorException, + ); + await expect(service.getCostsByType(CostType.MealsFood)).rejects.toThrow( + 'Failed to retrieve costs with type Meals and Food', + ); + }); + }); + + describe('createCost()', () => { + it('creates a cost and trims the name', async () => { + mockPutPromise.mockResolvedValue({}); + const payload = { + name: ' Food ', + amount: 200, + type: CostType.MealsFood, + }; + + const result = await service.createCost(payload); + + expect(result).toEqual({ + name: 'Food', + amount: 200, + type: CostType.MealsFood, + }); + expect(mockPut).toHaveBeenCalledWith({ + TableName: 'Costs', + Item: { + name: 'Food', + amount: 200, + type: CostType.MealsFood, + }, + ConditionExpression: 'attribute_not_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, + }); + }); + + it('throws BadRequestException for invalid amount', async () => { + await expect( + service.createCost({ + name: 'Food', + amount: 0, + type: CostType.MealsFood, + }), + ).rejects.toThrow(BadRequestException); + await expect( + service.createCost({ + name: 'Food', + amount: 0, + type: CostType.MealsFood, + }), + ).rejects.toThrow('amount must be a finite positive number'); + }); + + it('throws BadRequestException for invalid type', async () => { + await expect( + service.createCost({ + name: 'Food', + amount: 100, + type: 'INVALID' as unknown as CostType, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException for invalid name', async () => { + await expect( + service.createCost({ + name: ' ', + amount: 100, + type: CostType.MealsFood, + }), + ).rejects.toThrow(BadRequestException); + await expect( + service.createCost({ + name: ' ', + amount: 100, + type: CostType.MealsFood, + }), + ).rejects.toThrow('name must be a non-empty string'); + }); + + it('throws InternalServerErrorException when table name is missing', async () => { + delete process.env.CASHFLOW_COST_TABLE_NAME; + + await expect( + service.createCost({ + name: 'Food', + amount: 100, + type: CostType.MealsFood, + }), + ).rejects.toThrow(InternalServerErrorException); + }); + + it('throws InternalServerErrorException on DynamoDB error', async () => { + mockPutPromise.mockRejectedValue(new Error('put failed')); + + await expect( + service.createCost({ + name: 'Food', + amount: 100, + type: CostType.MealsFood, + }), + ).rejects.toThrow(InternalServerErrorException); + await expect( + service.createCost({ + name: 'Food', + amount: 100, + type: CostType.MealsFood, + }), + ).rejects.toThrow('Failed to create cost'); + }); + }); + + describe('updateCost()', () => { + it('updates non-key fields for an existing cost', async () => { + const updatedItem = { + name: 'Food', + amount: 300, + type: CostType.Services, + }; + mockUpdatePromise.mockResolvedValue({ Attributes: updatedItem }); + + const result = await service.updateCost('Food', { + amount: 300, + type: CostType.Services, + }); + + expect(result).toEqual(updatedItem); + expect(mockUpdate).toHaveBeenCalledWith({ + TableName: 'Costs', + Key: { name: 'Food' }, + UpdateExpression: 'SET #amount = :amount, #type = :type', + ExpressionAttributeNames: { + '#amount': 'amount', + '#type': 'type', + '#name': 'name', + }, + ExpressionAttributeValues: { + ':amount': 300, + ':type': CostType.Services, + }, + ConditionExpression: 'attribute_exists(#name)', + ReturnValues: 'ALL_NEW', + }); + }); + + it('updates only amount when type is not provided', async () => { + const updatedItem = { + name: 'Food', + amount: 275, + type: CostType.MealsFood, + }; + mockUpdatePromise.mockResolvedValue({ Attributes: updatedItem }); + + const result = await service.updateCost('Food', { + amount: 275, + }); + + expect(result).toEqual(updatedItem); + expect(mockUpdate).toHaveBeenCalledWith({ + TableName: 'Costs', + Key: { name: 'Food' }, + UpdateExpression: 'SET #amount = :amount', + ExpressionAttributeNames: { + '#amount': 'amount', + '#name': 'name', + }, + ExpressionAttributeValues: { + ':amount': 275, + }, + ConditionExpression: 'attribute_exists(#name)', + ReturnValues: 'ALL_NEW', + }); + }); + + it('updates only type when amount is not provided', async () => { + const updatedItem = { + name: 'Food', + amount: 200, + type: CostType.Services, + }; + mockUpdatePromise.mockResolvedValue({ Attributes: updatedItem }); + + const result = await service.updateCost('Food', { + type: CostType.Services, + }); + + expect(result).toEqual(updatedItem); + expect(mockUpdate).toHaveBeenCalledWith({ + TableName: 'Costs', + Key: { name: 'Food' }, + UpdateExpression: 'SET #type = :type', + ExpressionAttributeNames: { + '#type': 'type', + '#name': 'name', + }, + ExpressionAttributeValues: { + ':type': CostType.Services, + }, + ConditionExpression: 'attribute_exists(#name)', + ReturnValues: 'ALL_NEW', + }); + }); + + it('throws BadRequestException when update payload is empty', async () => { + await expect(service.updateCost('Food', {})).rejects.toThrow( + BadRequestException, + ); + await expect(service.updateCost('Food', {})).rejects.toThrow( + 'At least one field is required for update', + ); + }); + + it('throws BadRequestException for invalid amount', async () => { + await expect( + service.updateCost('Food', { amount: Number.NaN }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException for invalid type', async () => { + await expect( + service.updateCost('Food', { type: 'INVALID' as unknown as CostType }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws NotFoundException when non-rename update target does not exist', async () => { + const err = { code: 'ConditionalCheckFailedException' }; + mockUpdatePromise.mockRejectedValue(err); + + await expect( + service.updateCost('Food', { amount: 250 }), + ).rejects.toThrow(NotFoundException); + await expect( + service.updateCost('Food', { amount: 250 }), + ).rejects.toThrow('Cost with name Food not found'); + }); + + it('throws InternalServerErrorException on non-rename DynamoDB error', async () => { + mockUpdatePromise.mockRejectedValue(new Error('update failed')); + + await expect( + service.updateCost('Food', { amount: 250 }), + ).rejects.toThrow(InternalServerErrorException); + await expect( + service.updateCost('Food', { amount: 250 }), + ).rejects.toThrow('Failed to update cost Food'); + }); + + it('renames a cost safely using transaction', async () => { + mockGetPromise.mockResolvedValue({ + Item: { name: 'Food', amount: 200, type: CostType.MealsFood }, + }); + mockTransactWritePromise.mockResolvedValue({}); + + const result = await service.updateCost('Food', { + name: 'Meals', + amount: 300, + }); + + expect(result).toEqual({ + name: 'Meals', + amount: 300, + type: CostType.MealsFood, + }); + expect(mockGet).toHaveBeenCalledWith({ + TableName: 'Costs', + Key: { name: 'Food' }, + }); + expect(mockTransactWrite).toHaveBeenCalledWith({ + TransactItems: [ + { + Put: { + TableName: 'Costs', + Item: { + name: 'Meals', + amount: 300, + type: CostType.MealsFood, + }, + ConditionExpression: 'attribute_not_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, + }, + }, + { + Delete: { + TableName: 'Costs', + Key: { name: 'Food' }, + ConditionExpression: 'attribute_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, + }, + }, + ], + }); + }); + + it('throws NotFoundException when rename source does not exist', async () => { + mockGetPromise.mockResolvedValue({ Item: undefined }); + + await expect( + service.updateCost('Food', { name: 'Meals' }), + ).rejects.toThrow(NotFoundException); + await expect( + service.updateCost('Food', { name: 'Meals' }), + ).rejects.toThrow('Cost with name Food not found'); + }); + + it('throws BadRequestException when rename target already exists', async () => { + mockGetPromise.mockResolvedValue({ + Item: { name: 'Food', amount: 200, type: CostType.MealsFood }, + }); + mockTransactWritePromise.mockRejectedValue({ + code: 'ConditionalCheckFailedException', + }); + + await expect( + service.updateCost('Food', { name: 'Meals' }), + ).rejects.toThrow(BadRequestException); + await expect( + service.updateCost('Food', { name: 'Meals' }), + ).rejects.toThrow('Cost with name Meals already exists'); + }); + + it('throws InternalServerErrorException on rename transaction error', async () => { + mockGetPromise.mockResolvedValue({ + Item: { name: 'Food', amount: 200, type: CostType.MealsFood }, + }); + mockTransactWritePromise.mockRejectedValue(new Error('txn failed')); + + await expect( + service.updateCost('Food', { name: 'Meals' }), + ).rejects.toThrow(InternalServerErrorException); + await expect( + service.updateCost('Food', { name: 'Meals' }), + ).rejects.toThrow('Failed to update cost Food'); + }); + + it('throws InternalServerErrorException when table name is missing', async () => { + delete process.env.CASHFLOW_COST_TABLE_NAME; + + await expect(service.updateCost('Food', { amount: 200 })).rejects.toThrow( + InternalServerErrorException, + ); + }); + }); + + describe('deleteCost()', () => { + it('deletes a cost by name and trims whitespace', async () => { + mockDeletePromise.mockResolvedValue({}); + + const result = await service.deleteCost(' Food '); + + expect(result).toBe('Cost Food deleted successfully'); + expect(mockDelete).toHaveBeenCalledWith({ + TableName: 'Costs', + Key: { name: 'Food' }, + ConditionExpression: 'attribute_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, + }); + }); + + it('throws BadRequestException for invalid name', async () => { + await expect(service.deleteCost(' ')).rejects.toThrow(BadRequestException); + await expect(service.deleteCost(' ')).rejects.toThrow( + 'name must be a non-empty string', + ); + }); + + it('throws NotFoundException when cost does not exist', async () => { + mockDeletePromise.mockRejectedValue({ + code: 'ConditionalCheckFailedException', + }); + + await expect(service.deleteCost('Food')).rejects.toThrow(NotFoundException); + await expect(service.deleteCost('Food')).rejects.toThrow( + 'Cost with name Food not found', + ); + }); + + it('throws InternalServerErrorException on DynamoDB error', async () => { + mockDeletePromise.mockRejectedValue(new Error('delete failed')); + + await expect(service.deleteCost('Food')).rejects.toThrow( + InternalServerErrorException, + ); + await expect(service.deleteCost('Food')).rejects.toThrow( + 'Failed to delete cost Food', + ); + }); + + it('throws InternalServerErrorException when table name is missing', async () => { + delete process.env.CASHFLOW_COST_TABLE_NAME; + + await expect(service.deleteCost('Food')).rejects.toThrow( + InternalServerErrorException, + ); + await expect(service.deleteCost('Food')).rejects.toThrow( + 'Server configuration error', + ); + }); + }); +}); diff --git a/backend/src/cost/cost.controller.ts b/backend/src/cost/cost.controller.ts new file mode 100644 index 00000000..65003811 --- /dev/null +++ b/backend/src/cost/cost.controller.ts @@ -0,0 +1,153 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + Param, + ParseIntPipe, + Patch, + Post, +} from '@nestjs/common'; +import { + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { CostService } from './cost.service'; +import { CostType } from '../../../middle-layer/types/CostType'; + +interface CreateCostBody { + amount: number; + type: CostType; + name: string; +} + +interface UpdateCostBody { + amount?: number; + type?: CostType; + name?: string; +} + +@ApiTags('cost') +@Controller('cost') +export class CostController { + constructor(private readonly costService: CostService) {} + + /** + * Gets all the costs for cash flow + * @returns array of all CashflowCosts in db + */ + @Get() + @ApiResponse({ + status: 200, + description: 'Successfully retrieved all costs' }) + @ApiResponse({ + status: 500, + description: 'Internal Server Error' }) + async getAllCosts() { + return await this.costService.getAllCosts(); + } + + /** + * gets a cost by name + * @param costName name of cost (e.g. "Intern #1 Salary") + * @returns the cost with the specified name, if it exists + */ + @Get(':costName') + @ApiOperation({ summary: 'Get cost by name' }) + @ApiParam({ name: 'costName', type: String, description: 'Cost Name' }) + @ApiResponse({ status: 200, description: 'Successfully retrieved cost' }) + @ApiResponse({ status: 404, description: 'Cost not found' }) + @ApiResponse({ status: 500, description: 'Internal Server Error' }) + async getCostByName(@Param('costName') costName: string) { + return await this.costService.getCostByName(costName); + } + + /** + * gets costs by type (e.g. Personal Salary, Personal Benefits, etc.) + * @param costType type of cost you are trying to get (e.g. all Salary costs) + * @returns array of costs of the specified type, if any exist + */ + @Get(':costType') + @ApiOperation({ summary: 'Get costs by type' }) + @ApiParam({ name: 'costType', type: String, description: 'Cost Type' }) + @ApiResponse({ status: 200, description: 'Successfully retrieved costs' }) + @ApiResponse({ status: 500, description: 'Internal Server Error' }) + async getCostsByType(@Param('costType') costType: CostType) { + return await this.costService.getCostsByType(costType); + } + + /** + * creates a new cost with the specified fields in the request body + * @param body must include amount, type, and name of the cost to be created + * @returns + */ + @Post() + @ApiOperation({ summary: 'Create a cost' }) + @ApiBody({ + schema: { + type: 'object', + required: ['amount', 'type', 'name'], + properties: { + amount: { type: 'number', example: 12000 }, + type: { + type: 'string', + enum: Object.values(CostType), + example: CostType.Salary, + }, + name: { type: 'string', example: 'Program Manager Salary' }, + }, + }, + }) + @ApiResponse({ status: 201, description: 'Successfully created cost' }) + @ApiResponse({ status: 400, description: 'Bad Request - Invalid cost payload' }) + @ApiResponse({ status: 500, description: 'Internal Server Error' }) + async createCost(@Body() body: CreateCostBody) { + return await this.costService.createCost(body); + } + + @Patch(':costName') + @ApiOperation({ summary: 'Update cost fields by name' }) + @ApiParam({ name: 'costName', type: String, description: 'Cost Name' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + amount: { type: 'number', example: 13000 }, + type: { + type: 'string', + enum: Object.values(CostType), + example: CostType.Benefits, + }, + name: { type: 'string', example: 'Updated Cost Name' }, + }, + }, + }) + @ApiResponse({ status: 200, description: 'Successfully updated cost' }) + @ApiResponse({ status: 400, description: 'Bad Request - Invalid update payload' }) + @ApiResponse({ status: 404, description: 'Cost not found' }) + @ApiResponse({ status: 500, description: 'Internal Server Error' }) + async updateCost( + @Param('costName') costName: string, + @Body() body: UpdateCostBody, + ) { + if (Object.keys(body).length === 0) { + throw new BadRequestException('At least one field is required for update'); + } + + return await this.costService.updateCost(costName, body); + } + + @Delete(':costName') + @ApiOperation({ summary: 'Delete cost by name' }) + @ApiParam({ name: 'costName', type: String, description: 'Cost Name' }) + @ApiResponse({ status: 200, description: 'Successfully deleted cost' }) + @ApiResponse({ status: 404, description: 'Cost not found' }) + @ApiResponse({ status: 500, description: 'Internal Server Error' }) + async deleteCost(@Param('costName') costName: string) { + return await this.costService.deleteCost(costName); + } +} diff --git a/backend/src/cost/cost.module.ts b/backend/src/cost/cost.module.ts new file mode 100644 index 00000000..e04b2173 --- /dev/null +++ b/backend/src/cost/cost.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { CostController } from './cost.controller'; +import { CostService } from './cost.service'; + +@Module({ + controllers: [CostController], + providers: [CostService], +}) +export class CostModule {} diff --git a/backend/src/cost/cost.service.ts b/backend/src/cost/cost.service.ts new file mode 100644 index 00000000..810b5179 --- /dev/null +++ b/backend/src/cost/cost.service.ts @@ -0,0 +1,372 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, +} from '@nestjs/common'; +import * as AWS from 'aws-sdk'; +import { CashflowCost } from '../../../middle-layer/types/CashflowCost'; +import { CostType } from '../../../middle-layer/types/CostType'; + +interface UpdateCostBody { + amount?: number; + type?: CostType; + name?: string; +} + +@Injectable() +export class CostService { + private readonly logger = new Logger(CostService.name); + private dynamoDb = new AWS.DynamoDB.DocumentClient(); + + // Validation helper methods + private validateCostType(type: string) { + if (!Object.values(CostType).includes(type as CostType)) { + throw new BadRequestException( + `type must be one of: ${Object.values(CostType).join(', ')}`, + ); + } + } + + private validateAmount(amount: number) { + if (!Number.isFinite(amount) || amount <= 0) { + throw new BadRequestException('amount must be a finite positive number'); + } + + } + + private validateName(name: string) { + if (name.trim().length === 0) { + throw new BadRequestException('name must be a non-empty string'); + } + } + + // get all costs for cash flow + async getAllCosts(): Promise { + const tableName = process.env.CASHFLOW_COST_TABLE_NAME || ''; + this.logger.log('Retrieving all costs'); + + if (!tableName) { + this.logger.error('CASHFLOW_COST_TABLE_NAME is not defined'); + throw new InternalServerErrorException('Server configuration error'); + } + + try { + const result = await this.dynamoDb + .scan({ + TableName: tableName, + }) + .promise(); + + return (result.Items ?? []) as CashflowCost[]; + } catch (error) { + this.logger.error('Failed to retrieve costs', error as Error); + throw new InternalServerErrorException('Failed to retrieve costs'); + } + } + + // gets a specific cost by its name, the key + async getCostByName(costName: string): Promise { + const tableName = process.env.CASHFLOW_COST_TABLE_NAME || ''; + this.validateName(costName); + const normalizedName = costName.trim(); + this.logger.log(`Retrieving cost with name ${normalizedName}`); + + if (!tableName) { + this.logger.error('CASHFLOW_COST_TABLE_NAME is not defined'); + throw new InternalServerErrorException('Server configuration error'); + } + + try { + const result = await this.dynamoDb + .get({ + TableName: tableName, + Key: { name: normalizedName }, + }) + .promise(); + + if (!result.Item) { + this.logger.error(`Cost with name ${normalizedName} not found`); + throw new NotFoundException(`Cost with name ${normalizedName} not found`); + } + + return result.Item as CashflowCost; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + this.logger.error(`Failed to retrieve cost ${normalizedName}`, error as Error); + throw new InternalServerErrorException(`Failed to retrieve cost ${normalizedName}`); + } + } + + async getCostsByType(costType: CostType): Promise { + const tableName = process.env.CASHFLOW_COST_TABLE_NAME || ''; + this.logger.log(`Retrieving costs with type ${costType}`); + + if (!tableName) { + this.logger.error('CASHFLOW_COST_TABLE_NAME is not defined'); + throw new InternalServerErrorException('Server configuration error'); + } + + try { + const result = await this.dynamoDb + .scan({ + TableName: tableName, + FilterExpression: '#type = :type', + ExpressionAttributeNames: { + '#type': 'type', + }, + ExpressionAttributeValues: { + ':type': costType, + }, + }) + .promise(); + + this.logger.log(`Retrieved ${result.Items?.length ?? 0} costs with type ${costType}`); + return (result.Items ?? []) as CashflowCost[]; + } catch (error) { + this.logger.error(`Failed to retrieve costs with type ${costType}`, error as Error); + throw new InternalServerErrorException(`Failed to retrieve costs with type ${costType}`); + } + } + + async createCost(cost: CashflowCost): Promise { + const tableName = process.env.CASHFLOW_COST_TABLE_NAME || ''; + this.validateAmount(cost.amount); + this.validateCostType(cost.type); + this.validateName(cost.name); + const normalizedName = cost.name.trim(); + + if (!tableName) { + this.logger.error('CASHFLOW_COST_TABLE_NAME is not defined'); + throw new InternalServerErrorException('Server configuration error'); + } + + this.logger.log(`Creating cost with name ${normalizedName}`); + + try { + await this.dynamoDb + .put({ + TableName: tableName, + Item: { + ...cost, + name: normalizedName, + }, + ConditionExpression: 'attribute_not_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, + }) + .promise(); + + return { + ...cost, + name: normalizedName, + }; + } catch (error) { + if (error instanceof BadRequestException) { + throw error; + } + + this.logger.error('Failed to create cost', error as Error); + throw new InternalServerErrorException('Failed to create cost'); + } + } + + async updateCost(costName: string, updates: UpdateCostBody): Promise { + const tableName = process.env.CASHFLOW_COST_TABLE_NAME || ''; + this.validateName(costName); + const normalizedName = costName.trim(); + + if (!tableName) { + this.logger.error('CASHFLOW_COST_TABLE_NAME is not defined'); + throw new InternalServerErrorException('Server configuration error'); + } + + if (updates.amount !== undefined) { + this.validateAmount(updates.amount); + } + if (updates.type !== undefined) { + this.validateCostType(updates.type); + } + if (updates.name !== undefined) { + this.validateName(updates.name); + updates.name = updates.name.trim(); + } + + const shouldRename = + updates.name !== undefined && updates.name.trim() !== normalizedName; + + if (shouldRename) { + const targetName = updates.name as string; + this.logger.log(`Renaming cost ${normalizedName} to ${targetName}`); + + try { + const existingResult = await this.dynamoDb + .get({ + TableName: tableName, + Key: { name: normalizedName }, + }) + .promise(); + + if (!existingResult.Item) { + throw new NotFoundException(`Cost with name ${normalizedName} not found`); + } + + const existingCost = existingResult.Item as CashflowCost; + const renamedCost: CashflowCost = { + name: targetName, + amount: updates.amount ?? existingCost.amount, + type: updates.type ?? existingCost.type, + }; + + await this.dynamoDb + .transactWrite({ + TransactItems: [ + { + Put: { + TableName: tableName, + Item: renamedCost, + ConditionExpression: 'attribute_not_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, + }, + }, + { + Delete: { + TableName: tableName, + Key: { name: normalizedName }, + ConditionExpression: 'attribute_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, + }, + }, + ], + }) + .promise(); + + return renamedCost; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + const awsError = error as { code?: string }; + if (awsError.code === 'ConditionalCheckFailedException') { + throw new BadRequestException(`Cost with name ${targetName} already exists`); + } + + this.logger.error( + `Failed to rename cost ${normalizedName} to ${targetName}`, + error as Error, + ); + throw new InternalServerErrorException( + `Failed to update cost ${normalizedName}`, + ); + } + } + + const nonKeyUpdates: UpdateCostBody = {}; + + if (updates.amount !== undefined) { + nonKeyUpdates.amount = updates.amount; + } + + if (updates.type !== undefined) { + nonKeyUpdates.type = updates.type; + } + + const updateKeys = Object.keys(nonKeyUpdates) as Array; + + if (updateKeys.length === 0) { + throw new BadRequestException('At least one field is required for update'); + } + + const updateExpression = + 'SET ' + updateKeys.map((key) => `#${String(key)} = :${String(key)}`).join(', '); + const expressionAttributeNames = updateKeys.reduce>( + (acc, key) => { + acc[`#${String(key)}`] = String(key); + return acc; + }, + {}, + ); + const expressionAttributeValues = updateKeys.reduce>( + (acc, key) => { + acc[`:${String(key)}`] = nonKeyUpdates[key]; + return acc; + }, + {}, + ); + + this.logger.log(`Updating cost ${normalizedName} with updates: ${JSON.stringify(updates)}`); + + try { + const result = await this.dynamoDb + .update({ + TableName: tableName, + Key: { name: normalizedName }, + UpdateExpression: updateExpression, + ExpressionAttributeNames: { + ...expressionAttributeNames, + '#name': 'name', + }, + ExpressionAttributeValues: expressionAttributeValues, + ConditionExpression: 'attribute_exists(#name)', + ReturnValues: 'ALL_NEW', + }) + .promise(); + + return result.Attributes as CashflowCost; + } catch (error) { + const awsError = error as { code?: string }; + if (awsError.code === 'ConditionalCheckFailedException') { + throw new NotFoundException(`Cost with name ${normalizedName} not found`); + } + + this.logger.error(`Failed to update cost ${normalizedName}`, error as Error); + throw new InternalServerErrorException(`Failed to update cost ${normalizedName}`); + } + } + + async deleteCost(costName: string): Promise { + const tableName = process.env.CASHFLOW_COST_TABLE_NAME || ''; + this.validateName(costName); + const normalizedName = costName.trim(); + + if (!tableName) { + this.logger.error('CASHFLOW_COST_TABLE_NAME is not defined'); + throw new InternalServerErrorException('Server configuration error'); + } + + this.logger.log(`Deleting cost ${normalizedName}`); + + try { + await this.dynamoDb + .delete({ + TableName: tableName, + Key: { name: normalizedName }, + ConditionExpression: 'attribute_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, + }) + .promise(); + + return `Cost ${normalizedName} deleted successfully`; + } catch (error) { + const awsError = error as { code?: string }; + if (awsError.code === 'ConditionalCheckFailedException') { + throw new NotFoundException(`Cost with name ${normalizedName} not found`); + } + + this.logger.error(`Failed to delete cost ${normalizedName}`, error as Error); + throw new InternalServerErrorException(`Failed to delete cost ${normalizedName}`); + } + } +} diff --git a/middle-layer/types/CashflowCost.ts b/middle-layer/types/CashflowCost.ts index c315d9df..338d7b22 100644 --- a/middle-layer/types/CashflowCost.ts +++ b/middle-layer/types/CashflowCost.ts @@ -1,9 +1,7 @@ import { CostType } from "./CostType"; - - export interface CashflowCost { -    amount: number; -    type : CostType; -    name : string; + name: string; + amount: number; + type: CostType; } \ No newline at end of file From 214be6117d6c347a942e4312148a1c6a4cd996b2 Mon Sep 17 00:00:00 2001 From: lyannne Date: Wed, 18 Mar 2026 23:10:49 -0400 Subject: [PATCH 4/8] default values get and patch --- .../default-values.controller.ts | 77 ++++++++++++ .../default-values/default-values.module.ts | 9 ++ .../default-values/default-values.service.ts | 113 ++++++++++++++++++ .../types/default-values.types.ts | 10 ++ 4 files changed, 209 insertions(+) create mode 100644 backend/src/default-values/default-values.controller.ts create mode 100644 backend/src/default-values/default-values.module.ts create mode 100644 backend/src/default-values/default-values.service.ts create mode 100644 backend/src/default-values/types/default-values.types.ts diff --git a/backend/src/default-values/default-values.controller.ts b/backend/src/default-values/default-values.controller.ts new file mode 100644 index 00000000..b988c605 --- /dev/null +++ b/backend/src/default-values/default-values.controller.ts @@ -0,0 +1,77 @@ +import { Body, Controller, Get, Patch, Logger } from '@nestjs/common'; +import { ApiBody, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { DefaultValuesService } from './default-values.service'; +import { + DefaultValuesResponse, + UpdateDefaultValueBody, +} from './types/default-values.types'; +import { UseGuards } from '@nestjs/common'; +import { VerifyAdminRoleGuard } from '../guards/auth.guard'; +import { ApiBearerAuth } from '@nestjs/swagger'; + + +@ApiTags('default-values') +@Controller('default-values') +export class DefaultValuesController { + private readonly logger = new Logger(DefaultValuesController.name); + + constructor(private readonly defaultValuesService: DefaultValuesService) {} + + /** + * Gets the default values for starting cash, benefits increase, and salary increase for cash flow + * @returns DefaultValuesResponse containing the default values + */ + @Get() + @ApiResponse({ + status: 200, + description: 'Default values retrieved successfully', + }) + @ApiResponse({ + status: 404, + description: 'Default values not found', + }) + @ApiResponse({ + status: 500, + description: 'Internal Server Error', + }) + async getDefaultValues(): Promise { + this.logger.log('GET /default-values - Retrieving default values'); + return await this.defaultValuesService.getDefaultValues(); + } + + /** + * Edits a default value for cash flow based on the provided key and value + * @param body - UpdateDefaultValueBody containing the key of the default value to update and the new value + * @returns new DefaultValuesResponse with the updated default values + */ + @Patch() + @ApiBody({ schema: { + type: 'object', + properties: { + key: { type: 'string', enum: ['startingCash', 'benefitsIncrease', 'salaryIncrease'] }, + value: { type: 'number' } + } + }}) + @ApiResponse({ + status: 200, + description: 'Default value updated successfully', + }) + @ApiResponse({ + status: 400, + description: 'Bad Request - Invalid key or value', + }) + @ApiResponse({ + status: 500, + description: 'Internal Server Error', + }) + @UseGuards(VerifyAdminRoleGuard) + @ApiBearerAuth() + async updateDefaultValue( + @Body() body: UpdateDefaultValueBody, + ): Promise { + this.logger.log(`PATCH /default-values - Updating default value for key: ${body.key}`); + const updatedValues = await this.defaultValuesService.updateDefaultValue(body.key, body.value); + this.logger.log(`PATCH /default-values - Successfully updated default value for key: ${body.key}`); + return updatedValues; + } +} diff --git a/backend/src/default-values/default-values.module.ts b/backend/src/default-values/default-values.module.ts new file mode 100644 index 00000000..69ba001c --- /dev/null +++ b/backend/src/default-values/default-values.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { DefaultValuesController } from './default-values.controller'; +import { DefaultValuesService } from './default-values.service'; + +@Module({ + controllers: [DefaultValuesController], + providers: [DefaultValuesService], +}) +export class DefaultValuesModule {} diff --git a/backend/src/default-values/default-values.service.ts b/backend/src/default-values/default-values.service.ts new file mode 100644 index 00000000..037a01d5 --- /dev/null +++ b/backend/src/default-values/default-values.service.ts @@ -0,0 +1,113 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, +} from '@nestjs/common'; +import * as AWS from 'aws-sdk'; +import { DefaultValuesResponse } from './types/default-values.types'; + +@Injectable() +export class DefaultValuesService { + private readonly logger = new Logger(DefaultValuesService.name); + private dynamoDb = new AWS.DynamoDB.DocumentClient(); + + async getDefaultValues(): Promise { + const tableName = process.env.CASHFLOW_DEFAULT_VALUE_TABLE_NAME; + + if (!tableName) { + this.logger.error('CASHFLOW_DEFAULT_VALUE_TABLE_NAME is not defined'); + throw new InternalServerErrorException('Server configuration error'); + } + + try { + const result = await this.dynamoDb + .scan({ + TableName: tableName, + }) + .promise(); + + const items = (result.Items ?? []) as { name: string; value: number }[]; + + const startingCash = items.find((item) => item.name === 'startingCash')?.value || -1; + const benefitsIncrease = items.find((item) => item.name === 'benefitsIncrease')?.value || -1; + const salaryIncrease = items.find((item) => item.name === 'salaryIncrease')?.value || -1; + + if ( + !Number.isFinite(startingCash) || + !Number.isFinite(benefitsIncrease) || + !Number.isFinite(salaryIncrease) + ) { + this.logger.error('Default values table is missing required fields'); + throw new NotFoundException('Default values not found'); + } + + const defaultValues: DefaultValuesResponse = { + startingCash, + benefitsIncrease, + salaryIncrease, + }; + + return defaultValues; + + } catch (error) { + if ( + error instanceof InternalServerErrorException || + error instanceof NotFoundException + ) { + throw error; + } + + this.logger.error('Failed to retrieve default values', error as Error); + throw new InternalServerErrorException('Failed to retrieve default values'); + } + } + + async updateDefaultValue( + key: string, + value: number, + ): Promise { + const tableName = process.env.CASHFLOW_DEFAULT_VALUE_TABLE_NAME; + + if (!tableName) { + this.logger.error('CASHFLOW_DEFAULT_VALUE_TABLE_NAME is not defined'); + throw new InternalServerErrorException('Server configuration error'); + } + + if (!Number.isFinite(value)) { + throw new BadRequestException('Value must be a valid number'); + } + + if (!(key === 'startingCash' || key === 'benefitsIncrease' || key === 'salaryIncrease')) { + throw new BadRequestException( + 'Default value must be one of: startingCash, benefitsIncrease, salaryIncrease', + ); + } + + try { + await this.dynamoDb + .put({ + TableName: tableName, + Item: { + name: key, + value, + }, + }) + .promise(); + + return await this.getDefaultValues(); + } catch (error) { + if ( + error instanceof BadRequestException || + error instanceof NotFoundException || + error instanceof InternalServerErrorException + ) { + throw error; + } + + this.logger.error(`Failed to update default value '${key}'`, error as Error); + throw new InternalServerErrorException('Failed to update default value'); + } + } +} \ No newline at end of file diff --git a/backend/src/default-values/types/default-values.types.ts b/backend/src/default-values/types/default-values.types.ts new file mode 100644 index 00000000..93b3d485 --- /dev/null +++ b/backend/src/default-values/types/default-values.types.ts @@ -0,0 +1,10 @@ +export interface DefaultValuesResponse { + startingCash: number; + benefitsIncrease: number; + salaryIncrease: number; +} + +export interface UpdateDefaultValueBody { + key: string; + value: number; +} From 1f9585da2585fe7390ac071b6cb9414087caeaec Mon Sep 17 00:00:00 2001 From: lyannne Date: Thu, 19 Mar 2026 16:14:41 -0400 Subject: [PATCH 5/8] updated test for default values --- .../__test__/default-values.service.spec.ts | 303 ++++++++++++++++++ .../default-values/default-values.service.ts | 14 +- 2 files changed, 308 insertions(+), 9 deletions(-) create mode 100644 backend/src/default-values/__test__/default-values.service.spec.ts diff --git a/backend/src/default-values/__test__/default-values.service.spec.ts b/backend/src/default-values/__test__/default-values.service.spec.ts new file mode 100644 index 00000000..fa7947de --- /dev/null +++ b/backend/src/default-values/__test__/default-values.service.spec.ts @@ -0,0 +1,303 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DefaultValuesService } from '../default-values.service'; +import { + BadRequestException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const mockDefaultValues = [ + { name: 'startingCash', value: 10000 }, + { name: 'benefitsIncrease', value: 3.5 }, + { name: 'salaryIncrease', value: 2.0 }, +]; + +const mockPromise = vi.fn(); +const mockScan = vi.fn(); +const mockPut = vi.fn(); + +const mockDocumentClient = { + scan: mockScan, + put: mockPut, +}; + +vi.mock('aws-sdk', () => ({ + DynamoDB: { + DocumentClient: vi.fn(function () { + return mockDocumentClient; + }), + }, +})); + +describe('DefaultValuesService', () => { + let service: DefaultValuesService; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Setup DynamoDB mocks to return chainable objects with .promise() + mockScan.mockReturnValue({ promise: mockPromise }); + mockPut.mockReturnValue({ promise: mockPromise }); + + // Set the environment variable for the table name + process.env.CASHFLOW_DEFAULT_VALUE_TABLE_NAME = 'DefaultValues'; + + const module: TestingModule = await Test.createTestingModule({ + providers: [DefaultValuesService], + }).compile(); + + service = module.get(DefaultValuesService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getDefaultValues()', () => { + it('should return default values when all three values exist in the database', async () => { + mockPromise.mockResolvedValue({ Items: mockDefaultValues }); + + const result = await service.getDefaultValues(); + + expect(result).toEqual({ + startingCash: 10000, + benefitsIncrease: 3.5, + salaryIncrease: 2.0, + }); + expect(mockDocumentClient.scan).toHaveBeenCalledWith({ + TableName: 'DefaultValues', + }); + }); + + it('should throw NotFoundException if startingCash is missing', async () => { + const incompleteValues = [ + { name: 'benefitsIncrease', value: 3.5 }, + { name: 'salaryIncrease', value: 2.0 }, + ]; + mockPromise.mockResolvedValue({ Items: incompleteValues }); + + await expect(service.getDefaultValues()).rejects.toThrow( + NotFoundException, + ); + await expect(service.getDefaultValues()).rejects.toThrow( + 'Default values not found', + ); + }); + + it('should throw NotFoundException if benefitsIncrease is missing', async () => { + const incompleteValues = [ + { name: 'startingCash', value: 10000 }, + { name: 'salaryIncrease', value: 2.0 }, + ]; + mockPromise.mockResolvedValue({ Items: incompleteValues }); + + await expect(service.getDefaultValues()).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw NotFoundException if salaryIncrease is missing', async () => { + const incompleteValues = [ + { name: 'startingCash', value: 10000 }, + { name: 'benefitsIncrease', value: 3.5 }, + ]; + mockPromise.mockResolvedValue({ Items: incompleteValues }); + + await expect(service.getDefaultValues()).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw NotFoundException if all values are missing', async () => { + mockPromise.mockResolvedValue({ Items: [] }); + + await expect(service.getDefaultValues()).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw InternalServerErrorException if table name is not configured', async () => { + delete process.env.CASHFLOW_DEFAULT_VALUE_TABLE_NAME; + + await expect(service.getDefaultValues()).rejects.toThrow( + InternalServerErrorException, + ); + await expect(service.getDefaultValues()).rejects.toThrow( + 'Server configuration error', + ); + + // Restore for other tests + process.env.CASHFLOW_DEFAULT_VALUE_TABLE_NAME = 'DefaultValues'; + }); + + it('should throw InternalServerErrorException on general DynamoDB error', async () => { + const dbError = new Error('DynamoDB connection failed'); + mockPromise.mockRejectedValue(dbError); + + await expect(service.getDefaultValues()).rejects.toThrow( + InternalServerErrorException, + ); + await expect(service.getDefaultValues()).rejects.toThrow( + 'Failed to retrieve default values', + ); + }); + + it('should allow negative values', async () => { + const negativeValues = [ + { name: 'startingCash', value: -5000 }, + { name: 'benefitsIncrease', value: -1.5 }, + { name: 'salaryIncrease', value: -0.5 }, + ]; + mockPromise.mockResolvedValue({ Items: negativeValues }); + + const result = await service.getDefaultValues(); + + expect(result).toEqual({ + startingCash: -5000, + benefitsIncrease: -1.5, + salaryIncrease: -0.5, + }); + }); + }); + + describe('updateDefaultValue()', () => { + it('should successfully update startingCash', async () => { + mockPromise.mockResolvedValue({ Items: mockDefaultValues }); + + const result = await service.updateDefaultValue('startingCash', 15000); + + expect(result).toEqual({ + startingCash: 10000, + benefitsIncrease: 3.5, + salaryIncrease: 2.0, + }); + expect(mockDocumentClient.put).toHaveBeenCalledWith({ + TableName: 'DefaultValues', + Item: { + name: 'startingCash', + value: 15000, + }, + }); + }); + + it('should successfully update benefitsIncrease', async () => { + mockPromise.mockResolvedValue({ Items: mockDefaultValues }); + + const result = await service.updateDefaultValue('benefitsIncrease', 5.0); + + expect(result).toEqual({ + startingCash: 10000, + benefitsIncrease: 3.5, + salaryIncrease: 2.0, + }); + expect(mockDocumentClient.put).toHaveBeenCalledWith({ + TableName: 'DefaultValues', + Item: { + name: 'benefitsIncrease', + value: 5.0, + }, + }); + }); + + it('should successfully update salaryIncrease', async () => { + mockPromise.mockResolvedValue({ Items: mockDefaultValues }); + + const result = await service.updateDefaultValue('salaryIncrease', 3.0); + + expect(result).toEqual({ + startingCash: 10000, + benefitsIncrease: 3.5, + salaryIncrease: 2.0, + }); + expect(mockDocumentClient.put).toHaveBeenCalledWith({ + TableName: 'DefaultValues', + Item: { + name: 'salaryIncrease', + value: 3.0, + }, + }); + }); + + it('should throw BadRequestException for non-numeric values', async () => { + await expect( + service.updateDefaultValue('startingCash', Number.NaN), + ).rejects.toThrow(BadRequestException); + await expect( + service.updateDefaultValue('startingCash', Number.NaN), + ).rejects.toThrow('Value must be a valid number'); + }); + + it('should throw BadRequestException for Infinity', async () => { + await expect( + service.updateDefaultValue('startingCash', Infinity), + ).rejects.toThrow(BadRequestException); + await expect( + service.updateDefaultValue('startingCash', Infinity), + ).rejects.toThrow('Value must be a valid number'); + }); + + it('should throw BadRequestException for invalid keys', async () => { + await expect( + service.updateDefaultValue('invalidKey' as any, 100), + ).rejects.toThrow(BadRequestException); + await expect( + service.updateDefaultValue('invalidKey' as any, 100), + ).rejects.toThrow('Default value must be one of'); + }); + + it('should throw InternalServerErrorException if table name is not configured', async () => { + delete process.env.CASHFLOW_DEFAULT_VALUE_TABLE_NAME; + + await expect( + service.updateDefaultValue('startingCash', 5000), + ).rejects.toThrow(InternalServerErrorException); + await expect( + service.updateDefaultValue('startingCash', 5000), + ).rejects.toThrow('Server configuration error'); + + // Restore for other tests + process.env.CASHFLOW_DEFAULT_VALUE_TABLE_NAME = 'DefaultValues'; + }); + + it('should successfully update with negative value', async () => { + mockPromise.mockResolvedValue({ Items: mockDefaultValues }); + + const result = await service.updateDefaultValue('startingCash', -1000); + + expect(result).toEqual({ + startingCash: 10000, + benefitsIncrease: 3.5, + salaryIncrease: 2.0, + }); + expect(mockDocumentClient.put).toHaveBeenCalledWith({ + TableName: 'DefaultValues', + Item: { + name: 'startingCash', + value: -1000, + }, + }); + }); + + it('should throw InternalServerErrorException on DynamoDB put error', async () => { + const dbError = new Error('DynamoDB write failed'); + mockPut.mockReturnValue({ promise: vi.fn().mockRejectedValue(dbError) }); + + await expect( + service.updateDefaultValue('startingCash', 5000), + ).rejects.toThrow(InternalServerErrorException); + await expect( + service.updateDefaultValue('startingCash', 5000), + ).rejects.toThrow('Failed to update default value'); + }); + + it('should throw InternalServerErrorException if getDefaultValues fails after update', async () => { + const dbError = new Error('DynamoDB read failed'); + mockPromise.mockRejectedValue(dbError); + + await expect( + service.updateDefaultValue('startingCash', 5000), + ).rejects.toThrow(InternalServerErrorException); + }); + }); +}); diff --git a/backend/src/default-values/default-values.service.ts b/backend/src/default-values/default-values.service.ts index 037a01d5..a5c6d54a 100644 --- a/backend/src/default-values/default-values.service.ts +++ b/backend/src/default-values/default-values.service.ts @@ -28,17 +28,13 @@ export class DefaultValuesService { }) .promise(); - const items = (result.Items ?? []) as { name: string; value: number }[]; + const items = (result.Items ?? []); - const startingCash = items.find((item) => item.name === 'startingCash')?.value || -1; - const benefitsIncrease = items.find((item) => item.name === 'benefitsIncrease')?.value || -1; - const salaryIncrease = items.find((item) => item.name === 'salaryIncrease')?.value || -1; + const startingCash = items.find((item) => item.name === 'startingCash')?.value || null; + const benefitsIncrease = items.find((item) => item.name === 'benefitsIncrease')?.value || null; + const salaryIncrease = items.find((item) => item.name === 'salaryIncrease')?.value || null; - if ( - !Number.isFinite(startingCash) || - !Number.isFinite(benefitsIncrease) || - !Number.isFinite(salaryIncrease) - ) { + if (startingCash === null || benefitsIncrease === null || salaryIncrease === null) { this.logger.error('Default values table is missing required fields'); throw new NotFoundException('Default values not found'); } From e08f1ef2940792f9278949a7b8c98268bed09549 Mon Sep 17 00:00:00 2001 From: lyannne Date: Thu, 19 Mar 2026 19:11:51 -0400 Subject: [PATCH 6/8] cost routes and tests --- backend/src/app.module.ts | 4 +- .../src/cost/__test__/cost.service.spec.ts | 601 ++++++++++++++++++ backend/src/cost/cost.controller.ts | 153 +++++ backend/src/cost/cost.module.ts | 9 + backend/src/cost/cost.service.ts | 372 +++++++++++ middle-layer/types/CashflowCost.ts | 8 +- 6 files changed, 1141 insertions(+), 6 deletions(-) create mode 100644 backend/src/cost/__test__/cost.service.spec.ts create mode 100644 backend/src/cost/cost.controller.ts create mode 100644 backend/src/cost/cost.module.ts create mode 100644 backend/src/cost/cost.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 25449e13..75d769ae 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -4,8 +4,10 @@ import { UserModule } from './user/user.module'; import { GrantModule } from './grant/grant.module'; import { NotificationsModule } from './notifications/notification.module'; import { CashflowModule } from './cashflow/cashflow.module'; +import { CostModule } from './cost/cost.module'; +import { DefaultValuesModule } from './default-values/default-values.module'; @Module({ - imports: [AuthModule, UserModule, GrantModule, NotificationsModule,CashflowModule], + imports: [AuthModule, UserModule, GrantModule, NotificationsModule, CashflowModule, CostModule, DefaultValuesModule], }) export class AppModule {} \ No newline at end of file diff --git a/backend/src/cost/__test__/cost.service.spec.ts b/backend/src/cost/__test__/cost.service.spec.ts new file mode 100644 index 00000000..1b3370fd --- /dev/null +++ b/backend/src/cost/__test__/cost.service.spec.ts @@ -0,0 +1,601 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + BadRequestException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { CostService } from '../cost.service'; +import { CostType } from '../../../../middle-layer/types/CostType'; + +const mockScanPromise = vi.fn(); +const mockGetPromise = vi.fn(); +const mockPutPromise = vi.fn(); +const mockUpdatePromise = vi.fn(); +const mockDeletePromise = vi.fn(); +const mockTransactWritePromise = vi.fn(); + +const mockScan = vi.fn(() => ({ promise: mockScanPromise })); +const mockGet = vi.fn(() => ({ promise: mockGetPromise })); +const mockPut = vi.fn(() => ({ promise: mockPutPromise })); +const mockUpdate = vi.fn(() => ({ promise: mockUpdatePromise })); +const mockDelete = vi.fn(() => ({ promise: mockDeletePromise })); +const mockTransactWrite = vi.fn(() => ({ promise: mockTransactWritePromise })); + +const mockDocumentClient = { + scan: mockScan, + get: mockGet, + put: mockPut, + update: mockUpdate, + delete: mockDelete, + transactWrite: mockTransactWrite, +}; + +vi.mock('aws-sdk', () => ({ + DynamoDB: { + DocumentClient: vi.fn(function () { + return mockDocumentClient; + }), + }, +})); + +describe('CostService', () => { + let service: CostService; + + beforeEach(async () => { + vi.clearAllMocks(); + + process.env.CASHFLOW_COST_TABLE_NAME = 'Costs'; + + const module: TestingModule = await Test.createTestingModule({ + providers: [CostService], + }).compile(); + + service = module.get(CostService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getAllCosts()', () => { + it('returns all costs', async () => { + const items = [ + { name: 'Food', amount: 200, type: CostType.MealsFood }, + { name: 'Rent', amount: 500, type: CostType.RentAndSpace }, + ]; + mockScanPromise.mockResolvedValue({ Items: items }); + + const result = await service.getAllCosts(); + + expect(result).toEqual(items); + expect(mockScan).toHaveBeenCalledWith({ TableName: 'Costs' }); + }); + + it('returns empty list when no costs exist', async () => { + mockScanPromise.mockResolvedValue({ Items: [] }); + + const result = await service.getAllCosts(); + + expect(result).toEqual([]); + }); + + it('throws InternalServerErrorException when table name is missing', async () => { + delete process.env.CASHFLOW_COST_TABLE_NAME; + + await expect(service.getAllCosts()).rejects.toThrow( + InternalServerErrorException, + ); + await expect(service.getAllCosts()).rejects.toThrow( + 'Server configuration error', + ); + }); + + it('throws InternalServerErrorException on DynamoDB error', async () => { + mockScanPromise.mockRejectedValue(new Error('scan failed')); + + await expect(service.getAllCosts()).rejects.toThrow( + InternalServerErrorException, + ); + await expect(service.getAllCosts()).rejects.toThrow( + 'Failed to retrieve costs', + ); + }); + }); + + describe('getCostByName()', () => { + it('returns a cost by name and trims whitespace', async () => { + const item = { name: 'Food', amount: 200, type: CostType.MealsFood }; + mockGetPromise.mockResolvedValue({ Item: item }); + + const result = await service.getCostByName(' Food '); + + expect(result).toEqual(item); + expect(mockGet).toHaveBeenCalledWith({ + TableName: 'Costs', + Key: { name: 'Food' }, + }); + }); + + it('throws BadRequestException for empty name', async () => { + await expect(service.getCostByName(' ')).rejects.toThrow( + BadRequestException, + ); + await expect(service.getCostByName(' ')).rejects.toThrow( + 'name must be a non-empty string', + ); + }); + + it('throws NotFoundException when cost does not exist', async () => { + mockGetPromise.mockResolvedValue({ Item: undefined }); + + await expect(service.getCostByName('Food')).rejects.toThrow( + NotFoundException, + ); + await expect(service.getCostByName('Food')).rejects.toThrow( + 'Cost with name Food not found', + ); + }); + + it('throws InternalServerErrorException when table name is missing', async () => { + delete process.env.CASHFLOW_COST_TABLE_NAME; + + await expect(service.getCostByName('Food')).rejects.toThrow( + InternalServerErrorException, + ); + await expect(service.getCostByName('Food')).rejects.toThrow( + 'Server configuration error', + ); + }); + + it('throws InternalServerErrorException on DynamoDB error', async () => { + mockGetPromise.mockRejectedValue(new Error('get failed')); + + await expect(service.getCostByName('Food')).rejects.toThrow( + InternalServerErrorException, + ); + await expect(service.getCostByName('Food')).rejects.toThrow( + 'Failed to retrieve cost Food', + ); + }); + }); + + describe('getCostsByType()', () => { + it('returns costs filtered by type', async () => { + const items = [{ name: 'Food', amount: 200, type: CostType.MealsFood }]; + mockScanPromise.mockResolvedValue({ Items: items }); + + const result = await service.getCostsByType(CostType.MealsFood); + + expect(result).toEqual(items); + expect(mockScan).toHaveBeenCalledWith({ + TableName: 'Costs', + FilterExpression: '#type = :type', + ExpressionAttributeNames: { + '#type': 'type', + }, + ExpressionAttributeValues: { + ':type': CostType.MealsFood, + }, + }); + }); + + it('throws InternalServerErrorException when table name is missing', async () => { + delete process.env.CASHFLOW_COST_TABLE_NAME; + + await expect(service.getCostsByType(CostType.MealsFood)).rejects.toThrow( + InternalServerErrorException, + ); + }); + + it('throws InternalServerErrorException on DynamoDB error', async () => { + mockScanPromise.mockRejectedValue(new Error('scan failed')); + + await expect(service.getCostsByType(CostType.MealsFood)).rejects.toThrow( + InternalServerErrorException, + ); + await expect(service.getCostsByType(CostType.MealsFood)).rejects.toThrow( + 'Failed to retrieve costs with type Meals and Food', + ); + }); + }); + + describe('createCost()', () => { + it('creates a cost and trims the name', async () => { + mockPutPromise.mockResolvedValue({}); + const payload = { + name: ' Food ', + amount: 200, + type: CostType.MealsFood, + }; + + const result = await service.createCost(payload); + + expect(result).toEqual({ + name: 'Food', + amount: 200, + type: CostType.MealsFood, + }); + expect(mockPut).toHaveBeenCalledWith({ + TableName: 'Costs', + Item: { + name: 'Food', + amount: 200, + type: CostType.MealsFood, + }, + ConditionExpression: 'attribute_not_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, + }); + }); + + it('throws BadRequestException for invalid amount', async () => { + await expect( + service.createCost({ + name: 'Food', + amount: 0, + type: CostType.MealsFood, + }), + ).rejects.toThrow(BadRequestException); + await expect( + service.createCost({ + name: 'Food', + amount: 0, + type: CostType.MealsFood, + }), + ).rejects.toThrow('amount must be a finite positive number'); + }); + + it('throws BadRequestException for invalid type', async () => { + await expect( + service.createCost({ + name: 'Food', + amount: 100, + type: 'INVALID' as unknown as CostType, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException for invalid name', async () => { + await expect( + service.createCost({ + name: ' ', + amount: 100, + type: CostType.MealsFood, + }), + ).rejects.toThrow(BadRequestException); + await expect( + service.createCost({ + name: ' ', + amount: 100, + type: CostType.MealsFood, + }), + ).rejects.toThrow('name must be a non-empty string'); + }); + + it('throws InternalServerErrorException when table name is missing', async () => { + delete process.env.CASHFLOW_COST_TABLE_NAME; + + await expect( + service.createCost({ + name: 'Food', + amount: 100, + type: CostType.MealsFood, + }), + ).rejects.toThrow(InternalServerErrorException); + }); + + it('throws InternalServerErrorException on DynamoDB error', async () => { + mockPutPromise.mockRejectedValue(new Error('put failed')); + + await expect( + service.createCost({ + name: 'Food', + amount: 100, + type: CostType.MealsFood, + }), + ).rejects.toThrow(InternalServerErrorException); + await expect( + service.createCost({ + name: 'Food', + amount: 100, + type: CostType.MealsFood, + }), + ).rejects.toThrow('Failed to create cost'); + }); + }); + + describe('updateCost()', () => { + it('updates non-key fields for an existing cost', async () => { + const updatedItem = { + name: 'Food', + amount: 300, + type: CostType.Services, + }; + mockUpdatePromise.mockResolvedValue({ Attributes: updatedItem }); + + const result = await service.updateCost('Food', { + amount: 300, + type: CostType.Services, + }); + + expect(result).toEqual(updatedItem); + expect(mockUpdate).toHaveBeenCalledWith({ + TableName: 'Costs', + Key: { name: 'Food' }, + UpdateExpression: 'SET #amount = :amount, #type = :type', + ExpressionAttributeNames: { + '#amount': 'amount', + '#type': 'type', + '#name': 'name', + }, + ExpressionAttributeValues: { + ':amount': 300, + ':type': CostType.Services, + }, + ConditionExpression: 'attribute_exists(#name)', + ReturnValues: 'ALL_NEW', + }); + }); + + it('updates only amount when type is not provided', async () => { + const updatedItem = { + name: 'Food', + amount: 275, + type: CostType.MealsFood, + }; + mockUpdatePromise.mockResolvedValue({ Attributes: updatedItem }); + + const result = await service.updateCost('Food', { + amount: 275, + }); + + expect(result).toEqual(updatedItem); + expect(mockUpdate).toHaveBeenCalledWith({ + TableName: 'Costs', + Key: { name: 'Food' }, + UpdateExpression: 'SET #amount = :amount', + ExpressionAttributeNames: { + '#amount': 'amount', + '#name': 'name', + }, + ExpressionAttributeValues: { + ':amount': 275, + }, + ConditionExpression: 'attribute_exists(#name)', + ReturnValues: 'ALL_NEW', + }); + }); + + it('updates only type when amount is not provided', async () => { + const updatedItem = { + name: 'Food', + amount: 200, + type: CostType.Services, + }; + mockUpdatePromise.mockResolvedValue({ Attributes: updatedItem }); + + const result = await service.updateCost('Food', { + type: CostType.Services, + }); + + expect(result).toEqual(updatedItem); + expect(mockUpdate).toHaveBeenCalledWith({ + TableName: 'Costs', + Key: { name: 'Food' }, + UpdateExpression: 'SET #type = :type', + ExpressionAttributeNames: { + '#type': 'type', + '#name': 'name', + }, + ExpressionAttributeValues: { + ':type': CostType.Services, + }, + ConditionExpression: 'attribute_exists(#name)', + ReturnValues: 'ALL_NEW', + }); + }); + + it('throws BadRequestException when update payload is empty', async () => { + await expect(service.updateCost('Food', {})).rejects.toThrow( + BadRequestException, + ); + await expect(service.updateCost('Food', {})).rejects.toThrow( + 'At least one field is required for update', + ); + }); + + it('throws BadRequestException for invalid amount', async () => { + await expect( + service.updateCost('Food', { amount: Number.NaN }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException for invalid type', async () => { + await expect( + service.updateCost('Food', { type: 'INVALID' as unknown as CostType }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws NotFoundException when non-rename update target does not exist', async () => { + const err = { code: 'ConditionalCheckFailedException' }; + mockUpdatePromise.mockRejectedValue(err); + + await expect( + service.updateCost('Food', { amount: 250 }), + ).rejects.toThrow(NotFoundException); + await expect( + service.updateCost('Food', { amount: 250 }), + ).rejects.toThrow('Cost with name Food not found'); + }); + + it('throws InternalServerErrorException on non-rename DynamoDB error', async () => { + mockUpdatePromise.mockRejectedValue(new Error('update failed')); + + await expect( + service.updateCost('Food', { amount: 250 }), + ).rejects.toThrow(InternalServerErrorException); + await expect( + service.updateCost('Food', { amount: 250 }), + ).rejects.toThrow('Failed to update cost Food'); + }); + + it('renames a cost safely using transaction', async () => { + mockGetPromise.mockResolvedValue({ + Item: { name: 'Food', amount: 200, type: CostType.MealsFood }, + }); + mockTransactWritePromise.mockResolvedValue({}); + + const result = await service.updateCost('Food', { + name: 'Meals', + amount: 300, + }); + + expect(result).toEqual({ + name: 'Meals', + amount: 300, + type: CostType.MealsFood, + }); + expect(mockGet).toHaveBeenCalledWith({ + TableName: 'Costs', + Key: { name: 'Food' }, + }); + expect(mockTransactWrite).toHaveBeenCalledWith({ + TransactItems: [ + { + Put: { + TableName: 'Costs', + Item: { + name: 'Meals', + amount: 300, + type: CostType.MealsFood, + }, + ConditionExpression: 'attribute_not_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, + }, + }, + { + Delete: { + TableName: 'Costs', + Key: { name: 'Food' }, + ConditionExpression: 'attribute_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, + }, + }, + ], + }); + }); + + it('throws NotFoundException when rename source does not exist', async () => { + mockGetPromise.mockResolvedValue({ Item: undefined }); + + await expect( + service.updateCost('Food', { name: 'Meals' }), + ).rejects.toThrow(NotFoundException); + await expect( + service.updateCost('Food', { name: 'Meals' }), + ).rejects.toThrow('Cost with name Food not found'); + }); + + it('throws BadRequestException when rename target already exists', async () => { + mockGetPromise.mockResolvedValue({ + Item: { name: 'Food', amount: 200, type: CostType.MealsFood }, + }); + mockTransactWritePromise.mockRejectedValue({ + code: 'ConditionalCheckFailedException', + }); + + await expect( + service.updateCost('Food', { name: 'Meals' }), + ).rejects.toThrow(BadRequestException); + await expect( + service.updateCost('Food', { name: 'Meals' }), + ).rejects.toThrow('Cost with name Meals already exists'); + }); + + it('throws InternalServerErrorException on rename transaction error', async () => { + mockGetPromise.mockResolvedValue({ + Item: { name: 'Food', amount: 200, type: CostType.MealsFood }, + }); + mockTransactWritePromise.mockRejectedValue(new Error('txn failed')); + + await expect( + service.updateCost('Food', { name: 'Meals' }), + ).rejects.toThrow(InternalServerErrorException); + await expect( + service.updateCost('Food', { name: 'Meals' }), + ).rejects.toThrow('Failed to update cost Food'); + }); + + it('throws InternalServerErrorException when table name is missing', async () => { + delete process.env.CASHFLOW_COST_TABLE_NAME; + + await expect(service.updateCost('Food', { amount: 200 })).rejects.toThrow( + InternalServerErrorException, + ); + }); + }); + + describe('deleteCost()', () => { + it('deletes a cost by name and trims whitespace', async () => { + mockDeletePromise.mockResolvedValue({}); + + const result = await service.deleteCost(' Food '); + + expect(result).toBe('Cost Food deleted successfully'); + expect(mockDelete).toHaveBeenCalledWith({ + TableName: 'Costs', + Key: { name: 'Food' }, + ConditionExpression: 'attribute_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, + }); + }); + + it('throws BadRequestException for invalid name', async () => { + await expect(service.deleteCost(' ')).rejects.toThrow(BadRequestException); + await expect(service.deleteCost(' ')).rejects.toThrow( + 'name must be a non-empty string', + ); + }); + + it('throws NotFoundException when cost does not exist', async () => { + mockDeletePromise.mockRejectedValue({ + code: 'ConditionalCheckFailedException', + }); + + await expect(service.deleteCost('Food')).rejects.toThrow(NotFoundException); + await expect(service.deleteCost('Food')).rejects.toThrow( + 'Cost with name Food not found', + ); + }); + + it('throws InternalServerErrorException on DynamoDB error', async () => { + mockDeletePromise.mockRejectedValue(new Error('delete failed')); + + await expect(service.deleteCost('Food')).rejects.toThrow( + InternalServerErrorException, + ); + await expect(service.deleteCost('Food')).rejects.toThrow( + 'Failed to delete cost Food', + ); + }); + + it('throws InternalServerErrorException when table name is missing', async () => { + delete process.env.CASHFLOW_COST_TABLE_NAME; + + await expect(service.deleteCost('Food')).rejects.toThrow( + InternalServerErrorException, + ); + await expect(service.deleteCost('Food')).rejects.toThrow( + 'Server configuration error', + ); + }); + }); +}); diff --git a/backend/src/cost/cost.controller.ts b/backend/src/cost/cost.controller.ts new file mode 100644 index 00000000..65003811 --- /dev/null +++ b/backend/src/cost/cost.controller.ts @@ -0,0 +1,153 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + Param, + ParseIntPipe, + Patch, + Post, +} from '@nestjs/common'; +import { + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { CostService } from './cost.service'; +import { CostType } from '../../../middle-layer/types/CostType'; + +interface CreateCostBody { + amount: number; + type: CostType; + name: string; +} + +interface UpdateCostBody { + amount?: number; + type?: CostType; + name?: string; +} + +@ApiTags('cost') +@Controller('cost') +export class CostController { + constructor(private readonly costService: CostService) {} + + /** + * Gets all the costs for cash flow + * @returns array of all CashflowCosts in db + */ + @Get() + @ApiResponse({ + status: 200, + description: 'Successfully retrieved all costs' }) + @ApiResponse({ + status: 500, + description: 'Internal Server Error' }) + async getAllCosts() { + return await this.costService.getAllCosts(); + } + + /** + * gets a cost by name + * @param costName name of cost (e.g. "Intern #1 Salary") + * @returns the cost with the specified name, if it exists + */ + @Get(':costName') + @ApiOperation({ summary: 'Get cost by name' }) + @ApiParam({ name: 'costName', type: String, description: 'Cost Name' }) + @ApiResponse({ status: 200, description: 'Successfully retrieved cost' }) + @ApiResponse({ status: 404, description: 'Cost not found' }) + @ApiResponse({ status: 500, description: 'Internal Server Error' }) + async getCostByName(@Param('costName') costName: string) { + return await this.costService.getCostByName(costName); + } + + /** + * gets costs by type (e.g. Personal Salary, Personal Benefits, etc.) + * @param costType type of cost you are trying to get (e.g. all Salary costs) + * @returns array of costs of the specified type, if any exist + */ + @Get(':costType') + @ApiOperation({ summary: 'Get costs by type' }) + @ApiParam({ name: 'costType', type: String, description: 'Cost Type' }) + @ApiResponse({ status: 200, description: 'Successfully retrieved costs' }) + @ApiResponse({ status: 500, description: 'Internal Server Error' }) + async getCostsByType(@Param('costType') costType: CostType) { + return await this.costService.getCostsByType(costType); + } + + /** + * creates a new cost with the specified fields in the request body + * @param body must include amount, type, and name of the cost to be created + * @returns + */ + @Post() + @ApiOperation({ summary: 'Create a cost' }) + @ApiBody({ + schema: { + type: 'object', + required: ['amount', 'type', 'name'], + properties: { + amount: { type: 'number', example: 12000 }, + type: { + type: 'string', + enum: Object.values(CostType), + example: CostType.Salary, + }, + name: { type: 'string', example: 'Program Manager Salary' }, + }, + }, + }) + @ApiResponse({ status: 201, description: 'Successfully created cost' }) + @ApiResponse({ status: 400, description: 'Bad Request - Invalid cost payload' }) + @ApiResponse({ status: 500, description: 'Internal Server Error' }) + async createCost(@Body() body: CreateCostBody) { + return await this.costService.createCost(body); + } + + @Patch(':costName') + @ApiOperation({ summary: 'Update cost fields by name' }) + @ApiParam({ name: 'costName', type: String, description: 'Cost Name' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + amount: { type: 'number', example: 13000 }, + type: { + type: 'string', + enum: Object.values(CostType), + example: CostType.Benefits, + }, + name: { type: 'string', example: 'Updated Cost Name' }, + }, + }, + }) + @ApiResponse({ status: 200, description: 'Successfully updated cost' }) + @ApiResponse({ status: 400, description: 'Bad Request - Invalid update payload' }) + @ApiResponse({ status: 404, description: 'Cost not found' }) + @ApiResponse({ status: 500, description: 'Internal Server Error' }) + async updateCost( + @Param('costName') costName: string, + @Body() body: UpdateCostBody, + ) { + if (Object.keys(body).length === 0) { + throw new BadRequestException('At least one field is required for update'); + } + + return await this.costService.updateCost(costName, body); + } + + @Delete(':costName') + @ApiOperation({ summary: 'Delete cost by name' }) + @ApiParam({ name: 'costName', type: String, description: 'Cost Name' }) + @ApiResponse({ status: 200, description: 'Successfully deleted cost' }) + @ApiResponse({ status: 404, description: 'Cost not found' }) + @ApiResponse({ status: 500, description: 'Internal Server Error' }) + async deleteCost(@Param('costName') costName: string) { + return await this.costService.deleteCost(costName); + } +} diff --git a/backend/src/cost/cost.module.ts b/backend/src/cost/cost.module.ts new file mode 100644 index 00000000..e04b2173 --- /dev/null +++ b/backend/src/cost/cost.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { CostController } from './cost.controller'; +import { CostService } from './cost.service'; + +@Module({ + controllers: [CostController], + providers: [CostService], +}) +export class CostModule {} diff --git a/backend/src/cost/cost.service.ts b/backend/src/cost/cost.service.ts new file mode 100644 index 00000000..810b5179 --- /dev/null +++ b/backend/src/cost/cost.service.ts @@ -0,0 +1,372 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, +} from '@nestjs/common'; +import * as AWS from 'aws-sdk'; +import { CashflowCost } from '../../../middle-layer/types/CashflowCost'; +import { CostType } from '../../../middle-layer/types/CostType'; + +interface UpdateCostBody { + amount?: number; + type?: CostType; + name?: string; +} + +@Injectable() +export class CostService { + private readonly logger = new Logger(CostService.name); + private dynamoDb = new AWS.DynamoDB.DocumentClient(); + + // Validation helper methods + private validateCostType(type: string) { + if (!Object.values(CostType).includes(type as CostType)) { + throw new BadRequestException( + `type must be one of: ${Object.values(CostType).join(', ')}`, + ); + } + } + + private validateAmount(amount: number) { + if (!Number.isFinite(amount) || amount <= 0) { + throw new BadRequestException('amount must be a finite positive number'); + } + + } + + private validateName(name: string) { + if (name.trim().length === 0) { + throw new BadRequestException('name must be a non-empty string'); + } + } + + // get all costs for cash flow + async getAllCosts(): Promise { + const tableName = process.env.CASHFLOW_COST_TABLE_NAME || ''; + this.logger.log('Retrieving all costs'); + + if (!tableName) { + this.logger.error('CASHFLOW_COST_TABLE_NAME is not defined'); + throw new InternalServerErrorException('Server configuration error'); + } + + try { + const result = await this.dynamoDb + .scan({ + TableName: tableName, + }) + .promise(); + + return (result.Items ?? []) as CashflowCost[]; + } catch (error) { + this.logger.error('Failed to retrieve costs', error as Error); + throw new InternalServerErrorException('Failed to retrieve costs'); + } + } + + // gets a specific cost by its name, the key + async getCostByName(costName: string): Promise { + const tableName = process.env.CASHFLOW_COST_TABLE_NAME || ''; + this.validateName(costName); + const normalizedName = costName.trim(); + this.logger.log(`Retrieving cost with name ${normalizedName}`); + + if (!tableName) { + this.logger.error('CASHFLOW_COST_TABLE_NAME is not defined'); + throw new InternalServerErrorException('Server configuration error'); + } + + try { + const result = await this.dynamoDb + .get({ + TableName: tableName, + Key: { name: normalizedName }, + }) + .promise(); + + if (!result.Item) { + this.logger.error(`Cost with name ${normalizedName} not found`); + throw new NotFoundException(`Cost with name ${normalizedName} not found`); + } + + return result.Item as CashflowCost; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + this.logger.error(`Failed to retrieve cost ${normalizedName}`, error as Error); + throw new InternalServerErrorException(`Failed to retrieve cost ${normalizedName}`); + } + } + + async getCostsByType(costType: CostType): Promise { + const tableName = process.env.CASHFLOW_COST_TABLE_NAME || ''; + this.logger.log(`Retrieving costs with type ${costType}`); + + if (!tableName) { + this.logger.error('CASHFLOW_COST_TABLE_NAME is not defined'); + throw new InternalServerErrorException('Server configuration error'); + } + + try { + const result = await this.dynamoDb + .scan({ + TableName: tableName, + FilterExpression: '#type = :type', + ExpressionAttributeNames: { + '#type': 'type', + }, + ExpressionAttributeValues: { + ':type': costType, + }, + }) + .promise(); + + this.logger.log(`Retrieved ${result.Items?.length ?? 0} costs with type ${costType}`); + return (result.Items ?? []) as CashflowCost[]; + } catch (error) { + this.logger.error(`Failed to retrieve costs with type ${costType}`, error as Error); + throw new InternalServerErrorException(`Failed to retrieve costs with type ${costType}`); + } + } + + async createCost(cost: CashflowCost): Promise { + const tableName = process.env.CASHFLOW_COST_TABLE_NAME || ''; + this.validateAmount(cost.amount); + this.validateCostType(cost.type); + this.validateName(cost.name); + const normalizedName = cost.name.trim(); + + if (!tableName) { + this.logger.error('CASHFLOW_COST_TABLE_NAME is not defined'); + throw new InternalServerErrorException('Server configuration error'); + } + + this.logger.log(`Creating cost with name ${normalizedName}`); + + try { + await this.dynamoDb + .put({ + TableName: tableName, + Item: { + ...cost, + name: normalizedName, + }, + ConditionExpression: 'attribute_not_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, + }) + .promise(); + + return { + ...cost, + name: normalizedName, + }; + } catch (error) { + if (error instanceof BadRequestException) { + throw error; + } + + this.logger.error('Failed to create cost', error as Error); + throw new InternalServerErrorException('Failed to create cost'); + } + } + + async updateCost(costName: string, updates: UpdateCostBody): Promise { + const tableName = process.env.CASHFLOW_COST_TABLE_NAME || ''; + this.validateName(costName); + const normalizedName = costName.trim(); + + if (!tableName) { + this.logger.error('CASHFLOW_COST_TABLE_NAME is not defined'); + throw new InternalServerErrorException('Server configuration error'); + } + + if (updates.amount !== undefined) { + this.validateAmount(updates.amount); + } + if (updates.type !== undefined) { + this.validateCostType(updates.type); + } + if (updates.name !== undefined) { + this.validateName(updates.name); + updates.name = updates.name.trim(); + } + + const shouldRename = + updates.name !== undefined && updates.name.trim() !== normalizedName; + + if (shouldRename) { + const targetName = updates.name as string; + this.logger.log(`Renaming cost ${normalizedName} to ${targetName}`); + + try { + const existingResult = await this.dynamoDb + .get({ + TableName: tableName, + Key: { name: normalizedName }, + }) + .promise(); + + if (!existingResult.Item) { + throw new NotFoundException(`Cost with name ${normalizedName} not found`); + } + + const existingCost = existingResult.Item as CashflowCost; + const renamedCost: CashflowCost = { + name: targetName, + amount: updates.amount ?? existingCost.amount, + type: updates.type ?? existingCost.type, + }; + + await this.dynamoDb + .transactWrite({ + TransactItems: [ + { + Put: { + TableName: tableName, + Item: renamedCost, + ConditionExpression: 'attribute_not_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, + }, + }, + { + Delete: { + TableName: tableName, + Key: { name: normalizedName }, + ConditionExpression: 'attribute_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, + }, + }, + ], + }) + .promise(); + + return renamedCost; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + const awsError = error as { code?: string }; + if (awsError.code === 'ConditionalCheckFailedException') { + throw new BadRequestException(`Cost with name ${targetName} already exists`); + } + + this.logger.error( + `Failed to rename cost ${normalizedName} to ${targetName}`, + error as Error, + ); + throw new InternalServerErrorException( + `Failed to update cost ${normalizedName}`, + ); + } + } + + const nonKeyUpdates: UpdateCostBody = {}; + + if (updates.amount !== undefined) { + nonKeyUpdates.amount = updates.amount; + } + + if (updates.type !== undefined) { + nonKeyUpdates.type = updates.type; + } + + const updateKeys = Object.keys(nonKeyUpdates) as Array; + + if (updateKeys.length === 0) { + throw new BadRequestException('At least one field is required for update'); + } + + const updateExpression = + 'SET ' + updateKeys.map((key) => `#${String(key)} = :${String(key)}`).join(', '); + const expressionAttributeNames = updateKeys.reduce>( + (acc, key) => { + acc[`#${String(key)}`] = String(key); + return acc; + }, + {}, + ); + const expressionAttributeValues = updateKeys.reduce>( + (acc, key) => { + acc[`:${String(key)}`] = nonKeyUpdates[key]; + return acc; + }, + {}, + ); + + this.logger.log(`Updating cost ${normalizedName} with updates: ${JSON.stringify(updates)}`); + + try { + const result = await this.dynamoDb + .update({ + TableName: tableName, + Key: { name: normalizedName }, + UpdateExpression: updateExpression, + ExpressionAttributeNames: { + ...expressionAttributeNames, + '#name': 'name', + }, + ExpressionAttributeValues: expressionAttributeValues, + ConditionExpression: 'attribute_exists(#name)', + ReturnValues: 'ALL_NEW', + }) + .promise(); + + return result.Attributes as CashflowCost; + } catch (error) { + const awsError = error as { code?: string }; + if (awsError.code === 'ConditionalCheckFailedException') { + throw new NotFoundException(`Cost with name ${normalizedName} not found`); + } + + this.logger.error(`Failed to update cost ${normalizedName}`, error as Error); + throw new InternalServerErrorException(`Failed to update cost ${normalizedName}`); + } + } + + async deleteCost(costName: string): Promise { + const tableName = process.env.CASHFLOW_COST_TABLE_NAME || ''; + this.validateName(costName); + const normalizedName = costName.trim(); + + if (!tableName) { + this.logger.error('CASHFLOW_COST_TABLE_NAME is not defined'); + throw new InternalServerErrorException('Server configuration error'); + } + + this.logger.log(`Deleting cost ${normalizedName}`); + + try { + await this.dynamoDb + .delete({ + TableName: tableName, + Key: { name: normalizedName }, + ConditionExpression: 'attribute_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, + }) + .promise(); + + return `Cost ${normalizedName} deleted successfully`; + } catch (error) { + const awsError = error as { code?: string }; + if (awsError.code === 'ConditionalCheckFailedException') { + throw new NotFoundException(`Cost with name ${normalizedName} not found`); + } + + this.logger.error(`Failed to delete cost ${normalizedName}`, error as Error); + throw new InternalServerErrorException(`Failed to delete cost ${normalizedName}`); + } + } +} diff --git a/middle-layer/types/CashflowCost.ts b/middle-layer/types/CashflowCost.ts index c315d9df..338d7b22 100644 --- a/middle-layer/types/CashflowCost.ts +++ b/middle-layer/types/CashflowCost.ts @@ -1,9 +1,7 @@ import { CostType } from "./CostType"; - - export interface CashflowCost { -    amount: number; -    type : CostType; -    name : string; + name: string; + amount: number; + type: CostType; } \ No newline at end of file From 889e25fd55b5850f8433051262c9291964be9161 Mon Sep 17 00:00:00 2001 From: lyannne Date: Thu, 19 Mar 2026 19:54:19 -0400 Subject: [PATCH 7/8] fixed issue with getCostsByType --- backend/src/cost/cost.controller.ts | 3 +-- backend/src/cost/cost.service.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/src/cost/cost.controller.ts b/backend/src/cost/cost.controller.ts index 65003811..2753ffbf 100644 --- a/backend/src/cost/cost.controller.ts +++ b/backend/src/cost/cost.controller.ts @@ -5,7 +5,6 @@ import { Delete, Get, Param, - ParseIntPipe, Patch, Post, } from '@nestjs/common'; @@ -71,7 +70,7 @@ export class CostController { * @param costType type of cost you are trying to get (e.g. all Salary costs) * @returns array of costs of the specified type, if any exist */ - @Get(':costType') + @Get('type/:costType') @ApiOperation({ summary: 'Get costs by type' }) @ApiParam({ name: 'costType', type: String, description: 'Cost Type' }) @ApiResponse({ status: 200, description: 'Successfully retrieved costs' }) diff --git a/backend/src/cost/cost.service.ts b/backend/src/cost/cost.service.ts index 810b5179..34bd3180 100644 --- a/backend/src/cost/cost.service.ts +++ b/backend/src/cost/cost.service.ts @@ -111,6 +111,14 @@ export class CostService { throw new InternalServerErrorException('Server configuration error'); } + const validCostTypes = Object.values(CostType) as CostType[]; + + if (!validCostTypes.includes(costType)) { + throw new BadRequestException( + `costType must be one of: ${Object.values(CostType).join(', ')}`, + ); + } + try { const result = await this.dynamoDb .scan({ From ddf05b0ca492aceff33fcf1a16b2a94185c57ca5 Mon Sep 17 00:00:00 2001 From: lyannne Date: Thu, 19 Mar 2026 21:09:07 -0400 Subject: [PATCH 8/8] copilot comments resolved --- .../src/cost/__test__/cost.service.spec.ts | 5 +- backend/src/cost/cost.controller.ts | 17 +++++ backend/src/cost/cost.service.ts | 9 ++- .../__test__/default-values.service.spec.ts | 65 ++++++++++++++++--- .../default-values.controller.ts | 13 ++-- .../default-values/default-values.service.ts | 27 ++++++-- 6 files changed, 116 insertions(+), 20 deletions(-) diff --git a/backend/src/cost/__test__/cost.service.spec.ts b/backend/src/cost/__test__/cost.service.spec.ts index 1b3370fd..875fc3d5 100644 --- a/backend/src/cost/__test__/cost.service.spec.ts +++ b/backend/src/cost/__test__/cost.service.spec.ts @@ -3,6 +3,7 @@ import { BadRequestException, InternalServerErrorException, NotFoundException, + ConflictException, } from '@nestjs/common'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { CostService } from '../cost.service'; @@ -502,7 +503,7 @@ describe('CostService', () => { ).rejects.toThrow('Cost with name Food not found'); }); - it('throws BadRequestException when rename target already exists', async () => { + it('throws ConflictException when rename target already exists', async () => { mockGetPromise.mockResolvedValue({ Item: { name: 'Food', amount: 200, type: CostType.MealsFood }, }); @@ -512,7 +513,7 @@ describe('CostService', () => { await expect( service.updateCost('Food', { name: 'Meals' }), - ).rejects.toThrow(BadRequestException); + ).rejects.toThrow(ConflictException); await expect( service.updateCost('Food', { name: 'Meals' }), ).rejects.toThrow('Cost with name Meals already exists'); diff --git a/backend/src/cost/cost.controller.ts b/backend/src/cost/cost.controller.ts index 2753ffbf..c390d8e7 100644 --- a/backend/src/cost/cost.controller.ts +++ b/backend/src/cost/cost.controller.ts @@ -7,6 +7,7 @@ import { Param, Patch, Post, + UseGuards } from '@nestjs/common'; import { ApiBody, @@ -14,9 +15,11 @@ import { ApiParam, ApiResponse, ApiTags, + ApiBearerAuth } from '@nestjs/swagger'; import { CostService } from './cost.service'; import { CostType } from '../../../middle-layer/types/CostType'; +import { VerifyAdminRoleGuard } from '../guards/auth.guard'; interface CreateCostBody { amount: number; @@ -40,6 +43,8 @@ export class CostController { * @returns array of all CashflowCosts in db */ @Get() + @UseGuards(VerifyAdminRoleGuard) + @ApiBearerAuth() @ApiResponse({ status: 200, description: 'Successfully retrieved all costs' }) @@ -56,6 +61,8 @@ export class CostController { * @returns the cost with the specified name, if it exists */ @Get(':costName') + @UseGuards(VerifyAdminRoleGuard) + @ApiBearerAuth() @ApiOperation({ summary: 'Get cost by name' }) @ApiParam({ name: 'costName', type: String, description: 'Cost Name' }) @ApiResponse({ status: 200, description: 'Successfully retrieved cost' }) @@ -71,6 +78,8 @@ export class CostController { * @returns array of costs of the specified type, if any exist */ @Get('type/:costType') + @UseGuards(VerifyAdminRoleGuard) + @ApiBearerAuth() @ApiOperation({ summary: 'Get costs by type' }) @ApiParam({ name: 'costType', type: String, description: 'Cost Type' }) @ApiResponse({ status: 200, description: 'Successfully retrieved costs' }) @@ -85,6 +94,8 @@ export class CostController { * @returns */ @Post() + @UseGuards(VerifyAdminRoleGuard) + @ApiBearerAuth() @ApiOperation({ summary: 'Create a cost' }) @ApiBody({ schema: { @@ -103,12 +114,15 @@ export class CostController { }) @ApiResponse({ status: 201, description: 'Successfully created cost' }) @ApiResponse({ status: 400, description: 'Bad Request - Invalid cost payload' }) + @ApiResponse({ status: 409, description: 'Conflict - Cost with the same name already exists' }) @ApiResponse({ status: 500, description: 'Internal Server Error' }) async createCost(@Body() body: CreateCostBody) { return await this.costService.createCost(body); } @Patch(':costName') + @UseGuards(VerifyAdminRoleGuard) + @ApiBearerAuth() @ApiOperation({ summary: 'Update cost fields by name' }) @ApiParam({ name: 'costName', type: String, description: 'Cost Name' }) @ApiBody({ @@ -128,6 +142,7 @@ export class CostController { @ApiResponse({ status: 200, description: 'Successfully updated cost' }) @ApiResponse({ status: 400, description: 'Bad Request - Invalid update payload' }) @ApiResponse({ status: 404, description: 'Cost not found' }) + @ApiResponse({ status: 409, description: 'Conflict - Cost with the updated name already exists' }) @ApiResponse({ status: 500, description: 'Internal Server Error' }) async updateCost( @Param('costName') costName: string, @@ -141,6 +156,8 @@ export class CostController { } @Delete(':costName') + @UseGuards(VerifyAdminRoleGuard) + @ApiBearerAuth() @ApiOperation({ summary: 'Delete cost by name' }) @ApiParam({ name: 'costName', type: String, description: 'Cost Name' }) @ApiResponse({ status: 200, description: 'Successfully deleted cost' }) diff --git a/backend/src/cost/cost.service.ts b/backend/src/cost/cost.service.ts index 34bd3180..cd87e814 100644 --- a/backend/src/cost/cost.service.ts +++ b/backend/src/cost/cost.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, Injectable, InternalServerErrorException, Logger, @@ -175,6 +176,12 @@ export class CostService { name: normalizedName, }; } catch (error) { + const awsError = error as { code?: string }; + + if (awsError.code === 'ConditionalCheckFailedException') { + throw new ConflictException(`Cost with name ${normalizedName} already exists`); + } + if (error instanceof BadRequestException) { throw error; } @@ -266,7 +273,7 @@ export class CostService { const awsError = error as { code?: string }; if (awsError.code === 'ConditionalCheckFailedException') { - throw new BadRequestException(`Cost with name ${targetName} already exists`); + throw new ConflictException(`Cost with name ${targetName} already exists`); } this.logger.error( diff --git a/backend/src/default-values/__test__/default-values.service.spec.ts b/backend/src/default-values/__test__/default-values.service.spec.ts index fa7947de..820ecbce 100644 --- a/backend/src/default-values/__test__/default-values.service.spec.ts +++ b/backend/src/default-values/__test__/default-values.service.spec.ts @@ -67,6 +67,7 @@ describe('DefaultValuesService', () => { }); expect(mockDocumentClient.scan).toHaveBeenCalledWith({ TableName: 'DefaultValues', + ConsistentRead: true, }); }); @@ -163,12 +164,20 @@ describe('DefaultValuesService', () => { describe('updateDefaultValue()', () => { it('should successfully update startingCash', async () => { - mockPromise.mockResolvedValue({ Items: mockDefaultValues }); + mockPromise + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ + Items: [ + { name: 'startingCash', value: 15000 }, + { name: 'benefitsIncrease', value: 3.5 }, + { name: 'salaryIncrease', value: 2.0 }, + ], + }); const result = await service.updateDefaultValue('startingCash', 15000); expect(result).toEqual({ - startingCash: 10000, + startingCash: 15000, benefitsIncrease: 3.5, salaryIncrease: 2.0, }); @@ -178,17 +187,29 @@ describe('DefaultValuesService', () => { name: 'startingCash', value: 15000, }, + ConditionExpression: 'attribute_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, }); }); it('should successfully update benefitsIncrease', async () => { - mockPromise.mockResolvedValue({ Items: mockDefaultValues }); + mockPromise + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ + Items: [ + { name: 'startingCash', value: 10000 }, + { name: 'benefitsIncrease', value: 5.0 }, + { name: 'salaryIncrease', value: 2.0 }, + ], + }); const result = await service.updateDefaultValue('benefitsIncrease', 5.0); expect(result).toEqual({ startingCash: 10000, - benefitsIncrease: 3.5, + benefitsIncrease: 5.0, salaryIncrease: 2.0, }); expect(mockDocumentClient.put).toHaveBeenCalledWith({ @@ -197,18 +218,30 @@ describe('DefaultValuesService', () => { name: 'benefitsIncrease', value: 5.0, }, + ConditionExpression: 'attribute_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, }); }); it('should successfully update salaryIncrease', async () => { - mockPromise.mockResolvedValue({ Items: mockDefaultValues }); + mockPromise + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ + Items: [ + { name: 'startingCash', value: 10000 }, + { name: 'benefitsIncrease', value: 3.5 }, + { name: 'salaryIncrease', value: 3.0 }, + ], + }); const result = await service.updateDefaultValue('salaryIncrease', 3.0); expect(result).toEqual({ startingCash: 10000, benefitsIncrease: 3.5, - salaryIncrease: 2.0, + salaryIncrease: 3.0, }); expect(mockDocumentClient.put).toHaveBeenCalledWith({ TableName: 'DefaultValues', @@ -216,6 +249,10 @@ describe('DefaultValuesService', () => { name: 'salaryIncrease', value: 3.0, }, + ConditionExpression: 'attribute_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, }); }); @@ -261,12 +298,20 @@ describe('DefaultValuesService', () => { }); it('should successfully update with negative value', async () => { - mockPromise.mockResolvedValue({ Items: mockDefaultValues }); + mockPromise + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ + Items: [ + { name: 'startingCash', value: -1000 }, + { name: 'benefitsIncrease', value: 3.5 }, + { name: 'salaryIncrease', value: 2.0 }, + ], + }); const result = await service.updateDefaultValue('startingCash', -1000); expect(result).toEqual({ - startingCash: 10000, + startingCash: -1000, benefitsIncrease: 3.5, salaryIncrease: 2.0, }); @@ -276,6 +321,10 @@ describe('DefaultValuesService', () => { name: 'startingCash', value: -1000, }, + ConditionExpression: 'attribute_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, }); }); diff --git a/backend/src/default-values/default-values.controller.ts b/backend/src/default-values/default-values.controller.ts index b988c605..ed9db851 100644 --- a/backend/src/default-values/default-values.controller.ts +++ b/backend/src/default-values/default-values.controller.ts @@ -1,11 +1,10 @@ -import { Body, Controller, Get, Patch, Logger } from '@nestjs/common'; +import { Body, Controller, Get, Patch, Logger, UseGuards} from '@nestjs/common'; import { ApiBody, ApiResponse, ApiTags } from '@nestjs/swagger'; import { DefaultValuesService } from './default-values.service'; import { DefaultValuesResponse, UpdateDefaultValueBody, } from './types/default-values.types'; -import { UseGuards } from '@nestjs/common'; import { VerifyAdminRoleGuard } from '../guards/auth.guard'; import { ApiBearerAuth } from '@nestjs/swagger'; @@ -22,6 +21,8 @@ export class DefaultValuesController { * @returns DefaultValuesResponse containing the default values */ @Get() + @UseGuards(VerifyAdminRoleGuard) + @ApiBearerAuth() @ApiResponse({ status: 200, description: 'Default values retrieved successfully', @@ -45,6 +46,8 @@ export class DefaultValuesController { * @returns new DefaultValuesResponse with the updated default values */ @Patch() + @UseGuards(VerifyAdminRoleGuard) + @ApiBearerAuth() @ApiBody({ schema: { type: 'object', properties: { @@ -60,12 +63,14 @@ export class DefaultValuesController { status: 400, description: 'Bad Request - Invalid key or value', }) + @ApiResponse({ + status: 404, + description: 'Default value not found', + }) @ApiResponse({ status: 500, description: 'Internal Server Error', }) - @UseGuards(VerifyAdminRoleGuard) - @ApiBearerAuth() async updateDefaultValue( @Body() body: UpdateDefaultValueBody, ): Promise { diff --git a/backend/src/default-values/default-values.service.ts b/backend/src/default-values/default-values.service.ts index a5c6d54a..bcdb8793 100644 --- a/backend/src/default-values/default-values.service.ts +++ b/backend/src/default-values/default-values.service.ts @@ -25,14 +25,16 @@ export class DefaultValuesService { const result = await this.dynamoDb .scan({ TableName: tableName, + // Ensure update responses do not return stale values. + ConsistentRead: true, }) .promise(); const items = (result.Items ?? []); - const startingCash = items.find((item) => item.name === 'startingCash')?.value || null; - const benefitsIncrease = items.find((item) => item.name === 'benefitsIncrease')?.value || null; - const salaryIncrease = items.find((item) => item.name === 'salaryIncrease')?.value || null; + const startingCash = items.find((item) => item.name === 'startingCash')?.value ?? null; + const benefitsIncrease = items.find((item) => item.name === 'benefitsIncrease')?.value ?? null; + const salaryIncrease = items.find((item) => item.name === 'salaryIncrease')?.value ?? null; if (startingCash === null || benefitsIncrease === null || salaryIncrease === null) { this.logger.error('Default values table is missing required fields'); @@ -89,11 +91,26 @@ export class DefaultValuesService { name: key, value, }, + ConditionExpression: 'attribute_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, }) .promise(); - return await this.getDefaultValues(); - } catch (error) { + return await this.getDefaultValues(); + } catch (error) { + const awsError = error as AWS.AWSError; + + if (awsError && awsError.code === 'ConditionalCheckFailedException') { + this.logger.warn( + `Attempted to update non-existent default value '${key}'`, + ); + throw new NotFoundException( + `Default value '${key}' does not exist`, + ); + } + if ( error instanceof BadRequestException || error instanceof NotFoundException ||