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
4 changes: 4 additions & 0 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import typeorm from './config/typeorm';
import { CognitoModule } from './aws/cognito/cognito.module';
import { MatchesModule } from './matches/matches.module';
import { CoordinatorsModule } from './coordinators/coordinators.module';
import { VolunteersModule } from './volunteers/volunteers.module';

@Module({
Expand All @@ -17,6 +19,8 @@ import { VolunteersModule } from './volunteers/volunteers.module';
configService.getOrThrow('typeorm'),
}),
CognitoModule,
MatchesModule,
CoordinatorsModule,
VolunteersModule,
],
})
Expand Down
170 changes: 170 additions & 0 deletions apps/backend/src/aws/ses/awsSes.wrapper.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { Test } from '@nestjs/testing';
import {
SendEmailCommand,
SendEmailCommandOutput,
} from '@aws-sdk/client-sesv2';
import { AmazonSESWrapper } from './awsSes.wrapper';
import { AMAZON_SES_CLIENT } from './awsSesClient.factory';
import { SendEmailDTO } from './sendEmail.dto';

describe('AmazonSESWrapper', () => {
let wrapper: AmazonSESWrapper;
let mockClient: { send: jest.Mock };

const originalSenderEmail = process.env.AWS_SES_SENDER_EMAIL;

const successOutput: SendEmailCommandOutput = {
MessageId: 'msg-1',
$metadata: { httpStatusCode: 200 },
} as SendEmailCommandOutput;

const validDto: SendEmailDTO = {
toEmail: 'recipient@example.com',
subject: 'Hello',
bodyHtml: '<p>Hi there</p>',
};

/** The SendEmailCommand the wrapper handed to the client on its only send. */
const sentCommand = (): SendEmailCommand =>
mockClient.send.mock.calls[0][0] as SendEmailCommand;

/** The composed MIME message, decoded so headers can be inspected. */
const sentRawMessage = (): string => {
const data = sentCommand().input.Content?.Raw?.Data;
if (!data) {
throw new Error('Expected the sent command to carry a raw MIME message');
}
return Buffer.from(data).toString('utf8');
};

beforeEach(async () => {
mockClient = { send: jest.fn().mockResolvedValue(successOutput) };
process.env.AWS_SES_SENDER_EMAIL = 'sender@example.com';

const moduleRef = await Test.createTestingModule({
providers: [
AmazonSESWrapper,
{ provide: AMAZON_SES_CLIENT, useValue: mockClient },
],
}).compile();

wrapper = moduleRef.get<AmazonSESWrapper>(AmazonSESWrapper);
});

afterEach(() => {
if (originalSenderEmail === undefined) {
delete process.env.AWS_SES_SENDER_EMAIL;
} else {
process.env.AWS_SES_SENDER_EMAIL = originalSenderEmail;
}
});

describe('sendEmail', () => {
it('returns the client output and sends exactly one command', async () => {
const result = await wrapper.sendEmail(validDto);

expect(mockClient.send).toHaveBeenCalledTimes(1);
expect(sentCommand()).toBeInstanceOf(SendEmailCommand);
expect(result).toBe(successOutput);
});

it('addresses the recipient in Destination and in the MIME headers', async () => {
await wrapper.sendEmail(validDto);

expect(mockClient.send).toHaveBeenCalledTimes(1);
expect(sentCommand().input.Destination).toEqual({
Comment thread
Yurika-Kan marked this conversation as resolved.
ToAddresses: ['recipient@example.com'],
});

const raw = sentRawMessage();
expect(raw).toContain('To: recipient@example.com');
expect(raw).toContain('Subject: Hello');
expect(raw).toContain('<p>Hi there</p>');
});

it('uses AWS_SES_SENDER_EMAIL as the From address', async () => {
process.env.AWS_SES_SENDER_EMAIL = 'noreply@mspca.org';

await wrapper.sendEmail(validDto);

expect(mockClient.send).toHaveBeenCalledTimes(1);
expect(sentRawMessage()).toContain('From: noreply@mspca.org');
});

it('puts cc addresses in both Destination and the MIME headers', async () => {
await wrapper.sendEmail({
...validDto,
ccEmails: ['cc1@example.com', 'cc2@example.com'],
});

expect(mockClient.send).toHaveBeenCalledTimes(1);
expect(sentCommand().input.Destination?.CcAddresses).toEqual([
'cc1@example.com',
'cc2@example.com',
]);
expect(sentRawMessage()).toContain(
'Cc: cc1@example.com, cc2@example.com',
);
});

it('puts bcc addresses in Destination but never in the MIME headers', async () => {
await wrapper.sendEmail({
...validDto,
bccEmails: ['bcc@example.com'],
});

expect(mockClient.send).toHaveBeenCalledTimes(1);
expect(sentCommand().input.Destination?.BccAddresses).toEqual([
'bcc@example.com',
]);

// A `Bcc:` header would leak the hidden recipient list to everyone else
// on the message, so it must not appear anywhere in the raw MIME.
// Note: MailComposer also strips Bcc unless `keepBcc: true` is passed,
// so this locks in the end behaviour rather than the wrapper's choice
// to leave `mailOptions.bcc` unset.
const raw = sentRawMessage();
expect(raw).not.toMatch(/^Bcc:/im);
expect(raw).not.toContain('bcc@example.com');
});

it('omits CcAddresses and BccAddresses when the lists are absent or empty', async () => {
await wrapper.sendEmail({ ...validDto, ccEmails: [], bccEmails: [] });

expect(mockClient.send).toHaveBeenCalledTimes(1);
expect(sentCommand().input.Destination).toEqual({
ToAddresses: ['recipient@example.com'],
});
});

it('encodes attachments into the MIME message', async () => {
await wrapper.sendEmail({
...validDto,
attachments: [
{ filename: 'notes.txt', content: Buffer.from('hello attachment') },
],
});

expect(mockClient.send).toHaveBeenCalledTimes(1);
const raw = sentRawMessage();
expect(raw).toContain('filename=notes.txt');
expect(raw).toContain(Buffer.from('hello attachment').toString('base64'));
});

it('propagates errors thrown by the SES client', async () => {
mockClient.send.mockRejectedValue(new Error('SES rejected: throttled'));

await expect(wrapper.sendEmail(validDto)).rejects.toThrow(
'SES rejected: throttled',
);
expect(mockClient.send).toHaveBeenCalledTimes(1);
});

it('wraps non-Error rejections in an Error', async () => {
mockClient.send.mockRejectedValue('throttled');

await expect(wrapper.sendEmail(validDto)).rejects.toThrow('throttled');
expect(mockClient.send).toHaveBeenCalledTimes(1);
});
});
});
11 changes: 11 additions & 0 deletions apps/backend/src/matches/dtos/create-match.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { IsInt, IsPositive } from 'class-validator';

export class CreateMatchDto {
@IsInt()
@IsPositive()
volunteerId!: number;

@IsInt()
@IsPositive()
chameleonAnimalId!: number;
}
92 changes: 91 additions & 1 deletion apps/backend/src/matches/matches.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,31 @@
import { Test, TestingModule } from '@nestjs/testing';
import {
BadRequestException,
NotFoundException,
ValidationPipe,
} from '@nestjs/common';
import { MatchesController } from './matches.controller';
import { MatchesService } from './matches.service';
import { MatchStatus } from './matches.types';
import { Match } from './matches.entity';
import { CreateMatchDto } from './dtos/create-match.dto';

describe('MatchesController', () => {
let controller: MatchesController;
let service: { create: jest.Mock; withdraw: jest.Mock };

beforeEach(async () => {
service = {
Comment thread
Yurika-Kan marked this conversation as resolved.
create: jest.fn(),
withdraw: jest.fn(),
};

const module: TestingModule = await Test.createTestingModule({
controllers: [MatchesController],
providers: [
{
provide: MatchesService,
useValue: {},
useValue: service,
},
],
}).compile();
Expand All @@ -22,4 +36,80 @@ describe('MatchesController', () => {
it('should be defined', () => {
expect(controller).toBeDefined();
});

describe('createMatch', () => {
Comment thread
Yurika-Kan marked this conversation as resolved.
it('passes the body through to the service', async () => {
service.create.mockResolvedValue({ matchId: 10 } as Match);
const body = { volunteerId: 1, chameleonAnimalId: 42 };

const match = await controller.createMatch(body);

expect(service.create).toHaveBeenCalledWith(body);
expect(match.matchId).toEqual(10);
});

// The handler adds no guards of its own, so whatever the service throws
// has to come back out untouched.
it('surfaces a NotFoundException from the service', async () => {
service.create.mockRejectedValue(
new NotFoundException('Volunteer not found'),
);

await expect(
controller.createMatch({ volunteerId: 999, chameleonAnimalId: 42 }),
).rejects.toThrow(NotFoundException);
});

// Invalid bodies never reach the handler - the global ValidationPipe
// rejects them first, so run the real pipe over the DTO here.
it.each([
{ volunteerId: 0, chameleonAnimalId: 42 },
{ volunteerId: -1, chameleonAnimalId: 42 },
{ volunteerId: 1.5, chameleonAnimalId: 42 },
{ volunteerId: 1, chameleonAnimalId: 0 },
{ volunteerId: 1, chameleonAnimalId: 'abc' },
{ chameleonAnimalId: 42 },
])('rejects the invalid body %j', async (body) => {
const pipe = new ValidationPipe({ whitelist: true });

await expect(
pipe.transform(body, { type: 'body', metatype: CreateMatchDto }),
).rejects.toThrow(BadRequestException);
expect(service.create).not.toHaveBeenCalled();
});
});

describe('withdrawMatch', () => {
Comment thread
shreeyaadhikari marked this conversation as resolved.
it('withdraws a match with a valid ID', async () => {
Comment thread
Yurika-Kan marked this conversation as resolved.
service.withdraw.mockResolvedValue({
matchId: 10,
status: MatchStatus.WITHDRAWN,
} as Match);

const match = await controller.withdrawMatch(10);

expect(service.withdraw).toHaveBeenCalledWith(10);
expect(match.status).toEqual(MatchStatus.WITHDRAWN);
});

it('surfaces a NotFoundException from the service', async () => {
service.withdraw.mockRejectedValue(
new NotFoundException('Match not found'),
);

await expect(controller.withdrawMatch(999)).rejects.toThrow(
NotFoundException,
);
});

it('surfaces a BadRequestException from the service', async () => {
service.withdraw.mockRejectedValue(
new BadRequestException('Only pending matches can be withdrawn'),
);

await expect(controller.withdrawMatch(10)).rejects.toThrow(
BadRequestException,
);
});
});
});
73 changes: 72 additions & 1 deletion apps/backend/src/matches/matches.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,78 @@
import { Controller } from '@nestjs/common';
import {
Body,
Controller,
HttpStatus,
Param,
ParseIntPipe,
Patch,
Post,
} from '@nestjs/common';
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { MatchesService } from './matches.service';
import { Match } from './matches.entity';
import { CreateMatchDto } from './dtos/create-match.dto';
import { validateId } from '../utils/validation.utils';

@ApiTags('Matches')
@Controller('matches')
export class MatchesController {
constructor(private matchesService: MatchesService) {}

@Post()
Comment thread
Yurika-Kan marked this conversation as resolved.
@ApiOperation({
summary: 'Create a match',
description:
"Creates a PENDING match between a volunteer and a Chameleon animal and emails the volunteer's assigned foster coordinator.",
})
@ApiResponse({
status: HttpStatus.CREATED,
description: 'The created match, with a PENDING status.',
type: Match,
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description:
'The request body is invalid or the volunteer is not active, so a match cannot be made.',
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'No volunteer exists with the given ID.',
})
async createMatch(@Body() body: CreateMatchDto): Promise<Match> {
return this.matchesService.create(body);
}

@Patch(':matchId/withdraw')
@ApiOperation({
summary: 'Withdraw a match',
description:
'Sets a PENDING match to WITHDRAWN. The record is kept so volunteers can still see their withdrawn applications.',
})
@ApiParam({
name: 'matchId',
type: Number,
description: 'The ID of the match to withdraw.',
example: 10,
})
@ApiResponse({
Comment thread
Yurika-Kan marked this conversation as resolved.
status: HttpStatus.OK,
description: 'The updated match, with a WITHDRAWN status.',
type: Match,
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description:
'The match ID is not a positive integer, or the match is not PENDING and so cannot be withdrawn.',
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'No match exists with the given ID.',
})
async withdrawMatch(
@Param('matchId', ParseIntPipe) matchId: number,
): Promise<Match> {
validateId(matchId, 'Match');

return this.matchesService.withdraw(matchId);
}
}
Loading
Loading