diff --git a/backend/src/grant/__test__/grant.service.spec.ts b/backend/src/grant/__test__/grant.service.spec.ts index b2a4b058..ea588eff 100644 --- a/backend/src/grant/__test__/grant.service.spec.ts +++ b/backend/src/grant/__test__/grant.service.spec.ts @@ -1,31 +1,81 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { GrantController } from '../grant.controller'; -import { GrantService } from '../grant.service'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Test, TestingModule } from "@nestjs/testing"; +import { GrantController } from "../grant.controller"; +import { GrantService } from "../grant.service"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Grant } from "../../types/Grant"; +import { NotFoundException } from "@nestjs/common"; +import { CreateGrantDto } from "../dto/create-grant.dto"; + +enum Status { + Potential = "Potential", + Active = "Active", + Inactive = "Inactive", + Rejected = "Rejected", + Pending = "Pending", +} + +const mockGrants: Grant[] = [ + { + grantId: 1, + organization: "Test Organization", + does_bcan_qualify: true, + status: Status.Potential, + amount: 1000, + application_deadline: "2025-01-01", + report_deadline: "2025-01-01", + notification_date: "2025-01-01", + description: "Test Description", + application_requirements: "Test Application Requirements", + additional_notes: "Test Additional Notes", + timeline: 1, + estimated_completion_time: 100, + grantmaker_poc: ["test@test.com"], + attachments: [], + }, + { + grantId: 2, + organization: "Test Organization 2", + does_bcan_qualify: false, + status: Status.Potential, + amount: 1000, + application_deadline: "2025-02-01", + report_deadline: "2025-03-01", + notification_date: "2025-03-10", + description: "Test Description 2", + application_requirements: "More application requirements", + additional_notes: "More notes", + timeline: 2, + estimated_completion_time: 300, + grantmaker_poc: ["test2@test.com"], + attachments: [], + }, +]; // Create mock functions that we can reference const mockPromise = vi.fn(); const mockScan = vi.fn().mockReturnThis(); const mockGet = vi.fn().mockReturnThis(); +const mockUpdate = vi.fn().mockReturnThis(); +const mockPut = vi.fn().mockReturnThis(); const mockDocumentClient = { scan: mockScan, get: mockGet, + update: mockUpdate, promise: mockPromise, + put: mockPut, }; - - // Mock AWS SDK - Note the structure here -vi.mock('aws-sdk', () => ({ +vi.mock("aws-sdk", () => ({ default: { DynamoDB: { - DocumentClient: vi.fn(() => mockDocumentClient) - } - } + DocumentClient: vi.fn(() => mockDocumentClient), + }, + }, })); -describe('NotificationController', () => { +describe("GrantService", () => { let controller: GrantController; let grantService: GrantService; @@ -33,6 +83,9 @@ describe('NotificationController', () => { // Clear all mocks before each test vi.clearAllMocks(); + // Set the environment variable for the table name + process.env.DYNAMODB_GRANT_TABLE_NAME = 'Grants'; + const module: TestingModule = await Test.createTestingModule({ controllers: [GrantController], providers: [GrantService], @@ -42,16 +95,252 @@ describe('NotificationController', () => { grantService = module.get(GrantService); }); - it('should be defined', () => { + it("should be defined", () => { expect(controller).toBeDefined(); expect(grantService).toBeDefined(); }); - it('Test', async () => { - expect(true).toBe(true); + describe("getAllGrants()", () => { + it("should return a populated list of grants", async () => { + mockPromise.mockResolvedValue({ Items: mockGrants }); + + const data = await grantService.getAllGrants(); + + expect(data).toEqual(mockGrants); + expect(mockDocumentClient.scan).toHaveBeenCalledWith({ + TableName: expect.any(String) + }) + }); + + it("should return an empty list of grants if no grants exist in the database", async () => { + mockPromise.mockResolvedValue({ Items: [] }); + + const data = await grantService.getAllGrants(); + + 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); + + expect(grantService.getAllGrants()).rejects.toThrow( + "Could not retrieve grants" + ); + }); + }); + + describe("getGrantById()", () => { + it("should return the correct grant given a valid id", async () => { + mockPromise.mockResolvedValue({ Item: mockGrants[0] }); + + const data = await grantService.getGrantById(1); + + expect(data).toEqual(mockGrants[0]); + expect(mockDocumentClient.get).toHaveBeenCalledWith({ + TableName: expect.any(String), + Key: { + grantId: 1 + } + }) + }); + + it("should throw an error if given an invalid id", async () => { + const noGrantFoundError = new NotFoundException( + "No grant with id 5 found." + ); + mockPromise.mockRejectedValue(noGrantFoundError); + + expect(grantService.getGrantById(5)).rejects.toThrow( + "No grant with id 5 found." + ); + }); + }); + + 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]; + + expect(firstCallArgs).toMatchObject({ + TableName: "Grants", + Key: { grantId: 1 }, + UpdateExpression: "set isArchived = :archived", + ExpressionAttributeValues: { ":archived": false }, + ReturnValues: "UPDATED_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." + ); + }); + }); + + describe("updateGrant()", () => { + it("should update the correct grant and return a stringified JSON with the updated grant", async () => { + const mockUpdatedGrant: Grant = { + grantId: 2, + organization: mockGrants[1].organization, + does_bcan_qualify: true, // UPDATED + status: Status.Active, // UPDATED + amount: mockGrants[1].amount, + application_deadline: mockGrants[1].application_deadline, + report_deadline: mockGrants[1].report_deadline, + notification_date: mockGrants[1].notification_date, + description: mockGrants[1].description, + application_requirements: mockGrants[1].application_requirements, + additional_notes: "Even MORE notes", // UPDATED + timeline: mockGrants[1].timeline, + estimated_completion_time: 400, // UPDATED + grantmaker_poc: mockGrants[1].grantmaker_poc, + attachments: mockGrants[1].attachments, + }; + const updatedAttributes = { + does_bcan_qualify: mockUpdatedGrant.does_bcan_qualify, + status: mockUpdatedGrant.status, + additional_notes: mockUpdatedGrant.additional_notes, + estimated_completion_time: mockUpdatedGrant.estimated_completion_time, + }; + + mockUpdate.mockReturnValue({ + promise: vi.fn().mockResolvedValue({ Attributes: updatedAttributes }), + }); + + const data = await grantService.updateGrant(mockUpdatedGrant); + + expect(data).toEqual( + JSON.stringify({ + Attributes: updatedAttributes, + }) + ); + expect(mockGrants[0]).toEqual(mockGrants[0]); + expect(mockDocumentClient.update).toHaveBeenCalledWith({ + TableName: expect.any(String), + Key: { grantId: 2 }, + UpdateExpression: expect.any(String), + ExpressionAttributeNames: expect.any(Object), + ExpressionAttributeValues: expect.any(Object), + ReturnValues: "UPDATED_NEW" + }) + }); + + it("should throw an error if the updated grant has an invalid id", async () => { + const mockUpdatedGrant: Grant = { + grantId: 90, + organization: mockGrants[1].organization, + does_bcan_qualify: true, // UPDATED + status: Status.Active, // UPDATED + amount: mockGrants[1].amount, + application_deadline: mockGrants[1].application_deadline, + report_deadline: mockGrants[1].report_deadline, + notification_date: mockGrants[1].notification_date, + description: mockGrants[1].description, + application_requirements: mockGrants[1].application_requirements, + additional_notes: "Even MORE notes", // UPDATED + timeline: mockGrants[1].timeline, + estimated_completion_time: 400, // UPDATED + grantmaker_poc: mockGrants[1].grantmaker_poc, + attachments: mockGrants[1].attachments, + }; + + mockUpdate.mockRejectedValue({ + promise: vi.fn().mockRejectedValue(new Error()), + }); + + await expect(grantService.updateGrant(mockUpdatedGrant)).rejects.toThrow( + new Error("Failed to update Grant 90") + ); + }); }); - it('Test', async () => { - expect(true).toBe(true); + describe("addGrant()", () => { + it("should successfully add a grant and return the new grant id", async () => { + const mockCreateGrantDto: CreateGrantDto = { + organization: "New test organization", + description: + "This is a new organization that does organizational things", + grantmaker_poc: ["newtestorg@test.com"], + application_deadline: "2026-02-14", + report_deadline: "2026-11-05", + notification_date: "2026-10-07", + timeline: 200, + estimated_completion_time: 200, + does_bcan_qualify: true, + status: Status.Potential, + amount: 35000, + attachments: [], + }; + + const now = Date.now() + + mockPut.mockReturnValue({ + promise: vi.fn().mockResolvedValue(now), + }); + + const data = await grantService.addGrant(mockCreateGrantDto); + + expect(data).toEqual(now); + expect(mockDocumentClient.put).toHaveBeenCalledWith({ + TableName: expect.any(String), + Item: { + grantId: expect.any(Number), + ...mockCreateGrantDto, + }, + }); + }); + + it("should throw an error if the database put operation fails", async () => { + const mockCreateGrantDto = { + organization: "New Org", + description: "New Desc", + grantmaker_poc: ["new@test.com"], + application_deadline: "2025-04-01", + notification_date: "2025-04-10", + report_deadline: "2025-05-01", + timeline: 3, + estimated_completion_time: 200, + does_bcan_qualify: true, + status: Status.Active, + amount: 1500, + attachments: [], + }; + + mockPut.mockRejectedValue(new Error("DB Error")); + + await expect(grantService.addGrant(mockCreateGrantDto)).rejects.toThrow( + "Failed to upload new grant from New Org" + ); + }); }); -}); \ No newline at end of file +}); diff --git a/backend/src/grant/grant.service.ts b/backend/src/grant/grant.service.ts index 5ddaaf12..6b8e2502 100644 --- a/backend/src/grant/grant.service.ts +++ b/backend/src/grant/grant.service.ts @@ -1,4 +1,4 @@ -import { Injectable,Logger } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import AWS from 'aws-sdk'; import { Grant } from '../../../middle-layer/types/Grant'; import { CreateGrantDto } from './dto/create-grant.dto'; @@ -39,17 +39,19 @@ export class GrantService { const data = await this.dynamoDb.get(params).promise(); if (!data.Item) { - throw new Error('No grant with id ' + grantId + ' found.'); + throw new NotFoundException('No grant with id ' + grantId + ' found.'); } return data.Item as Grant; } catch (error) { + if (error instanceof NotFoundException) throw error; + console.log(error) throw new Error('Failed to retrieve grant.'); } } - // Method to archive grants takes in array + // Method to unarchive grants takes in array async unarchiveGrants(grantIds :number[]) : Promise { let successfulUpdates: number[] = []; for (const grantId of grantIds) { @@ -68,7 +70,7 @@ export class GrantService { console.log(res) if (res.Attributes && res.Attributes.isArchived === false) { - console.log(`Grant ${grantId} successfully archived.`); + console.log(`Grant ${grantId} successfully un-archived.`); successfulUpdates.push(grantId); } else { console.log(`Grant ${grantId} update failed or no change in status.`);