Skip to content
Open
46 changes: 46 additions & 0 deletions apps/backend/src/animals/animals.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AnimalsController } from './animals.controller';
import { AnimalsService } from './animals.service';
import { Animal } from './animals.entity';

describe('AnimalsController', () => {
let controller: AnimalsController;
let service: { findAll: jest.Mock };

beforeEach(async () => {
service = { findAll: jest.fn() };

const module: TestingModule = await Test.createTestingModule({
controllers: [AnimalsController],
providers: [{ provide: AnimalsService, useValue: service }],
}).compile();

controller = module.get<AnimalsController>(AnimalsController);
});

it('should be defined', () => {
expect(controller).toBeDefined();
});

describe('getAnimals', () => {
it('returns the animals from the service', async () => {
const animals = [{ animalId: 1 }, { animalId: 2 }] as Animal[];
service.findAll.mockResolvedValue(animals);

await expect(controller.getAnimals()).resolves.toBe(animals);
expect(service.findAll).toHaveBeenCalledTimes(1);
});

it('returns an empty array when there are no animals', async () => {
service.findAll.mockResolvedValue([]);

await expect(controller.getAnimals()).resolves.toEqual([]);
});

it('propagates service errors', async () => {
service.findAll.mockRejectedValue(new Error('db down'));

await expect(controller.getAnimals()).rejects.toThrow('db down');
});
});
});
25 changes: 25 additions & 0 deletions apps/backend/src/animals/animals.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Controller, Get, HttpStatus } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AnimalsService } from './animals.service';
import { Animal } from './animals.entity';

@ApiTags('Animals')
@Controller('animals')
export class AnimalsController {
constructor(private animalsService: AnimalsService) {}

@Get()
@ApiOperation({
summary: 'Get all available animals',
description:
'Returns every animal. All animals in the database are currently treated as available.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'The animals, or an empty array when none are available.',
type: [Animal],
})
async getAnimals(): Promise<Animal[]> {
return this.animalsService.findAll();
}
}
2 changes: 2 additions & 0 deletions apps/backend/src/animals/animals.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Animal } from './animals.entity';
import { AnimalsController } from './animals.controller';
import { AnimalsService } from './animals.service';

@Module({
imports: [TypeOrmModule.forFeature([Animal])],
controllers: [AnimalsController],
providers: [AnimalsService],
exports: [AnimalsService],
})
Expand Down
17 changes: 17 additions & 0 deletions apps/backend/src/animals/animals.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ describe('AnimalsService', () => {
expect(service).toBeDefined();
});

describe('findAll', () => {
it('returns every animal', async () => {
mockRepo.find.mockResolvedValue([mockAnimal]);

const result = await service.findAll();

expect(result).toEqual([mockAnimal]);
expect(mockRepo.find).toHaveBeenCalledWith();
});

it('returns an empty array when there are no animals', async () => {
mockRepo.find.mockResolvedValue([]);

await expect(service.findAll()).resolves.toEqual([]);
});
});

describe('findByChameleonIdOrFail', () => {
it('returns the animal when one with the Chameleon ID exists', async () => {
mockRepo.findOneBy.mockResolvedValue(mockAnimal);
Expand Down
12 changes: 12 additions & 0 deletions apps/backend/src/animals/animals.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ export class AnimalsService {
private repo: Repository<Animal>,
) {}

/**
* Fetches every available Animal.
*
* There is no availability tracking yet, so every Animal in the database is
* treated as available.
*
* @returns All Animals, or an empty array when there are none.
*/
async findAll(): Promise<Animal[]> {
return this.repo.find();
}

/**
* Fetches an Animal by its Chameleon animal ID.
*
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/config/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { CreateAnimals1792897315518 } from '../migrations/1792897315518-CreateAn
import { AddVolunteerCoordinatorNotes1793055981679 } from '../migrations/1793055981679-AddVolunteerCoordinatorNotes';
import { ReplaceCanineTrainingWithCompletedTrainings1793150103842 } from '../migrations/1793150103842-ReplaceCanineTrainingWithCompletedTrainings';
import { AddAnimalUpdatesAndFosterTypeArray1793163582751 } from '../migrations/1793163582751-AddAnimalUpdatesAndFosterTypeArray';
import { AddMatchAdditionalComments1793300000000 } from '../migrations/1793300000000-AddMatchAdditionalComments';
import { AddFarmAnimalsToFosterType1793476100199 } from '../migrations/1793476100199-AddFarmAnimalsToFosterType';

const schemaMigrations = [
Expand All @@ -23,6 +24,7 @@ const schemaMigrations = [
AddVolunteerCoordinatorNotes1793055981679,
ReplaceCanineTrainingWithCompletedTrainings1793150103842,
AddAnimalUpdatesAndFosterTypeArray1793163582751,
AddMatchAdditionalComments1793300000000,
AddFarmAnimalsToFosterType1793476100199,
];

Expand Down
6 changes: 5 additions & 1 deletion apps/backend/src/matches/dtos/create-match.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IsInt, IsPositive, MaxLength } from 'class-validator';
import { IsInt, IsPositive, MaxLength, ValidateIf } from 'class-validator';
import { IsNonEmptyString } from '../../utils/decorators/is-non-empty-string.decorator';

export class CreateMatchDto {
Expand All @@ -9,4 +9,8 @@ export class CreateMatchDto {
@IsNonEmptyString()
@MaxLength(255)
chameleonAnimalId!: string;

@ValidateIf((_, value) => value !== undefined && value !== null)
@IsNonEmptyString()
additionalComments?: string;
}
32 changes: 29 additions & 3 deletions apps/backend/src/matches/matches.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { MatchesService } from './matches.service';
import { MatchStatus } from './matches.types';
import { Match } from './matches.entity';
import { DenyMatchDto } from './dtos/deny-match.dto';
import { AnimalsService } from '../animals/animals.service';

describe('MatchesController', () => {
let controller: MatchesController;
Expand All @@ -15,6 +16,7 @@ describe('MatchesController', () => {
approveMatch: jest.Mock;
denyMatch: jest.Mock;
};
let animalsService: { findByChameleonIdOrFail: jest.Mock };

const mockMatch = {
matchId: 1,
Expand All @@ -29,10 +31,14 @@ describe('MatchesController', () => {
approveMatch: jest.fn(),
denyMatch: jest.fn(),
};
animalsService = { findByChameleonIdOrFail: jest.fn() };

const module: TestingModule = await Test.createTestingModule({
controllers: [MatchesController],
providers: [{ provide: MatchesService, useValue: service }],
providers: [
{ provide: MatchesService, useValue: service },
{ provide: AnimalsService, useValue: animalsService },
],
}).compile();

controller = module.get<MatchesController>(MatchesController);
Expand All @@ -48,18 +54,38 @@ describe('MatchesController', () => {

describe('createMatch', () => {
it('passes the body through to the service', async () => {
animalsService.findByChameleonIdOrFail.mockResolvedValue({});
service.create.mockResolvedValue({ matchId: 10 } as Match);
const body = { volunteerId: 1, chameleonAnimalId: 'A42' };

const match = await controller.createMatch(body);

expect(animalsService.findByChameleonIdOrFail).toHaveBeenCalledWith(
'A42',
);
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('does not create a match when the animal does not exist', async () => {
const error = new NotFoundException(
'Animal with Chameleon ID 42 not found',
);
animalsService.findByChameleonIdOrFail.mockRejectedValue(error);

await expect(
controller.createMatch({ volunteerId: 1, chameleonAnimalId: 'A42' }),
).rejects.toThrow(error);
expect(animalsService.findByChameleonIdOrFail).toHaveBeenCalledWith(
'A42',
);
expect(service.create).not.toHaveBeenCalled();
});

// Apart from the animal check, whatever the service throws has to come
// back out untouched.
it('surfaces a NotFoundException from the service', async () => {
animalsService.findByChameleonIdOrFail.mockResolvedValue({});
service.create.mockRejectedValue(
new NotFoundException('Volunteer not found'),
);
Expand Down
7 changes: 6 additions & 1 deletion apps/backend/src/matches/matches.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@ import { Match } from './matches.entity';
import { CreateMatchDto } from './dtos/create-match.dto';
import { validateId } from '../utils/validation.utils';
import { DenyMatchDto } from './dtos/deny-match.dto';
import { AnimalsService } from '../animals/animals.service';

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

@Post()
@ApiOperation({
Expand All @@ -40,6 +44,7 @@ export class MatchesController {
description: 'No volunteer or animal exists with the given ID.',
})
async createMatch(@Body() body: CreateMatchDto): Promise<Match> {
await this.animalsService.findByChameleonIdOrFail(body.chameleonAnimalId);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should move this check into the service as well, and therefore we shouldnt have a diff for this file or the controller tests

return this.matchesService.create(body);
}

Expand Down
3 changes: 3 additions & 0 deletions apps/backend/src/matches/matches.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export class Match {
@Column({ type: 'enum', enum: MatchStatus })
status!: MatchStatus;

@Column({ name: 'additional_comments', type: 'text', nullable: true })
additionalComments!: string | null;

@Column({ name: 'denied_reason', type: 'text', nullable: true })
deniedReason!: string | null;
}
25 changes: 25 additions & 0 deletions apps/backend/src/matches/matches.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,11 +189,36 @@ describe('MatchesService', () => {
volunteerId: 1,
chameleonAnimalId: 'A42',
status: MatchStatus.PENDING,
additionalComments: null,
deniedReason: null,
});
expect(match.status).toEqual(MatchStatus.PENDING);
});

it('saves additional comments when provided', async () => {
volunteersService.findActiveOrFail.mockResolvedValue(
volunteerWithCoordinator,
);
emailsService.sendEmail.mockResolvedValue(undefined);
matchRepo.create.mockImplementation((attrs) => attrs);
matchRepo.save.mockImplementation((match) =>
Promise.resolve({ matchId: 10, ...match }),
);

const match = await service.create({
volunteerId: 1,
chameleonAnimalId: 'A42',
additionalComments: 'Can pick up on weekends',
});

expect(matchRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
additionalComments: 'Can pick up on weekends',
}),
);
expect(match.additionalComments).toEqual('Can pick up on weekends');
});

it("emails the sender address with the volunteer's assigned coordinator cc'd", async () => {
volunteersService.findActiveOrFail.mockResolvedValue(
volunteerWithCoordinator,
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/matches/matches.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export class MatchesService {
volunteerId: dto.volunteerId,
chameleonAnimalId: dto.chameleonAnimalId,
status: MatchStatus.PENDING,
additionalComments: dto.additionalComments ?? null,
deniedReason: null,
}),
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddMatchAdditionalComments1793300000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "matches" ADD COLUMN "additional_comments" text`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "matches" DROP COLUMN "additional_comments"`,
);
}
}
25 changes: 25 additions & 0 deletions apps/backend/src/volunteers/volunteers.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { CoordinatorUpdateVolunteerDto } from './dtos/coordinator-update-volunte
import { Homebase } from '../types';
import { UpdateHomebaseDto } from './dtos/update-homebase.dto';
import { UpdateAnimalPreferencesDto } from './dtos/update-animal-preferences.dto';
import { AnimalsService } from '../animals/animals.service';

describe('VolunteersController', () => {
let controller: VolunteersController;
Expand Down Expand Up @@ -55,6 +56,7 @@ describe('VolunteersController', () => {
coordinatorUpdateVolunteerById: jest.Mock;
};
let mockMatchesService: { findByVolunteerId: jest.Mock };
let mockAnimalsService: { checkAllExistOrFail: jest.Mock };

const mockVolunteers = [
{ volunteerId: 1, firstName: 'Jane', lastName: 'Doe' },
Expand All @@ -77,12 +79,14 @@ describe('VolunteersController', () => {
coordinatorUpdateVolunteerById: jest.fn(),
};
mockMatchesService = { findByVolunteerId: jest.fn() };
mockAnimalsService = { checkAllExistOrFail: jest.fn() };

const module: TestingModule = await Test.createTestingModule({
controllers: [VolunteersController],
providers: [
{ provide: VolunteersService, useValue: mockVolunteersService },
{ provide: MatchesService, useValue: mockMatchesService },
{ provide: AnimalsService, useValue: mockAnimalsService },
],
}).compile();

Expand Down Expand Up @@ -564,6 +568,27 @@ describe('VolunteersController', () => {
).toHaveBeenCalledWith(1, dto);
});

it('does not update preferences when an added animal does not exist', async () => {
const error = new NotFoundException(
'Animals with Chameleon IDs not found: 998, 999',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this error should be A998 and A999

);
mockAnimalsService.checkAllExistOrFail.mockRejectedValue(error);

await expect(
controller.updateAnimalPreferences(1, {
added: ['A55', 'A998', 'A999'],
}),
).rejects.toThrow(error);
expect(mockAnimalsService.checkAllExistOrFail).toHaveBeenCalledWith([
'A55',
'A998',
'A999',
]);
expect(
mockVolunteersService.updateAnimalPreferences,
).not.toHaveBeenCalled();
});

it.each([{}, { added: [] }, { removed: [] }, { added: [], removed: [] }])(
'throws BadRequestException when neither list has IDs: %j',
async (body) => {
Expand Down
Loading
Loading