Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/backend/src/config/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { InitialSchemas1789354568323 } from '../migrations/1789354568323-Initial
import { AddFosterVolunteerCoordinatorAssignmentRelation1790820106597 } from '../migrations/1790820106597-AddFosterVolunteerCoordinatorAssignmentRelation';
import { ReplaceVolunteerActiveWithStatus1790998545137 } from '../migrations/1790998545137-ReplaceVolunteerActiveWithStatus';
import { AddVolunteerCognitoSub1791000000000 } from '../migrations/1791000000000-AddVolunteerCognitoSub';
import { AddAnimalUpdatesAndFosterTypeArray1791429623582 } from '../migrations/1791429623582-AddAnimalUpdatesAndFosterTypeArray';

const schemaMigrations = [
InitialSchemas1789354568323,
AddFosterVolunteerCoordinatorAssignmentRelation1790820106597,
ReplaceVolunteerActiveWithStatus1790998545137,
AddVolunteerCognitoSub1791000000000,
AddAnimalUpdatesAndFosterTypeArray1791429623582,
];

export default schemaMigrations;
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddAnimalUpdatesAndFosterTypeArray1791429623582
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "foster_volunteers" ADD "animal_updates" boolean NOT NULL DEFAULT true`,
);
await queryRunner.query(
`ALTER TABLE "foster_volunteers"
ALTER COLUMN "foster_type" TYPE "foster_type_enum"[]
USING ARRAY["foster_type"]`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "foster_volunteers"
ALTER COLUMN "foster_type" TYPE "foster_type_enum"
USING "foster_type"[1]`,
);
await queryRunner.query(
`ALTER TABLE "foster_volunteers" DROP COLUMN "animal_updates"`,
);
}
}
53 changes: 53 additions & 0 deletions apps/backend/src/volunteers/dtos/create-volunteer.dto.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CreateVolunteerDto } from './create-volunteer.dto';
import { FosterType } from '../volunteers.types';
import { Homebase } from '../../types';

const validBody = {

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 normally don't write test files for our dtos. this is because we are relying under the assumption that our validators work and will make sure we have valid inputs. our class-validators are coming from nestjs, which we assume already works.

because of this, do you think we could delete the two spec files here?

firstName: 'Jane',
lastName: 'Doe',
phone: '617-555-0100',
email: 'jane@example.com',
address: '350 S Huntington Ave',
city: 'Boston',
zipcode: '02130',
homebase: Homebase.BOSTON,
residentAnimals: 'One cat',
fosterType: [FosterType.CAT],
};

async function fosterTypeErrors(fosterType: unknown) {
const errors = await validate(
plainToInstance(CreateVolunteerDto, { ...validBody, fosterType }),
);
return errors.filter((e) => e.property === 'fosterType');
}

describe('CreateVolunteerDto fosterType', () => {
it('accepts a list with one type', async () => {
expect(await fosterTypeErrors([FosterType.DOG])).toHaveLength(0);
});

it('accepts a list with several types', async () => {
expect(
await fosterTypeErrors([FosterType.DOG, FosterType.CAT]),
).toHaveLength(0);
});

it('rejects an empty list', async () => {
expect(await fosterTypeErrors([])).not.toHaveLength(0);
});

it('rejects a single value that is not in a list', async () => {
expect(await fosterTypeErrors(FosterType.DOG)).not.toHaveLength(0);
});

it('rejects a list containing an invalid type', async () => {
expect(await fosterTypeErrors(['Dragon'])).not.toHaveLength(0);
});

it('rejects a missing fosterType', async () => {
expect(await fosterTypeErrors(undefined)).not.toHaveLength(0);
});
});
8 changes: 6 additions & 2 deletions apps/backend/src/volunteers/dtos/create-volunteer.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
} from 'class-validator';
import { Homebase } from '../../types';
import { FosterType } from '../volunteers.types';
import { ArrayNotEmpty } from 'class-validator';
import { IsArray } from 'class-validator';

export class CreateVolunteerDto {
@IsString()
Expand Down Expand Up @@ -61,8 +63,10 @@ export class CreateVolunteerDto {
@IsString()
notes?: string;

@IsEnum(FosterType)
fosterType!: FosterType;
@IsArray()
@ArrayNotEmpty()
@IsEnum(FosterType, { each: true })
fosterType!: FosterType[];

@IsOptional()
@IsBoolean()
Expand Down
33 changes: 33 additions & 0 deletions apps/backend/src/volunteers/dtos/update-animal-updates.dto.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { UpdateAnimalUpdatesDto } from './update-animal-updates.dto';

async function errorsFor(body: unknown) {
return validate(plainToInstance(UpdateAnimalUpdatesDto, body));
}

describe('UpdateAnimalUpdatesDto', () => {
it('accepts true', async () => {
expect(await errorsFor({ animalUpdates: true })).toHaveLength(0);
});

it('accepts false', async () => {
expect(await errorsFor({ animalUpdates: false })).toHaveLength(0);
});

it('rejects an empty body', async () => {
expect(await errorsFor({})).not.toHaveLength(0);
});

it('rejects a string', async () => {
expect(await errorsFor({ animalUpdates: 'true' })).not.toHaveLength(0);
});

it('rejects a number', async () => {
expect(await errorsFor({ animalUpdates: 1 })).not.toHaveLength(0);
});

it('rejects null', async () => {
expect(await errorsFor({ animalUpdates: null })).not.toHaveLength(0);
});
});
6 changes: 6 additions & 0 deletions apps/backend/src/volunteers/dtos/update-animal-updates.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';

export class UpdateAnimalUpdatesDto {
@IsBoolean()
animalUpdates!: boolean;
}
34 changes: 33 additions & 1 deletion apps/backend/src/volunteers/volunteers.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe('VolunteersController', () => {
findByIdOrFail: jest.Mock;
deactivate: jest.Mock;
reactivate: jest.Mock;
updateAnimalUpdates: jest.Mock;
};
let matchesService: { findByVolunteerId: jest.Mock };

Expand All @@ -27,6 +28,7 @@ describe('VolunteersController', () => {
findByIdOrFail: jest.fn(),
deactivate: jest.fn(),
reactivate: jest.fn(),
updateAnimalUpdates: jest.fn(),
};
matchesService = { findByVolunteerId: jest.fn() };

Expand Down Expand Up @@ -56,7 +58,7 @@ describe('VolunteersController', () => {
zipcode: '02130',
homebase: Homebase.BOSTON,
residentAnimals: 'One cat',
fosterType: FosterType.CAT,
fosterType: [FosterType.CAT],
};

it('is public so volunteers can sign up without a token', () => {
Expand Down Expand Up @@ -181,4 +183,34 @@ describe('VolunteersController', () => {
);
});
});
describe('updateAnimalUpdates', () => {
it('should call service.updateAnimalUpdates with the parsed id and body', async () => {
const dto = { animalUpdates: false };
const volunteer = {
volunteerId: 1,
animalUpdates: false,
} as FosterVolunteer;
volunteersService.updateAnimalUpdates.mockResolvedValue(volunteer);

const result = await controller.updateAnimalUpdates(1, dto);

expect(volunteersService.updateAnimalUpdates).toHaveBeenCalledWith(
1,
dto,
);
expect(result).toEqual(volunteer);
});

it('propagates errors thrown by the service', async () => {
volunteersService.updateAnimalUpdates.mockRejectedValue(
new NotFoundException('Volunteer with ID 999 not found'),
);

await expect(
controller.updateAnimalUpdates(999, { animalUpdates: true }),
).rejects.toThrow(
new NotFoundException('Volunteer with ID 999 not found'),
);
});
});
});
27 changes: 27 additions & 0 deletions apps/backend/src/volunteers/volunteers.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { validateId } from '../utils/validation.utils';
import { FosterVolunteer } from './volunteers.entity';
import { CreateVolunteerDto } from './dtos/create-volunteer.dto';
import { Public } from '../aws/cognito/cognito.decorator';
import { UpdateAnimalUpdatesDto } from './dtos/update-animal-updates.dto';

@ApiTags('Volunteers')
@Controller('volunteers')
Expand Down Expand Up @@ -96,4 +97,30 @@ export class VolunteersController {
validateId(id, 'Volunteer');
return this.volunteersService.reactivate(id);
}

@ApiOperation({
summary: 'Opt a foster volunteer in or out of animal updates',
})
@ApiParam({ name: 'id', type: Number, description: 'Volunteer ID' })
@ApiResponse({
status: HttpStatus.OK,
description: 'The volunteer was updated',
type: FosterVolunteer,
})
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: 'The ID is malformed or the body is not a boolean',
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'No volunteer with this ID exists',
})
@Patch('/:id/animal-updates')
async updateAnimalUpdates(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateAnimalUpdatesDto,
): Promise<FosterVolunteer> {
validateId(id, 'Volunteer');
return this.volunteersService.updateAnimalUpdates(id, dto);
}
}
7 changes: 5 additions & 2 deletions apps/backend/src/volunteers/volunteers.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,11 @@ export class FosterVolunteer {
@Column({ type: 'text', nullable: true })
notes!: string | null;

@Column({ name: 'foster_type', type: 'enum', enum: FosterType })
fosterType!: FosterType;
@Column({ name: 'foster_type', type: 'enum', enum: FosterType, array: true })
fosterType!: FosterType[];

@Column({ name: 'animal_updates', type: 'boolean', default: true })
animalUpdates!: boolean;

@Column({
name: 'completed_canine_training',
Expand Down
55 changes: 54 additions & 1 deletion apps/backend/src/volunteers/volunteers.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ describe('VolunteersService', () => {
homebase: Homebase.BOSTON,
residentAnimals: 'One cat',
notes: 'Prefers kittens',
fosterType: FosterType.CAT,
fosterType: [FosterType.CAT],
};

beforeEach(() => {
Expand Down Expand Up @@ -299,6 +299,59 @@ describe('VolunteersService', () => {
});
});

describe('updateAnimalUpdates', () => {
it('opts the volunteer out and returns the updated volunteer', async () => {
const volunteer = {
volunteerId: 1,
animalUpdates: true,
} as FosterVolunteer;
repo.findOne.mockResolvedValue(volunteer);
repo.save.mockImplementation(async (v) => v);

const result = await service.updateAnimalUpdates(1, {
animalUpdates: false,
});

expect(repo.findOne).toHaveBeenCalledWith({
where: { volunteerId: 1 },
relations: ['assignedCoordinator'],
});
expect(repo.save).toHaveBeenCalledWith(
expect.objectContaining({ volunteerId: 1, animalUpdates: false }),
);
expect(result.animalUpdates).toBe(false);
});

it('opts the volunteer back in', async () => {
const volunteer = {
volunteerId: 1,
animalUpdates: false,
} as FosterVolunteer;
repo.findOne.mockResolvedValue(volunteer);
repo.save.mockImplementation(async (v) => v);

const result = await service.updateAnimalUpdates(1, {
animalUpdates: true,
});

expect(repo.save).toHaveBeenCalledWith(
expect.objectContaining({ volunteerId: 1, animalUpdates: true }),
);
expect(result.animalUpdates).toBe(true);
});

it('throws NotFoundException if volunteer does not exist', async () => {
repo.findOne.mockResolvedValue(null);

await expect(
service.updateAnimalUpdates(999, { animalUpdates: false }),
).rejects.toThrow(
new NotFoundException('Volunteer with ID 999 not found'),
);
expect(repo.save).not.toHaveBeenCalled();
});
});

describe('reactivate', () => {
it('sets status to Active and saves the volunteer', async () => {
const volunteer = {
Expand Down
21 changes: 21 additions & 0 deletions apps/backend/src/volunteers/volunteers.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { VolunteerStatus } from './volunteers.types';
import { CreateVolunteerDto } from './dtos/create-volunteer.dto';
import { CognitoService } from '../aws/cognito/cognito.service';
import { CognitoRole } from '../aws/cognito/cognito.types';
import { UpdateAnimalUpdatesDto } from './dtos/update-animal-updates.dto';

@Injectable()
export class VolunteersService {
Expand Down Expand Up @@ -136,6 +137,26 @@ export class VolunteersService {
return this.repo.save(volunteer);
}

/**
* Opts a Volunteer in or out of hearing what happens to an animal after it
* returns to MSPCA.
*
* @param id - The Volunteer's ID.
* @param dto - Contains the new animalUpdates value.
* @returns The updated Volunteer.
* @throws {NotFoundException} If no Volunteer with the ID exists.
*/
async updateAnimalUpdates(
id: number,
dto: UpdateAnimalUpdatesDto,
): Promise<FosterVolunteer> {
const volunteer = await this.findByIdOrFail(id);

volunteer.animalUpdates = dto.animalUpdates;

return this.repo.save(volunteer);
}

/**
* Fetches a Volunteer by ID, requiring that they are active.
*
Expand Down
Loading