Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 81 additions & 47 deletions backend/src/grant/__test__/grant.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,40 @@ const mockGrants: Grant[] = [
attachments: [],
isRestricted: true
},
{
grantId: 3,
organization: "Test Organization",
does_bcan_qualify: true,
status: Status.Active,
amount: 1000,
grant_start_date: "2024-01-01",
application_deadline: "2025-01-01",
report_deadlines: ["2025-01-01"],
description: "Test Description",
timeline: 1,
estimated_completion_time: 100,
grantmaker_poc: { POC_name: "name", POC_email: "test@test.com" },
bcan_poc: { POC_name: "name", POC_email: ""},
attachments: [],
isRestricted: false
},
{
grantId: 4,
organization: "Test Organization 2",
does_bcan_qualify: false,
status: Status.Active,
amount: 1000,
grant_start_date: "2025-02-15",
application_deadline: "2025-02-01",
report_deadlines: ["2025-03-01", "2025-04-01"],
description: "Test Description 2",
timeline: 2,
estimated_completion_time: 300,
bcan_poc: { POC_name: "Allie", POC_email: "allie@gmail.com" },
grantmaker_poc: { POC_name: "Benjamin", POC_email: "benpetrillo@yahoo.com" },
attachments: [],
isRestricted: true
},
];

// Create mock functions that we can reference
Expand All @@ -58,6 +92,7 @@ const mockGet = vi.fn().mockReturnThis();
const mockDelete = vi.fn().mockReturnThis();
const mockUpdate = vi.fn().mockReturnThis();
const mockPut = vi.fn().mockReturnThis();
// const mockGetGrantById = vi.fn();

const mockDocumentClient = {
scan: mockScan,
Expand Down Expand Up @@ -99,6 +134,8 @@ describe("GrantService", () => {
updateNotification: vi.fn()
}
});



controller = module.get<GrantController>(GrantController);
grantService = module.get<GrantService>(GrantService);
Expand Down Expand Up @@ -167,53 +204,44 @@ describe("GrantService", () => {
});
});

describe("unarchiveGrants()", () => {
it("should unarchive multiple grants and return their ids", async () => {
mockPromise
.mockResolvedValueOnce({ Attributes: { isArchived: false } })
.mockResolvedValueOnce({ Attributes: { isArchived: false } });

const data = await grantService.unarchiveGrants([1, 2]);

expect(data).toEqual([1, 2]);
expect(mockUpdate).toHaveBeenCalledTimes(2);

const firstCallArgs = mockUpdate.mock.calls[0][0];
const secondCallArgs = mockUpdate.mock.calls[1][0];
describe("makeGrantsInactive()", () => {
it("should inactivate multiple grants and return the updated grant objects", async () => {
const inactiveGrant3 = {
grantId: 3,
organization: "Test Organization",
does_bcan_qualify: true,
status: Status.Inactive,
amount: 1000,
grant_start_date: "2024-01-01",
application_deadline: "2025-01-01",
report_deadlines: ["2025-01-01"],
description: "Test Description",
timeline: 1,
estimated_completion_time: 100,
grantmaker_poc: { POC_name: "name", POC_email: "test@test.com" },
bcan_poc: { POC_name: "name", POC_email: ""},
attachments: [],
isRestricted: false
};

expect(firstCallArgs).toMatchObject({
mockPromise.mockResolvedValueOnce({ Attributes: inactiveGrant3 });

const data = await grantService.makeGrantsInactive(3);

expect(data).toEqual(inactiveGrant3);

expect(mockUpdate).toHaveBeenCalledTimes(1);

const callArgs = mockUpdate.mock.calls[0][0];

expect(callArgs).toMatchObject({
TableName: "Grants",
Key: { grantId: 1 },
UpdateExpression: "set isArchived = :archived",
ExpressionAttributeValues: { ":archived": false },
ReturnValues: "UPDATED_NEW",
Key: { grantId: 3 },
UpdateExpression: "SET #status = :inactiveStatus",
ExpressionAttributeNames: { "#status": "status" },
ExpressionAttributeValues: { ":inactiveStatus": Status.Inactive },
ReturnValues: "ALL_NEW",
});
expect(secondCallArgs).toMatchObject({
TableName: "Grants",
Key: { grantId: 2 },
UpdateExpression: "set isArchived = :archived",
ExpressionAttributeValues: { ":archived": false },
ReturnValues: "UPDATED_NEW",
});
});

it("should skip over grants that are already ", async () => {
mockPromise
.mockResolvedValueOnce({ Attributes: { isArchived: true } })
.mockResolvedValueOnce({ Attributes: { isArchived: false } });

const data = await grantService.unarchiveGrants([1, 2]);

expect(data).toEqual([2]);
expect(mockUpdate).toHaveBeenCalledTimes(2);
});

it("should throw an error if any update call fails", async () => {
mockPromise.mockRejectedValueOnce(new Error("DB Error"));

await expect(grantService.unarchiveGrants([90])).rejects.toThrow(
"Failed to update Grant 90 status."
);
});
});

Expand Down Expand Up @@ -364,7 +392,9 @@ describe("GrantService", () => {
// Tests for deleteGrantById method
describe('deleteGrantById', () => {
it('should call DynamoDB delete with the correct params and return success message', async () => {
mockPromise.mockResolvedValueOnce({});
mockDelete.mockReturnValue({
promise: vi.fn().mockResolvedValue({})
});

const result = await grantService.deleteGrantById('123');

Expand All @@ -387,14 +417,18 @@ describe('deleteGrantById', () => {
const conditionalError = new Error('Conditional check failed');
(conditionalError as any).code = 'ConditionalCheckFailedException';

mockPromise.mockRejectedValueOnce(conditionalError);
mockDelete.mockReturnValue({
promise: vi.fn().mockRejectedValue(conditionalError)
});

await expect(grantService.deleteGrantById('999'))
.rejects.toThrow(/does not exist/);
});

it('should throw a generic failure when DynamoDB fails for other reasons', async () => {
mockPromise.mockRejectedValueOnce(new Error('Some other DynamoDB error'));
mockDelete.mockReturnValue({
promise: vi.fn().mockRejectedValue(new Error('Some other DynamoDB error'))
});

await expect(grantService.deleteGrantById('123'))
.rejects.toThrow(/Failed to delete/);
Expand Down
31 changes: 15 additions & 16 deletions backend/src/grant/grant.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,19 @@ export class GrantController {
return await this.grantService.getAllGrants();
}

@Get(':id')
async getGrantById(@Param('id') GrantId: string) {
return await this.grantService.getGrantById(parseInt(GrantId, 10));
}

@Put('archive')
async archive(
@Body('grantIds') grantIds: number[]
): Promise<number[]> {
return await this.grantService.unarchiveGrants(grantIds)
}


@Put('unarchive')
async unarchive(
@Put('inactivate')
async inactivate(
@Body('grantIds') grantIds: number[]
): Promise<number[]> {
return await this.grantService.unarchiveGrants(grantIds)
): Promise<Grant[]> {
let grants: Grant[] = [];
for(const id of grantIds){
Logger.log(`Inactivating grant with ID: ${id}`);
let newGrant = await this.grantService.makeGrantsInactive(id)
grants.push(newGrant);
}
return grants;
}

@Post('new-grant')
Expand All @@ -47,5 +43,8 @@ export class GrantController {
async deleteGrant(@Param('grantId') grantId: string) {
return await this.grantService.deleteGrantById(grantId);
}

@Get(':id')
async getGrantById(@Param('id') GrantId: string) {
return await this.grantService.getGrantById(parseInt(GrantId, 10));
}
}
95 changes: 61 additions & 34 deletions backend/src/grant/grant.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Grant } from '../../../middle-layer/types/Grant';
import { NotificationService } from '.././notifications/notifcation.service';
import { Notification } from '../../../middle-layer/types/Notification';
import { TDateISO } from '../utils/date';
import { Status } from '../../../middle-layer/types/Status';
@Injectable()
export class GrantService {
private readonly logger = new Logger(GrantService.name);
Expand All @@ -19,9 +20,31 @@ export class GrantService {
};

try {
const data = await this.dynamoDb.scan(params).promise();
const data = await this.dynamoDb.scan(params).promise();
const grants = (data.Items as Grant[]) || [];
const inactiveGrantIds: number[] = [];
const now = new Date();

for (const grant of grants) {
if (grant.status === "Active") {
const startDate = new Date(grant.grant_start_date);

// add timeline years to start date
const endDate = new Date(startDate);
endDate.setFullYear(
endDate.getFullYear() + grant.timeline
);

if (now >= endDate) {
inactiveGrantIds.push(grant.grantId);
let newGrant = this.makeGrantsInactive(grant.grantId)
grants.filter(g => g.grantId !== grant.grantId);
grants.push(await newGrant);

return data.Items as Grant[] || [];
}
}
}
return grants;
} catch (error) {
console.log(error)
throw new Error('Could not retrieve grants.');
Expand Down Expand Up @@ -54,38 +77,43 @@ export class GrantService {
}
}

// Method to unarchive grants takes in array
async unarchiveGrants(grantIds :number[]) : Promise<number[]> {
let successfulUpdates: number[] = [];
for (const grantId of grantIds) {
const params = {
TableName: process.env.DYNAMODB_GRANT_TABLE_NAME || 'TABLE_FAILURE',
Key: {
grantId: grantId,
},
UpdateExpression: "set isArchived = :archived",
ExpressionAttributeValues: { ":archived": false },
ReturnValues: "UPDATED_NEW",
};
// Method to make grants inactive
async makeGrantsInactive(grantId: number): Promise<Grant> {
let updatedGrant: Grant = {} as Grant;

try{
const res = await this.dynamoDb.update(params).promise();
console.log(res)
const params = {
TableName: process.env.DYNAMODB_GRANT_TABLE_NAME || "TABLE_FAILURE",
Key: { grantId },
UpdateExpression: "SET #status = :inactiveStatus",
ExpressionAttributeNames: {
"#status": "status",
},
ExpressionAttributeValues: {
":inactiveStatus": Status.Inactive as String,
},
ReturnValues: "ALL_NEW",
};

try {
const res = await this.dynamoDb.update(params).promise();

if (res.Attributes?.status === Status.Inactive) {
console.log(`Grant ${grantId} successfully marked as inactive.`);

const currentGrant = res.Attributes as Grant;
console.log(currentGrant);
updatedGrant = currentGrant
} else {
console.log(`Grant ${grantId} update failed or no change in status.`);
}
} catch (err) {
console.log(err);
throw new Error(`Failed to update Grant ${grantId} status.`);
}

return updatedGrant;
}

if (res.Attributes && res.Attributes.isArchived === false) {
console.log(`Grant ${grantId} successfully un-archived.`);
successfulUpdates.push(grantId);
} else {
console.log(`Grant ${grantId} update failed or no change in status.`);
}
}
catch(err){
console.log(err);
throw new Error(`Failed to update Grant ${grantId} status.`);
}
};
return successfulUpdates;
}

/**
* Will push or overwrite new grant data to database
Expand Down Expand Up @@ -175,15 +203,14 @@ export class GrantService {
try {
await this.dynamoDb.delete(params).promise();
this.logger.log(`Grant ${grantId} deleted successfully`);
return 'Grant ${grantId} deleted successfully';
return `Grant ${grantId} deleted successfully`;
} catch (error: any) {
if (error.code === "ConditionalCheckFailedException") {
throw new Error(`Grant ${grantId} does not exist`);
}
this.logger.error(`Failed to delete Grant ${grantId}`, error.stack);
throw new Error(`Failed to delete Grant ${grantId}`);
}

}

/*
Expand Down