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
51 changes: 36 additions & 15 deletions backend/src/auth/__test__/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from '../auth.service';
import { NotificationService } from '../../notifications/notification.service';
import { User } from '../../../../middle-layer/types/User';
import { UserStatus } from '../../../../middle-layer/types/UserStatus';
import {
Expand All @@ -9,6 +10,7 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest';
import { GrantService } from '../../grant/grant.service';

// ─── Cognito mocks ────────────────────────────────────────────────────────────
const mockCognitoPromise = vi.fn();
Expand Down Expand Up @@ -106,13 +108,18 @@ describe('AuthService', () => {
mockDynamoPromise.mockResolvedValue({});

const module: TestingModule = await Test.createTestingModule({
providers: [AuthService],
providers: [
AuthService,
{
provide: GrantService,
useValue: { updateGrantsByPOC: vi.fn().mockResolvedValue(undefined) },
},
],
}).compile();

service = module.get<AuthService>(AuthService);
(service as any).grantService = {
updateGrantsByPOC: vi.fn().mockResolvedValue(undefined),
};
service = Object.assign(module.get<AuthService>(AuthService), {
grantService: { updateGrantsByPOC: vi.fn().mockResolvedValue(undefined) },
});
});

// ── register ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -191,22 +198,36 @@ describe('AuthService', () => {
const original = process.env.COGNITO_USER_POOL_ID;
delete process.env.COGNITO_USER_POOL_ID;

const module = await Test.createTestingModule({ providers: [AuthService] }).compile();
const svc = module.get<AuthService>(AuthService);

await expect(svc.register('test@test.com', 'Pass123!', 'John', 'Doe')).rejects.toThrow('Server configuration error');
process.env.COGNITO_USER_POOL_ID = original;
try {
const module = await Test.createTestingModule({
providers: [
AuthService,
{ provide: GrantService, useValue: { updateGrantsByPOC: vi.fn() } },
],
}).compile();
const svc = module.get<AuthService>(AuthService);
await expect(svc.register('test@test.com', 'Pass123!', 'John', 'Doe')).rejects.toThrow('Server configuration error');
} finally {
process.env.COGNITO_USER_POOL_ID = original;
}
});

it('should throw InternalServerErrorException when DYNAMODB_USER_TABLE_NAME is missing', async () => {
const original = process.env.DYNAMODB_USER_TABLE_NAME;
delete process.env.DYNAMODB_USER_TABLE_NAME;

const module = await Test.createTestingModule({ providers: [AuthService] }).compile();
const svc = module.get<AuthService>(AuthService);

await expect(svc.register('test@test.com', 'Pass123!', 'John', 'Doe')).rejects.toThrow('Server configuration error');
process.env.DYNAMODB_USER_TABLE_NAME = original;
try {
const module = await Test.createTestingModule({
providers: [
AuthService,
{ provide: GrantService, useValue: { updateGrantsByPOC: vi.fn() } },
],
}).compile();
const svc = module.get<AuthService>(AuthService);
await expect(svc.register('test@test.com', 'Pass123!', 'John', 'Doe')).rejects.toThrow('Server configuration error');
} finally {
process.env.DYNAMODB_USER_TABLE_NAME = original;
}
});

it('should rollback Cognito user if adminSetUserPassword fails', async () => {
Expand Down
1 change: 1 addition & 0 deletions backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { NotificationsModule } from '../notifications/notification.module';
import { GrantModule } from '../grant/grant.module';

@Module({
Expand Down
71 changes: 36 additions & 35 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -883,42 +883,45 @@ async updateProfile(

throw new InternalServerErrorException("Failed to update user data in database");
}
// ── Step 3: Update grants where user is BCAN POC ──────────────────────
try {
await this.grantService.updateGrantsByPOC(
currentEmail,
newEmail,
`${firstName} ${lastName}`,
);
this.logger.log(`Grants updated for new POC info`);
} catch (grantError: any) {
this.logger.error(`Failed to update grants, rolling back profile changes`, grantError);

// Rollback DynamoDB
await this.dynamoDb.update({
TableName: tableName,
Key: { email: currentEmail },
UpdateExpression: "SET firstName = :firstName, lastName = :lastName, email = :email",
ExpressionAttributeValues: {
":firstName": existingUser.firstName,
":lastName": existingUser.lastName,
":email": currentEmail,
},
ReturnValues: "NONE",
}).promise();
this.logger.log(`DynamoDB rolled back to original values`);

// Rollback Cognito if email changed
if (isEmailChanging) {
await this.cognito.updateUserAttributes({
AccessToken: accessToken,
UserAttributes: [{ Name: "email", Value: currentEmail }],
}).promise();
this.logger.log(`Cognito rolled back to ${currentEmail}`);
}
// ── Step 3: Update grants where user is BCAN POC ──────────────────────
try {
await this.grantService.updateGrantsByPOC(
currentEmail,
newEmail,
`${firstName} ${lastName}`,
);
this.logger.log(`Grants updated for new POC info`);
} catch (grantError: any) {
this.logger.error(`Failed to update grants, rolling back profile changes`, grantError);

throw new InternalServerErrorException("Failed to update grants. All changes have been rolled back.");
// Rollback DynamoDB
await this.dynamoDb.update({
TableName: tableName,
Key: { email: currentEmail },
UpdateExpression: "SET firstName = :firstName, lastName = :lastName, email = :email",
ExpressionAttributeValues: {
":firstName": existingUser.firstName,
":lastName": existingUser.lastName,
":email": currentEmail,
},
ReturnValues: "NONE",
}).promise();
this.logger.log(`DynamoDB rolled back to original values`);

// Rollback Cognito if email changed
if (isEmailChanging) {
await this.cognito.updateUserAttributes({
AccessToken: accessToken,
UserAttributes: [{ Name: "email", Value: currentEmail }],
}).promise();
this.logger.log(`Cognito rolled back to ${currentEmail}`);
}

throw new InternalServerErrorException("Failed to update grants. All changes have been rolled back.");
}


} catch (error) {
if (error instanceof HttpException) {
throw error;
Expand All @@ -935,8 +938,6 @@ async updateProfile(
}
}

// Add this to auth.service.ts

// purpose statement: validates a user's session token via cognito and retrieves user data from dynamodb
// use case: employee is accessing the app with an existing session token
async validateSession(accessToken: string): Promise<any> {
Expand Down
109 changes: 96 additions & 13 deletions backend/src/grant/__test__/grant.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,13 @@ describe("GrantService", () => {
}).compile();

grantService = Object.assign(module.get<GrantService>(GrantService), {
notificationService: {
createNotification: vi.fn(),
updateNotification: vi.fn(),
notificationService: {
createNotification: vi.fn().mockResolvedValue(undefined),
updateNotification: vi.fn().mockResolvedValue(undefined),
getNotificationByUserEmail: vi.fn().mockResolvedValue([]),
deleteNotification: vi.fn().mockResolvedValue('deleted')
getNotificationsByGrantId: vi.fn().mockResolvedValue([]),
updateNotificationsEmailAndOrgByGrantId: vi.fn().mockResolvedValue(undefined),
deleteNotification: vi.fn().mockResolvedValue('deleted'),
}
});

Expand Down Expand Up @@ -397,6 +399,10 @@ describe("GrantService", () => {
estimated_completion_time: mockUpdatedGrant.estimated_completion_time,
};

mockGet.mockReturnValueOnce({
promise: vi.fn().mockResolvedValue({ Item: mockGrants[1] }),
});

mockUpdate.mockReturnValue({
promise: vi.fn().mockResolvedValue({ Attributes: updatedAttributes }),
});
Expand Down Expand Up @@ -715,14 +721,17 @@ describe('Notification helpers', () => {
notificationServiceMock = {
createNotification: vi.fn().mockResolvedValue(undefined),
updateNotification: vi.fn().mockResolvedValue(undefined),
updateNotificationsEmailAndOrgByGrantId: vi.fn().mockResolvedValue(undefined),
getNotificationsByGrantId: vi.fn().mockResolvedValue([]),
deleteNotification: vi.fn().mockResolvedValue(undefined),
};

grantServiceWithMockNotif = new GrantService(notificationServiceMock);
});

describe('getNotificationTimes', () => {
it('should return ISO strings for 14, 7, and 3 days before deadline', () => {
const deadline = '2025-12-25T00:00:00.000Z';
const deadline = '2027-12-25T00:00:00.000Z';
const result = (grantServiceWithMockNotif as any).getNotificationTimes(deadline);

expect(result).toHaveLength(3);
Expand All @@ -749,8 +758,8 @@ describe('Notification helpers', () => {
status: Status.Active,
amount: 10000,
grant_start_date: '2025-01-01',
application_deadline: '2025-12-31T00:00:00.000Z',
report_deadlines: ['2026-01-31T00:00:00.000Z'],
application_deadline: '2027-12-31T00:00:00.000Z',
report_deadlines: ['2027-01-31T00:00:00.000Z'],
description: 'Helping local communities',
timeline: 12,
estimated_completion_time: 365,
Expand All @@ -767,15 +776,17 @@ describe('Notification helpers', () => {
expect(notificationServiceMock.createNotification).toHaveBeenCalledTimes(6);
expect(notificationServiceMock.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
notificationId: expect.stringContaining('-app'),
notificationId: expect.stringContaining('-app-'),
message: expect.stringContaining('Application due in'),
alertTime: expect.any(String),
grantId: 100,
})
);
expect(notificationServiceMock.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
notificationId: expect.stringContaining('-report'),
notificationId: expect.stringContaining('-report-'),
message: expect.stringContaining('Report due in'),
grantId: 100,
})
);
});
Expand Down Expand Up @@ -812,8 +823,8 @@ describe('Notification helpers', () => {
status: Status.Pending,
amount: 5000,
grant_start_date: '2025-01-01',
application_deadline: '2025-06-30T00:00:00.000Z',
report_deadlines: ['2025-07-15T00:00:00.000Z'],
application_deadline: '2027-06-30T00:00:00.000Z',
report_deadlines: ['2027-07-15T00:00:00.000Z'],
description: 'Test desc',
timeline: 1,
estimated_completion_time: 100,
Expand All @@ -828,14 +839,14 @@ describe('Notification helpers', () => {
// Expect 6 updateNotification calls (3 per deadline)
expect(notificationServiceMock.updateNotification).toHaveBeenCalledTimes(6);
expect(notificationServiceMock.updateNotification).toHaveBeenCalledWith(
expect.stringContaining('-app'),
expect.stringContaining('-app-'),
expect.objectContaining({
message: expect.stringContaining('Application due in'),
alertTime: expect.any(String),
})
);
expect(notificationServiceMock.updateNotification).toHaveBeenCalledWith(
expect.stringContaining('-report'),
expect.stringContaining('-report-'),
expect.objectContaining({
message: expect.stringContaining('Report due in'),
})
Expand Down Expand Up @@ -865,5 +876,77 @@ describe('Notification helpers', () => {
expect(notificationServiceMock.updateNotification).not.toHaveBeenCalled();
});
});

describe('updateGrant notification sync', () => {
const baseGrant: Grant = {
grantId: 100,
organization: 'Boston Cares',
does_bcan_qualify: true,
status: Status.Active,
amount: 10000,
grant_start_date: '2025-01-01',
application_deadline: '2025-12-31T00:00:00.000Z',
report_deadlines: [],
description: '',
timeline: 12,
estimated_completion_time: 365,
grantmaker_poc: { POC_name: 'Sarah', POC_email: 'sarah@test.com' },
bcan_poc: { POC_name: 'Tom', POC_email: 'tom@test.com' },
attachments: [],
isRestricted: false,
};

it('should call updateNotificationsEmailAndOrgByGrantId when bcan_poc email changes', async () => {
mockGet.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Item: baseGrant }) });
mockUpdate.mockReturnValue({ promise: vi.fn().mockResolvedValue({ Attributes: {} }) });

const updatedGrant = { ...baseGrant, bcan_poc: { POC_name: 'New POC', POC_email: 'newpoc@test.com' } };
await grantServiceWithMockNotif.updateGrant(updatedGrant);

expect(notificationServiceMock.updateNotificationsEmailAndOrgByGrantId).toHaveBeenCalledWith(
100,
'newpoc@test.com',
'Boston Cares',
);
});

it('should call updateNotificationsEmailAndOrgByGrantId when organization changes', async () => {
mockGet.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Item: baseGrant }) });
mockUpdate.mockReturnValue({ promise: vi.fn().mockResolvedValue({ Attributes: {} }) });

const updatedGrant = { ...baseGrant, organization: 'Boston Cares Updated' };
await grantServiceWithMockNotif.updateGrant(updatedGrant);

expect(notificationServiceMock.updateNotificationsEmailAndOrgByGrantId).toHaveBeenCalledWith(
100,
'tom@test.com',
'Boston Cares Updated',
);
});

it('should not call updateNotificationsEmailAndOrgByGrantId when neither email nor org changed', async () => {
mockGet.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Item: baseGrant }) });
mockUpdate.mockReturnValue({ promise: vi.fn().mockResolvedValue({ Attributes: {} }) });

const updatedGrant = { ...baseGrant, amount: 99999 };
await grantServiceWithMockNotif.updateGrant(updatedGrant);

expect(notificationServiceMock.updateNotificationsEmailAndOrgByGrantId).not.toHaveBeenCalled();
});

it('should not call updateNotificationsEmailAndOrgByGrantId when deadlines change (rebuild takes priority)', async () => {
mockGet.mockReturnValueOnce({ promise: vi.fn().mockResolvedValue({ Item: baseGrant }) });
mockUpdate.mockReturnValue({ promise: vi.fn().mockResolvedValue({ Attributes: {} }) });

const updatedGrant = {
...baseGrant,
bcan_poc: { POC_name: 'New POC', POC_email: 'newpoc@test.com' },
application_deadline: '2026-06-01T00:00:00.000Z' as TDateISO,
};
await grantServiceWithMockNotif.updateGrant(updatedGrant);

expect(notificationServiceMock.updateNotificationsEmailAndOrgByGrantId).not.toHaveBeenCalled();
});
});
});
});
4 changes: 2 additions & 2 deletions backend/src/grant/grant.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Controller, Get, Param, Put, Body, Patch, Post, Delete, ValidationPipe, Logger, UseGuards } from '@nestjs/common';
import { Controller, Get, Param, Put, Body, Patch, Post, Delete, ValidationPipe, Logger, UseGuards, ParseIntPipe } from '@nestjs/common';
import { GrantService } from './grant.service';
import { Grant } from '../../../middle-layer/types/Grant';
import { VerifyUserGuard } from '../guards/auth.guard';
Expand Down Expand Up @@ -100,7 +100,7 @@ export class GrantController {
@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 - AWS error or server configuration issue', example: '{Error occurred}' })
async deleteGrant(@Param('grantId') grantId: number): Promise<string> {
async deleteGrant(@Param('grantId', ParseIntPipe) grantId: number): Promise<string> {
this.logger.log(`DELETE /grant/${grantId} - Deleting grant`);
const result = await this.grantService.deleteGrantById(grantId);
this.logger.log(`DELETE /grant/${grantId} - Successfully deleted grant`);
Expand Down
Loading
Loading