From 69f153ee99d30f298a7cc282cda73dd2a5248c06 Mon Sep 17 00:00:00 2001 From: adityapat24 Date: Thu, 5 Feb 2026 12:26:56 -0500 Subject: [PATCH 1/3] controller changes --- backend/src/grant/grant.controller.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/backend/src/grant/grant.controller.ts b/backend/src/grant/grant.controller.ts index 0a590e8..b463860 100644 --- a/backend/src/grant/grant.controller.ts +++ b/backend/src/grant/grant.controller.ts @@ -17,9 +17,10 @@ export class GrantController { @ApiBearerAuth() @ApiOperation({ summary: 'Retrieve all grants', description: 'Returns a list of all grants in the database. Automatically inactivates expired grants.' }) @ApiResponse({ status: 200, description: 'Successfully retrieved all grants', type: [GrantResponseDto] }) + @ApiResponse({ status: 400, description: 'Bad Request - Invalid request parameters or AWS validation error', example: '{Error occurred}' }) @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing authentication token' }) @ApiResponse({ status: 403, description: 'Forbidden - User does not have access to this resource' }) - @ApiResponse({ status: 500, description: 'Internal Server Error', example: 'Internal Server Error' }) + @ApiResponse({ status: 500, description: 'Internal Server Error - AWS error or server configuration issue', example: '{Error occurred}' }) async getAllGrants(): Promise { this.logger.log('GET /grant - Retrieving all grants'); const grants = await this.grantService.getAllGrants(); @@ -33,9 +34,11 @@ export class GrantController { @ApiOperation({ summary: 'Inactivate grants', description: 'Marks one or more grants as inactive by their grant IDs' }) @ApiBody({ type: InactivateGrantBody, description: 'Array of grant IDs to inactivate' }) @ApiResponse({ status: 200, description: 'Successfully inactivated grants', type: [GrantResponseDto] }) + @ApiResponse({ status: 400, description: 'Bad Request - Invalid grant ID or AWS validation error', example: '{Error occurred}' }) + @ApiResponse({ status: 404, description: 'Not Found - Grant does not exist', example: '{Error occurred}' }) @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing authentication token' }) @ApiResponse({ status: 403, description: 'Forbidden - User does not have access to this resource' }) - @ApiResponse({ status: 500, description: 'Internal Server Error', example: 'Internal Server Error' }) + @ApiResponse({ status: 500, description: 'Internal Server Error - AWS error or server configuration issue', example: '{Error occurred}' }) async inactivate( @Body() body: InactivateGrantBody ): Promise { @@ -76,10 +79,10 @@ export class GrantController { @ApiOperation({ summary: 'Update an existing grant', description: 'Updates an existing grant in the database with new grant data' }) @ApiBody({ type: UpdateGrantBody, description: 'Updated grant data including grantId' }) @ApiResponse({ status: 200, description: 'Successfully updated grant', type: String, example: '{"Attributes": {...}}' }) - @ApiResponse({ status: 400, description: 'Bad Request - Invalid grant data', example: '{Error encountered}' }) + @ApiResponse({ status: 400, description: 'Bad Request - Invalid grant data or AWS validation error', example: '{Error occurred}' }) @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing authentication token' }) @ApiResponse({ status: 403, description: 'Forbidden - User does not have access to this resource' }) - @ApiResponse({ status: 500, description: 'Internal Server Error', example: 'Internal Server Error' }) + @ApiResponse({ status: 500, description: 'Internal Server Error - AWS error or server configuration issue', example: '{Error occurred}' }) async saveGrant(@Body() grantData: UpdateGrantBody): Promise { this.logger.log(`PUT /grant/save - Updating grant with ID: ${grantData.grantId}`); const result = await this.grantService.updateGrant(grantData as Grant); @@ -93,10 +96,10 @@ export class GrantController { @ApiOperation({ summary: 'Delete a grant', description: 'Deletes a grant from the database by its grant ID' }) @ApiParam({ name: 'grantId', type: Number, description: 'The ID of the grant to delete' }) @ApiResponse({ status: 200, description: 'Successfully deleted grant', type: String, example: 'Grant 1234567890 deleted successfully' }) - @ApiResponse({ status: 400, description: 'Bad Request - Grant does not exist', example: '{Error encountered}' }) + @ApiResponse({ status: 400, description: 'Bad Request - Invalid grant ID or grant does not exist', example: '{Error occurred}' }) @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing authentication token' }) @ApiResponse({ status: 403, description: 'Forbidden - User does not have access to this resource' }) - @ApiResponse({ status: 500, description: 'Internal Server Error', example: 'Internal Server Error' }) + @ApiResponse({ status: 500, description: 'Internal Server Error - AWS error or server configuration issue', example: '{Error occurred}' }) async deleteGrant(@Param('grantId') grantId: number): Promise { this.logger.log(`DELETE /grant/${grantId} - Deleting grant`); const result = await this.grantService.deleteGrantById(grantId); @@ -110,10 +113,11 @@ export class GrantController { @ApiOperation({ summary: 'Get a grant by ID', description: 'Retrieves a single grant from the database by its grant ID' }) @ApiParam({ name: 'id', type: String, description: 'The ID of the grant to retrieve' }) @ApiResponse({ status: 200, description: 'Successfully retrieved grant', type: GrantResponseDto }) - @ApiResponse({ status: 404, description: 'Grant not found', example: '{Error encountered}' }) + @ApiResponse({ status: 400, description: 'Bad Request - Invalid grant ID or AWS validation error', example: '{Error occurred}' }) + @ApiResponse({ status: 404, description: 'Not Found - Grant does not exist', example: '{Error occurred}' }) @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing authentication token' }) @ApiResponse({ status: 403, description: 'Forbidden - User does not have access to this resource' }) - @ApiResponse({ status: 500, description: 'Internal Server Error', example: 'Internal Server Error' }) + @ApiResponse({ status: 500, description: 'Internal Server Error - AWS error or server configuration issue', example: '{Error occurred}' }) async getGrantById(@Param('id') GrantId: string): Promise { this.logger.log(`GET /grant/${GrantId} - Retrieving grant by ID`); const grant = await this.grantService.getGrantById(parseInt(GrantId, 10)); From 4f194cb96a26621b05c665cc2590dc4314bef41c Mon Sep 17 00:00:00 2001 From: adityapat24 Date: Thu, 5 Feb 2026 12:27:34 -0500 Subject: [PATCH 2/3] service error handling --- backend/src/grant/grant.service.ts | 299 ++++++++++++++++++++++++++--- 1 file changed, 269 insertions(+), 30 deletions(-) diff --git a/backend/src/grant/grant.service.ts b/backend/src/grant/grant.service.ts index 9903fb8..e95ed2f 100644 --- a/backend/src/grant/grant.service.ts +++ b/backend/src/grant/grant.service.ts @@ -1,10 +1,18 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException, BadRequestException, InternalServerErrorException } from '@nestjs/common'; import * as AWS from 'aws-sdk'; import { Grant } from '../../../middle-layer/types/Grant'; import { NotificationService } from '../notifications/notification.service'; import { Notification } from '../../../middle-layer/types/Notification'; import { TDateISO } from '../utils/date'; import { Status } from '../../../middle-layer/types/Status'; + +interface AWSError extends Error { + code?: string; + statusCode?: number; + requestId?: string; + retryable?: boolean; +} + @Injectable() export class GrantService { private readonly logger = new Logger(GrantService.name); @@ -12,6 +20,67 @@ export class GrantService { constructor(private readonly notificationService: NotificationService) {} + /** + * Helper method to check if an error is an AWS error and extract relevant information + */ + private isAWSError(error: unknown): error is AWSError { + return ( + typeof error === 'object' && + error !== null && + ('code' in error || 'statusCode' in error || 'requestId' in error) + ); + } + + /** + * Helper method to handle AWS errors and throw appropriate NestJS exceptions + */ + private handleAWSError(error: AWSError, operation: string, context?: string): never { + const errorContext = context ? ` (${context})` : ''; + const errorDetails = { + code: error.code, + message: error.message, + requestId: error.requestId, + retryable: error.retryable, + }; + + this.logger.error(`AWS Error during ${operation}${errorContext}:`, { + ...errorDetails, + stack: error.stack, + }); + + // Handle specific AWS error codes + switch (error.code) { + case 'ResourceNotFoundException': + throw new BadRequestException( + `AWS DynamoDB Error: Table or resource not found. ${error.message}` + ); + case 'ValidationException': + throw new BadRequestException( + `AWS DynamoDB Validation Error: Invalid request parameters. ${error.message}` + ); + case 'ProvisionedThroughputExceededException': + throw new InternalServerErrorException( + `AWS DynamoDB Error: Request rate too high. Please retry later. ${error.message}` + ); + case 'ThrottlingException': + throw new InternalServerErrorException( + `AWS DynamoDB Error: Request throttled. Please retry later. ${error.message}` + ); + case 'ConditionalCheckFailedException': + throw new BadRequestException( + `AWS DynamoDB Error: Conditional check failed. ${error.message}` + ); + case 'ItemCollectionSizeLimitExceededException': + throw new BadRequestException( + `AWS DynamoDB Error: Item collection size limit exceeded. ${error.message}` + ); + default: + throw new InternalServerErrorException( + `AWS DynamoDB Error during ${operation}: ${error.message || 'Unknown AWS error'}` + ); + } + } + // Retrieves all grants from the database and automatically inactivates expired grants async getAllGrants(): Promise { this.logger.log('Starting to retrieve all grants from database'); @@ -19,6 +88,12 @@ export class GrantService { TableName: process.env.DYNAMODB_GRANT_TABLE_NAME || 'TABLE_FAILURE', }; + // Validate table name + if (params.TableName === 'TABLE_FAILURE') { + this.logger.error('DYNAMODB_GRANT_TABLE_NAME environment variable is not set'); + throw new InternalServerErrorException('Server configuration error: DynamoDB table name not configured'); + } + try { this.logger.debug(`Scanning DynamoDB table: ${params.TableName}`); const data = await this.dynamoDb.scan(params).promise(); @@ -42,10 +117,14 @@ export class GrantService { if (now >= endDate) { this.logger.warn(`Grant ${grant.grantId} has expired and will be marked inactive`); inactiveGrantIds.push(grant.grantId); - let newGrant = this.makeGrantsInactive(grant.grantId) - grants.filter(g => g.grantId !== grant.grantId); - grants.push(await newGrant); - + try { + let newGrant = await this.makeGrantsInactive(grant.grantId); + grants.filter(g => g.grantId !== grant.grantId); + grants.push(newGrant); + } catch (inactiveError) { + this.logger.error(`Failed to inactivate expired grant ${grant.grantId}`, inactiveError instanceof Error ? inactiveError.stack : undefined); + // Continue processing other grants even if one fails to inactivate + } } } } @@ -57,14 +136,31 @@ export class GrantService { this.logger.log(`Successfully retrieved ${grants.length} grants`); return grants; } catch (error) { + if (this.isAWSError(error)) { + this.handleAWSError(error, 'getAllGrants', `table: ${params.TableName}`); + } + + // Handle application logic errors + if (error instanceof BadRequestException || error instanceof InternalServerErrorException) { + throw error; + } + + // Generic error fallback this.logger.error('Failed to retrieve grants from database', error instanceof Error ? error.stack : undefined); - throw new Error('Could not retrieve grants.'); + throw new InternalServerErrorException(`Failed to retrieve grants: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Retrieves a single grant from the database by its unique grant ID async getGrantById(grantId: number): Promise { this.logger.log(`Retrieving grant with ID: ${grantId}`); + + // Validate input + if (!grantId || isNaN(Number(grantId))) { + this.logger.error(`Invalid grant ID provided: ${grantId}`); + throw new BadRequestException(`Invalid grant ID: ${grantId}. Grant ID must be a valid number.`); + } + const params = { TableName: process.env.DYNAMODB_GRANT_TABLE_NAME || 'TABLE_FAILURE', Key: { @@ -72,31 +168,55 @@ export class GrantService { }, }; + // Validate table name + if (params.TableName === 'TABLE_FAILURE') { + this.logger.error('DYNAMODB_GRANT_TABLE_NAME environment variable is not set'); + throw new InternalServerErrorException('Server configuration error: DynamoDB table name not configured'); + } + try { this.logger.debug(`Querying DynamoDB for grant ID: ${grantId}`); const data = await this.dynamoDb.get(params).promise(); if (!data.Item) { this.logger.warn(`Grant with ID ${grantId} not found in database`); - throw new NotFoundException('No grant with id ' + grantId + ' found.'); + throw new NotFoundException(`No grant with id ${grantId} found.`); } this.logger.log(`Successfully retrieved grant ${grantId} from database`); return data.Item as Grant; } catch (error) { + // Re-throw NestJS exceptions if (error instanceof NotFoundException) { this.logger.warn(`Grant ${grantId} not found: ${error.message}`); throw error; } + if (error instanceof BadRequestException || error instanceof InternalServerErrorException) { + throw error; + } + + // Handle AWS errors + if (this.isAWSError(error)) { + this.handleAWSError(error, 'getGrantById', `grantId: ${grantId}`); + } + + // Generic error fallback this.logger.error(`Failed to retrieve grant ${grantId}`, error instanceof Error ? error.stack : undefined); - throw new Error('Failed to retrieve grant.'); + throw new InternalServerErrorException(`Failed to retrieve grant ${grantId}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Marks a grant as inactive by updating its status in the database async makeGrantsInactive(grantId: number): Promise { this.logger.log(`Marking grant ${grantId} as inactive`); + + // Validate input + if (!grantId || isNaN(Number(grantId))) { + this.logger.error(`Invalid grant ID provided: ${grantId}`); + throw new BadRequestException(`Invalid grant ID: ${grantId}. Grant ID must be a valid number.`); + } + let updatedGrant: Grant = {} as Grant; const params = { @@ -112,20 +232,42 @@ export class GrantService { ReturnValues: "ALL_NEW", }; + // Validate table name + if (params.TableName === 'TABLE_FAILURE') { + this.logger.error('DYNAMODB_GRANT_TABLE_NAME environment variable is not set'); + throw new InternalServerErrorException('Server configuration error: DynamoDB table name not configured'); + } + try { this.logger.debug(`Updating grant ${grantId} status to inactive in DynamoDB`); const res = await this.dynamoDb.update(params).promise(); - if (res.Attributes?.status === Status.Inactive) { + if (!res.Attributes) { + this.logger.warn(`Grant ${grantId} update returned no attributes - grant may not exist`); + throw new NotFoundException(`Grant with id ${grantId} not found or could not be updated.`); + } + + if (res.Attributes.status === Status.Inactive) { this.logger.log(`Grant ${grantId} successfully marked as inactive`); - const currentGrant = res.Attributes as Grant; - updatedGrant = currentGrant; + updatedGrant = res.Attributes as Grant; } else { - this.logger.warn(`Grant ${grantId} update failed or no change in status`); + this.logger.warn(`Grant ${grantId} update completed but status is ${res.Attributes.status}, expected ${Status.Inactive}`); + updatedGrant = res.Attributes as Grant; + } + } catch (error) { + // Re-throw NestJS exceptions + if (error instanceof NotFoundException || error instanceof BadRequestException || error instanceof InternalServerErrorException) { + throw error; + } + + // Handle AWS errors + if (this.isAWSError(error)) { + this.handleAWSError(error, 'makeGrantsInactive', `grantId: ${grantId}`); } - } catch (err) { - this.logger.error(`Failed to update grant ${grantId} status to inactive`, err instanceof Error ? err.stack : undefined); - throw new Error(`Failed to update Grant ${grantId} status.`); + + // Generic error fallback + this.logger.error(`Failed to update grant ${grantId} status to inactive`, error instanceof Error ? error.stack : undefined); + throw new InternalServerErrorException(`Failed to update grant ${grantId} status: ${error instanceof Error ? error.message : 'Unknown error'}`); } return updatedGrant; @@ -134,12 +276,33 @@ export class GrantService { // Updates an existing grant in the database with new grant data async updateGrant(grantData: Grant): Promise { + // Validate input - check for null/undefined first + if (!grantData) { + this.logger.error('Invalid grant data provided for update'); + throw new BadRequestException('Invalid grant data: grant object is required.'); + } + this.logger.log(`Updating grant with ID: ${grantData.grantId}`); + if (!grantData.grantId) { + this.logger.error('Invalid grant data provided for update'); + throw new BadRequestException('Invalid grant data: grantId is required.'); + } + + if (isNaN(Number(grantData.grantId))) { + this.logger.error(`Invalid grant ID provided: ${grantData.grantId}`); + throw new BadRequestException(`Invalid grant ID: ${grantData.grantId}. Grant ID must be a valid number.`); + } + const updateKeys = Object.keys(grantData).filter( key => key != 'grantId' ); + if (updateKeys.length === 0) { + this.logger.warn(`No fields to update for grant ${grantData.grantId}`); + throw new BadRequestException('No fields provided to update. At least one field besides grantId is required.'); + } + this.logger.debug(`Updating ${updateKeys.length} fields for grant ${grantData.grantId}: ${updateKeys.join(', ')}`); const UpdateExpression = "SET " + updateKeys.map((key) => `#${key} = :${key}`).join(", "); @@ -157,22 +320,56 @@ export class GrantService { ReturnValues: "UPDATED_NEW", }; + // Validate table name + if (params.TableName === 'TABLE_FAILURE') { + this.logger.error('DYNAMODB_GRANT_TABLE_NAME environment variable is not set'); + throw new InternalServerErrorException('Server configuration error: DynamoDB table name not configured'); + } + try { this.logger.debug(`Executing DynamoDB update for grant ${grantData.grantId}`); const result = await this.dynamoDb.update(params).promise(); this.logger.log(`Successfully updated grant ${grantData.grantId} in database`); //await this.updateGrantNotifications(grantData); return JSON.stringify(result); - } catch(err: unknown) { - this.logger.error(`Failed to update grant ${grantData.grantId} in DynamoDB`, err instanceof Error ? err.stack : undefined); - this.logger.error(`Error details: ${JSON.stringify(err)}`); - throw new Error(`Failed to update Grant ${grantData.grantId}`); + } catch(error: unknown) { + // Re-throw NestJS exceptions + if (error instanceof BadRequestException || error instanceof InternalServerErrorException) { + throw error; + } + + // Handle AWS errors + if (this.isAWSError(error)) { + this.handleAWSError(error, 'updateGrant', `grantId: ${grantData.grantId}`); + } + + // Generic error fallback + this.logger.error(`Failed to update grant ${grantData.grantId} in DynamoDB`, error instanceof Error ? error.stack : undefined); + this.logger.error(`Error details: ${JSON.stringify(error)}`); + throw new InternalServerErrorException(`Failed to update grant ${grantData.grantId}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } // Creates a new grant in the database and generates a unique grant ID async addGrant(grant: Grant): Promise { + // Validate input - check for null/undefined first + if (!grant) { + this.logger.error('Invalid grant data provided'); + throw new BadRequestException('Invalid grant data: grant object is required.'); + } + this.logger.log(`Creating new grant for organization: ${grant.organization}`); + + if (!grant.organization || grant.organization.trim() === '') { + this.logger.error('Invalid organization name provided'); + throw new BadRequestException('Invalid grant data: organization is required.'); + } + + if (!grant.bcan_poc || !grant.bcan_poc.POC_email) { + this.logger.error('Invalid bcan_poc provided'); + throw new BadRequestException('Invalid grant data: bcan_poc with POC_email is required.'); + } + // Generate a unique grant ID (using Date.now() for simplicity, needs proper UUID) const newGrantId = Date.now(); this.logger.debug(`Generated grant ID: ${newGrantId}`); @@ -198,6 +395,12 @@ export class GrantService { } }; + // Validate table name + if (params.TableName === 'TABLE_FAILURE') { + this.logger.error('DYNAMODB_GRANT_TABLE_NAME environment variable is not set'); + throw new InternalServerErrorException('Server configuration error: DynamoDB table name not configured'); + } + try { this.logger.debug(`Inserting grant ${newGrantId} into DynamoDB`); await this.dynamoDb.put(params).promise(); @@ -209,11 +412,22 @@ export class GrantService { //await this.createGrantNotifications({ ...grant, grantId: newGrantId }, userId); this.logger.log(`Successfully created grant ${newGrantId} with all associated data`); - } catch (error: any) { - this.logger.error(`Failed to create new grant for organization: ${grant.organization}`); - this.logger.error(`Error details: ${error.message}`); - this.logger.error(`Stack trace: ${error.stack}`); - throw new Error(`Failed to upload new grant from ${grant.organization}`); + } catch (error: unknown) { + // Re-throw NestJS exceptions + if (error instanceof BadRequestException || error instanceof InternalServerErrorException) { + throw error; + } + + // Handle AWS errors + if (this.isAWSError(error)) { + this.handleAWSError(error, 'addGrant', `organization: ${grant.organization}, grantId: ${newGrantId}`); + } + + // Generic error fallback + this.logger.error(`Failed to create new grant for organization: ${grant.organization}`); + this.logger.error(`Error details: ${error instanceof Error ? error.message : JSON.stringify(error)}`); + this.logger.error(`Stack trace: ${error instanceof Error ? error.stack : undefined}`); + throw new InternalServerErrorException(`Failed to create grant for organization ${grant.organization}: ${error instanceof Error ? error.message : 'Unknown error'}`); } return newGrantId; @@ -222,24 +436,49 @@ export class GrantService { // Deletes a grant from the database by its grant ID async deleteGrantById(grantId: number): Promise { this.logger.log(`Deleting grant with ID: ${grantId}`); + + // Validate input + if (!grantId || isNaN(Number(grantId))) { + this.logger.error(`Invalid grant ID provided: ${grantId}`); + throw new BadRequestException(`Invalid grant ID: ${grantId}. Grant ID must be a valid number.`); + } + const params = { TableName: process.env.DYNAMODB_GRANT_TABLE_NAME || "TABLE_FAILURE", Key: { grantId: Number(grantId) }, ConditionExpression: "attribute_exists(grantId)", // ensures grant exists }; + // Validate table name + if (params.TableName === 'TABLE_FAILURE') { + this.logger.error('DYNAMODB_GRANT_TABLE_NAME environment variable is not set'); + throw new InternalServerErrorException('Server configuration error: DynamoDB table name not configured'); + } + try { this.logger.debug(`Executing DynamoDB delete for grant ${grantId}`); await this.dynamoDb.delete(params).promise(); this.logger.log(`Successfully deleted grant ${grantId} from database`); return `Grant ${grantId} deleted successfully`; - } catch (error: any) { - if (error.code === "ConditionalCheckFailedException") { - this.logger.warn(`Grant ${grantId} does not exist in database`); - throw new Error(`Grant ${grantId} does not exist`); + } catch (error: unknown) { + // Re-throw NestJS exceptions + if (error instanceof BadRequestException || error instanceof InternalServerErrorException) { + throw error; } - this.logger.error(`Failed to delete grant ${grantId}`, error.stack); - throw new Error(`Failed to delete Grant ${grantId}`); + + // Handle AWS errors + if (this.isAWSError(error)) { + // ConditionalCheckFailedException means the grant doesn't exist + if (error.code === "ConditionalCheckFailedException") { + this.logger.warn(`Grant ${grantId} does not exist in database`); + throw new BadRequestException(`Grant ${grantId} does not exist`); + } + this.handleAWSError(error, 'deleteGrantById', `grantId: ${grantId}`); + } + + // Generic error fallback + this.logger.error(`Failed to delete grant ${grantId}`, error instanceof Error ? error.stack : undefined); + throw new InternalServerErrorException(`Failed to delete grant ${grantId}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } From 8e2a09d2cb58c0034b2ea69d9503f7ba4cdc1dc2 Mon Sep 17 00:00:00 2001 From: adityapat24 Date: Thu, 5 Feb 2026 12:27:48 -0500 Subject: [PATCH 3/3] service test fixes --- .../src/grant/__test__/grant.service.spec.ts | 278 +++++++++++++++--- 1 file changed, 234 insertions(+), 44 deletions(-) diff --git a/backend/src/grant/__test__/grant.service.spec.ts b/backend/src/grant/__test__/grant.service.spec.ts index 333159f..ec5a854 100644 --- a/backend/src/grant/__test__/grant.service.spec.ts +++ b/backend/src/grant/__test__/grant.service.spec.ts @@ -6,7 +6,7 @@ type POC = { POC_name: string; POC_email: string }; import { TDateISO } from "../../utils/date"; -import { NotFoundException } from "@nestjs/common"; +import { NotFoundException, BadRequestException, InternalServerErrorException } from "@nestjs/common"; interface Grant { grantId: number; @@ -207,13 +207,44 @@ describe("GrantService", () => { expect(data).toEqual([]); }); - it("should throw an error if there is an issue retrieving the grants", async () => { - const dbError = new Error("Could not retrieve grants"); - mockPromise.mockRejectedValue(dbError); + it("should throw BadRequestException if there is an AWS ResourceNotFoundException", async () => { + const awsError = new Error("AWS DynamoDB error"); + (awsError as any).code = "ResourceNotFoundException"; + mockPromise.mockRejectedValue(awsError); await expect(grantService.getAllGrants()).rejects.toThrow( - "Could not retrieve grants" + BadRequestException ); + await expect(grantService.getAllGrants()).rejects.toThrow( + /AWS DynamoDB Error/ + ); + }); + + it("should throw InternalServerErrorException if there is an AWS throttling error", async () => { + const awsError = new Error("AWS DynamoDB error"); + (awsError as any).code = "ThrottlingException"; + mockPromise.mockRejectedValue(awsError); + + await expect(grantService.getAllGrants()).rejects.toThrow( + InternalServerErrorException + ); + await expect(grantService.getAllGrants()).rejects.toThrow( + /AWS DynamoDB Error/ + ); + }); + + it("should throw InternalServerErrorException if table name is not configured", async () => { + delete process.env.DYNAMODB_GRANT_TABLE_NAME; + + await expect(grantService.getAllGrants()).rejects.toThrow( + InternalServerErrorException + ); + await expect(grantService.getAllGrants()).rejects.toThrow( + /Server configuration error/ + ); + + // Restore for other tests + process.env.DYNAMODB_GRANT_TABLE_NAME = 'Grants'; }); }); @@ -232,14 +263,39 @@ describe("GrantService", () => { }) }); - it("should throw an error if given an invalid id", async () => { - const noGrantFoundError = new NotFoundException( - "No grant with id 5 found." - ); - mockPromise.mockRejectedValue(noGrantFoundError); + it("should throw NotFoundException if grant does not exist", async () => { + mockPromise.mockResolvedValue({ Item: null }); await expect(grantService.getGrantById(5)).rejects.toThrow( - "No grant with id 5 found." + NotFoundException + ); + await expect(grantService.getGrantById(5)).rejects.toThrow( + /No grant with id 5 found/ + ); + }); + + it("should throw BadRequestException if given an invalid grant ID", async () => { + await expect(grantService.getGrantById(null as any)).rejects.toThrow( + BadRequestException + ); + await expect(grantService.getGrantById(undefined as any)).rejects.toThrow( + BadRequestException + ); + await expect(grantService.getGrantById(NaN)).rejects.toThrow( + BadRequestException + ); + }); + + it("should throw InternalServerErrorException if AWS error occurs", async () => { + const awsError = new Error("AWS DynamoDB error"); + (awsError as any).code = "ThrottlingException"; + mockPromise.mockRejectedValue(awsError); + + await expect(grantService.getGrantById(1)).rejects.toThrow( + InternalServerErrorException + ); + await expect(grantService.getGrantById(1)).rejects.toThrow( + /AWS DynamoDB Error/ ); }); }); @@ -283,6 +339,36 @@ describe("GrantService", () => { ReturnValues: "ALL_NEW", }); }); + + it("should throw BadRequestException if grant ID is invalid", async () => { + await expect(grantService.makeGrantsInactive(null as any)).rejects.toThrow( + BadRequestException + ); + await expect(grantService.makeGrantsInactive(NaN)).rejects.toThrow( + BadRequestException + ); + }); + + it("should throw NotFoundException if grant does not exist", async () => { + mockPromise.mockResolvedValue({ Attributes: null }); + + await expect(grantService.makeGrantsInactive(999)).rejects.toThrow( + NotFoundException + ); + }); + + it("should throw InternalServerErrorException if AWS error occurs", async () => { + const awsError = new Error("AWS DynamoDB error"); + (awsError as any).code = "ValidationException"; + mockPromise.mockRejectedValue(awsError); + + await expect(grantService.makeGrantsInactive(1)).rejects.toThrow( + BadRequestException + ); + await expect(grantService.makeGrantsInactive(1)).rejects.toThrow( + /AWS DynamoDB Validation Error/ + ); + }); }); describe("updateGrant()", () => { @@ -332,18 +418,44 @@ describe("GrantService", () => { }) }); - it("should throw an error if the updated grant has an invalid id", async () => { + it("should throw BadRequestException if grant data is invalid", async () => { + await expect(grantService.updateGrant(null as any)).rejects.toThrow( + BadRequestException + ); + await expect(grantService.updateGrant({} as any)).rejects.toThrow( + BadRequestException + ); + }); + + it("should throw BadRequestException if grant ID is invalid", async () => { + const invalidGrant = { ...mockGrants[1], grantId: NaN }; + await expect(grantService.updateGrant(invalidGrant as any)).rejects.toThrow( + BadRequestException + ); + }); + + it("should throw BadRequestException if no fields to update", async () => { + const grantWithOnlyId = { grantId: 1 } as any; + await expect(grantService.updateGrant(grantWithOnlyId)).rejects.toThrow( + BadRequestException + ); + await expect(grantService.updateGrant(grantWithOnlyId)).rejects.toThrow( + /No fields provided to update/ + ); + }); + + it("should throw InternalServerErrorException if AWS error occurs", async () => { const mockUpdatedGrant: Grant = { grantId: 90, organization: mockGrants[1].organization, - does_bcan_qualify: true, // UPDATED - status: Status.Active, // UPDATED + does_bcan_qualify: true, + status: Status.Active, amount: mockGrants[1].amount, application_deadline: mockGrants[1].application_deadline, report_deadlines: mockGrants[1].report_deadlines, description: mockGrants[1].description, timeline: mockGrants[1].timeline, - estimated_completion_time: 400, // UPDATED + estimated_completion_time: 400, grantmaker_poc: mockGrants[1].grantmaker_poc, attachments: mockGrants[1].attachments, grant_start_date: mockGrants[1].grant_start_date, @@ -351,12 +463,15 @@ describe("GrantService", () => { isRestricted: mockGrants[1].isRestricted, }; - mockUpdate.mockRejectedValue({ - promise: vi.fn().mockRejectedValue(new Error()), - }); + const awsError = new Error("AWS DynamoDB error"); + (awsError as any).code = "ProvisionedThroughputExceededException"; + mockPromise.mockRejectedValue(awsError); await expect(grantService.updateGrant(mockUpdatedGrant)).rejects.toThrow( - new Error("Failed to update Grant 90") + InternalServerErrorException + ); + await expect(grantService.updateGrant(mockUpdatedGrant)).rejects.toThrow( + /AWS DynamoDB Error/ ); }); }); @@ -399,34 +514,72 @@ describe("GrantService", () => { }); }); - // decided this test wasn't relevant since you would never pass in something that wasn't a Grant - /* - it("should throw an error if the database put operation fails", async () => { - const mockCreateGrant : Grant = { - organization: "New Org", - description: "New Desc", - grantmaker_poc: { POC_name: "name", POC_email: "email" }, - bcan_poc: { POC_name: "name", POC_email: "email" }, - grant_start_date: "2025-03-01", - application_deadline: "2025-04-01", - report_deadlines: ["2025-05-01"], - timeline: 3, - estimated_completion_time: 200, - does_bcan_qualify: true, - status: Status.Active, - amount: 1500, - attachments: [], - grantId: 0, - isRestricted: false + it("should throw BadRequestException if grant data is invalid", async () => { + await expect(grantService.addGrant(null as any)).rejects.toThrow( + BadRequestException + ); + await expect(grantService.addGrant({} as any)).rejects.toThrow( + BadRequestException + ); + }); + + it("should throw BadRequestException if organization is missing", async () => { + const invalidGrant = { ...mockGrants[0], organization: "" }; + await expect(grantService.addGrant(invalidGrant as any)).rejects.toThrow( + BadRequestException + ); + await expect(grantService.addGrant(invalidGrant as any)).rejects.toThrow( + /organization is required/ + ); + }); + + it("should throw BadRequestException if bcan_poc is missing", async () => { + const invalidGrant = { ...mockGrants[0], bcan_poc: null }; + await expect(grantService.addGrant(invalidGrant as any)).rejects.toThrow( + BadRequestException + ); + await expect(grantService.addGrant(invalidGrant as any)).rejects.toThrow( + /bcan_poc with POC_email is required/ + ); + }); + + it("should throw BadRequestException if AWS ItemCollectionSizeLimitExceededException occurs", async () => { + // Use a grant with valid bcan_poc to pass validation + const validGrant: Grant = { + ...mockGrants[1], // This one has valid bcan_poc + grantId: 0, // Will be replaced by service }; + + const awsError = new Error("AWS DynamoDB error"); + (awsError as any).code = "ItemCollectionSizeLimitExceededException"; + mockPromise.mockRejectedValue(awsError); - mockPut.mockRejectedValue(new Error("DB Error")); + await expect(grantService.addGrant(validGrant)).rejects.toThrow( + BadRequestException + ); + await expect(grantService.addGrant(validGrant)).rejects.toThrow( + /AWS DynamoDB Error/ + ); + }); + + it("should throw InternalServerErrorException if AWS throttling error occurs", async () => { + // Use a grant with valid bcan_poc to pass validation + const validGrant: Grant = { + ...mockGrants[1], // This one has valid bcan_poc + grantId: 0, // Will be replaced by service + }; + + const awsError = new Error("AWS DynamoDB error"); + (awsError as any).code = "ThrottlingException"; + mockPromise.mockRejectedValue(awsError); - await expect(grantService.addGrant(mockCreateGrant)).rejects.toThrow( - "Failed to upload new grant from New Org" + await expect(grantService.addGrant(validGrant)).rejects.toThrow( + InternalServerErrorException + ); + await expect(grantService.addGrant(validGrant)).rejects.toThrow( + /AWS DynamoDB Error/ ); }); - */ }); // Tests for deleteGrantById method @@ -465,13 +618,50 @@ describe('deleteGrantById', () => { .rejects.toThrow(/does not exist/); }); - it('should throw a generic failure when DynamoDB fails for other reasons', async () => { + it('should throw BadRequestException if grant ID is invalid', async () => { + await expect(grantService.deleteGrantById(null as any)) + .rejects.toThrow(BadRequestException); + await expect(grantService.deleteGrantById(NaN)) + .rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when grant does not exist (ConditionalCheckFailedException)', async () => { + const conditionalError = new Error('Conditional check failed'); + (conditionalError as any).code = 'ConditionalCheckFailedException'; + + mockDelete.mockReturnValue({ + promise: vi.fn().mockRejectedValue(conditionalError) + }); + + await expect(grantService.deleteGrantById(999)) + .rejects.toThrow(BadRequestException); + await expect(grantService.deleteGrantById(999)) + .rejects.toThrow(/does not exist/); + }); + + it('should throw InternalServerErrorException when AWS error occurs', async () => { + const awsError = new Error('AWS DynamoDB error'); + (awsError as any).code = 'ThrottlingException'; + + mockDelete.mockReturnValue({ + promise: vi.fn().mockRejectedValue(awsError) + }); + + await expect(grantService.deleteGrantById(123)) + .rejects.toThrow(InternalServerErrorException); + await expect(grantService.deleteGrantById(123)) + .rejects.toThrow(/AWS DynamoDB Error/); + }); + + it('should throw InternalServerErrorException for generic DynamoDB errors', async () => { mockDelete.mockReturnValue({ promise: vi.fn().mockRejectedValue(new Error('Some other DynamoDB error')) }); await expect(grantService.deleteGrantById(123)) - .rejects.toThrow(/Failed to delete/); + .rejects.toThrow(InternalServerErrorException); + await expect(grantService.deleteGrantById(123)) + .rejects.toThrow(/Failed to delete grant/); }); }); describe('Notification helpers', () => {