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/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import typeorm from './config/typeorm';
import { CognitoModule } from './aws/cognito/cognito.module';
import { RecommendationsModule } from './recommendations/recommendations.module';

@Module({
imports: [
Expand All @@ -16,6 +17,7 @@ import { CognitoModule } from './aws/cognito/cognito.module';
configService.getOrThrow('typeorm'),
}),
CognitoModule,
RecommendationsModule,
],
})
export class AppModule {}
11 changes: 11 additions & 0 deletions apps/backend/src/recommendations/createRecommendation.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { IsInt, IsPositive } from 'class-validator';

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

@IsInt()
@IsPositive()
chameleonAnimalId!: number;
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { RecommendationsController } from './recommendations.controller';
import { RecommendationsService } from './recommendations.service';
import { Recommendation } from './recommendations.entity';
import { CreateRecommendationDTO } from './createRecommendation.dto';
import { VolunteersService } from '../volunteers/volunteers.service';

describe('RecommendationsController', () => {
let controller: RecommendationsController;
let recommendationsService: { create: jest.Mock };
let volunteersService: { findActiveOrFail: jest.Mock };

const body = {
volunteerId: 7,
chameleonAnimalId: 42,
} as CreateRecommendationDTO;

beforeEach(async () => {
recommendationsService = { create: jest.fn() };
volunteersService = { findActiveOrFail: jest.fn() };

const module: TestingModule = await Test.createTestingModule({
controllers: [RecommendationsController],
providers: [
{
provide: RecommendationsService,
useValue: {},
useValue: recommendationsService,
},
{
provide: VolunteersService,
useValue: volunteersService,
},
],
}).compile();
Expand All @@ -24,4 +42,62 @@ describe('RecommendationsController', () => {
it('should be defined', () => {
expect(controller).toBeDefined();
});

describe('createRecommendation', () => {
it('returns the created recommendation when the volunteer is active', async () => {
const recommendation = { ...body, isActive: true } as Recommendation;
volunteersService.findActiveOrFail.mockResolvedValue({});
recommendationsService.create.mockResolvedValue(recommendation);

const result = await controller.createRecommendation(body);

expect(result).toBe(recommendation);
expect(volunteersService.findActiveOrFail).toHaveBeenCalledWith(7);
expect(recommendationsService.create).toHaveBeenCalledWith(body);
});

it.each([
['does not exist', new NotFoundException()],
['is not active', new BadRequestException()],
])(
'does not create a recommendation when the volunteer %s',
async (_case, error) => {
volunteersService.findActiveOrFail.mockRejectedValue(error);

await expect(controller.createRecommendation(body)).rejects.toThrow(
error,
);
expect(recommendationsService.create).not.toHaveBeenCalled();
},
);

it.each([
['volunteerId is missing', { chameleonAnimalId: 42 }],
['chameleonAnimalId is missing', { volunteerId: 7 }],
[
'volunteerId is not an integer',
{ volunteerId: 1.5, chameleonAnimalId: 42 },
],
[
'chameleonAnimalId is not an integer',
{ volunteerId: 7, chameleonAnimalId: 1.5 },
],
[
'volunteerId is not positive',
{ volunteerId: 0, chameleonAnimalId: 42 },
],
[
'chameleonAnimalId is not positive',
{ volunteerId: 7, chameleonAnimalId: -1 },
],
])('throws BadRequestException when %s', async (_case, invalidBody) => {
await expect(
controller.createRecommendation(
invalidBody as unknown as CreateRecommendationDTO,
),
).rejects.toThrow(BadRequestException);
expect(volunteersService.findActiveOrFail).not.toHaveBeenCalled();
expect(recommendationsService.create).not.toHaveBeenCalled();
});
});
});
34 changes: 32 additions & 2 deletions apps/backend/src/recommendations/recommendations.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,37 @@
import { Controller } from '@nestjs/common';
import { Body, Controller, HttpStatus, Post } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { RecommendationsService } from './recommendations.service';
import { Recommendation } from './recommendations.entity';
import { CreateRecommendationDTO } from './createRecommendation.dto';
import { VolunteersService } from '../volunteers/volunteers.service';
import { validateId } from '../utils/validation.utils';

@ApiTags('Recommendations')
// @ApiBearerAuth()
@Controller('recommendations')
export class RecommendationsController {
constructor(private recommendationsService: RecommendationsService) {}
constructor(
private recommendationsService: RecommendationsService,
private volunteersService: VolunteersService,
) {}

@Post()
Comment thread
Juwang110 marked this conversation as resolved.
@ApiOperation({
summary: 'Recommend a Chameleon Animal to an active Volunteer',
})
@ApiResponse({
status: HttpStatus.CREATED,
description: 'The created or reactivated recommendation',
type: Recommendation,
})
async createRecommendation(
@Body() body: CreateRecommendationDTO,
): Promise<Recommendation> {
validateId(body.volunteerId, 'Volunteer');
validateId(body.chameleonAnimalId, 'Chameleon Animal');

await this.volunteersService.findActiveOrFail(body.volunteerId);

Comment thread
Juwang110 marked this conversation as resolved.
return this.recommendationsService.create(body);
}
}
3 changes: 2 additions & 1 deletion apps/backend/src/recommendations/recommendations.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Recommendation } from './recommendations.entity';
import { RecommendationsController } from './recommendations.controller';
import { RecommendationsService } from './recommendations.service';
import { VolunteersModule } from '../volunteers/volunteers.module';

@Module({
imports: [TypeOrmModule.forFeature([Recommendation])],
imports: [TypeOrmModule.forFeature([Recommendation]), VolunteersModule],
controllers: [RecommendationsController],
providers: [RecommendationsService],
exports: [RecommendationsService],
Expand Down
60 changes: 59 additions & 1 deletion apps/backend/src/recommendations/recommendations.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,26 @@ import { Recommendation } from './recommendations.entity';

describe('RecommendationsService', () => {
let service: RecommendationsService;
let repo: { upsert: jest.Mock; findOneByOrFail: jest.Mock };

const saved = {
volunteerId: 7,
chameleonAnimalId: 42,
isActive: true,
} as Recommendation;

beforeEach(async () => {
repo = {
upsert: jest.fn().mockResolvedValue({ identifiers: [] }),
findOneByOrFail: jest.fn().mockResolvedValue(saved),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
RecommendationsService,
{
provide: getRepositoryToken(Recommendation),
useValue: {},
useValue: repo,
},
],
}).compile();
Expand All @@ -23,4 +35,50 @@ describe('RecommendationsService', () => {
it('should be defined', () => {
expect(service).toBeDefined();
});

describe('create', () => {
it('upserts the recommendation as active on the composite key', async () => {
await service.create({ volunteerId: 7, chameleonAnimalId: 42 });

expect(repo.upsert).toHaveBeenCalledWith(
{ volunteerId: 7, chameleonAnimalId: 42, isActive: true },
['volunteerId', 'chameleonAnimalId'],
);
});

it('defaults new recommendations to active', async () => {
await service.create({ volunteerId: 7, chameleonAnimalId: 42 });

const [values] = repo.upsert.mock.calls[0];
expect(values.isActive).toBe(true);
});

it('returns the persisted recommendation', async () => {
const result = await service.create({
volunteerId: 7,
chameleonAnimalId: 42,
});

expect(result).toBe(saved);
expect(repo.findOneByOrFail).toHaveBeenCalledWith({
volunteerId: 7,
chameleonAnimalId: 42,
});
});

it('reads the row back only after the upsert has resolved', async () => {
const order: string[] = [];
repo.upsert.mockImplementation(async () => {
order.push('upsert');
});
repo.findOneByOrFail.mockImplementation(async () => {
order.push('read');
return saved;
});

await service.create({ volunteerId: 7, chameleonAnimalId: 42 });

expect(order).toEqual(['upsert', 'read']);
});
});
});
13 changes: 13 additions & 0 deletions apps/backend/src/recommendations/recommendations.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,24 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Recommendation } from './recommendations.entity';
import { CreateRecommendationDTO } from './createRecommendation.dto';

@Injectable()
export class RecommendationsService {
constructor(
@InjectRepository(Recommendation)
private repo: Repository<Recommendation>,
) {}

async create({
volunteerId,
chameleonAnimalId,
}: CreateRecommendationDTO): Promise<Recommendation> {
await this.repo.upsert({ volunteerId, chameleonAnimalId, isActive: true }, [
Comment thread
Juwang110 marked this conversation as resolved.
'volunteerId',
'chameleonAnimalId',
]);

return this.repo.findOneByOrFail({ volunteerId, chameleonAnimalId });
}
}
8 changes: 0 additions & 8 deletions apps/backend/src/strategies/plural-naming.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,4 @@ export class PluralNamingStrategy
tableName(targetName: string, userSpecifiedName: string | undefined): string {
return userSpecifiedName || targetName.toLowerCase() + 's'; // Pluralize the table name
}

columnName(propertyName: string): string {
return propertyName;
}

relationName(propertyName: string): string {
return propertyName;
}
}
12 changes: 12 additions & 0 deletions apps/backend/src/utils/validation.utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,16 @@ describe('validateId', () => {
new BadRequestException('Invalid User ID'),
);
});

it('should throw BadRequestException for a non-integer ID', () => {
expect(() => validateId(1.5, 'User')).toThrow(
new BadRequestException('Invalid User ID'),
);
});

it('should throw BadRequestException for a missing ID', () => {
expect(() => validateId(undefined as unknown as number, 'User')).toThrow(
new BadRequestException('Invalid User ID'),
);
});
});
2 changes: 1 addition & 1 deletion apps/backend/src/utils/validation.utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { BadRequestException } from '@nestjs/common';

export function validateId(id: number, entityName: string): void {
if (!id || id < 1) {
if (!Number.isInteger(id) || id < 1) {
Comment thread
Juwang110 marked this conversation as resolved.
throw new BadRequestException(`Invalid ${entityName} ID`);
}
}
3 changes: 2 additions & 1 deletion apps/backend/src/volunteers/volunteers.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { FosterVolunteer } from './volunteers.entity';
import { VolunteersController } from './volunteers.controller';
import { VolunteersService } from './volunteers.service';
import { CoordinatorsModule } from '../coordinators/coordinators.module';

@Module({
imports: [TypeOrmModule.forFeature([FosterVolunteer])],
imports: [TypeOrmModule.forFeature([FosterVolunteer]), CoordinatorsModule],
controllers: [VolunteersController],
providers: [VolunteersService],
exports: [VolunteersService],
Expand Down
34 changes: 33 additions & 1 deletion apps/backend/src/volunteers/volunteers.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { VolunteersService } from './volunteers.service';
import { FosterVolunteer } from './volunteers.entity';

describe('VolunteersService', () => {
let service: VolunteersService;
let repo: { findOneBy: jest.Mock };

beforeEach(async () => {
repo = { findOneBy: jest.fn() };

const module: TestingModule = await Test.createTestingModule({
providers: [
VolunteersService,
{
provide: getRepositoryToken(FosterVolunteer),
useValue: {},
useValue: repo,
},
],
}).compile();
Expand All @@ -23,4 +27,32 @@ describe('VolunteersService', () => {
it('should be defined', () => {
expect(service).toBeDefined();
});

describe('findActiveOrFail', () => {
it('returns the volunteer when they are active', async () => {
const volunteer = { volunteerId: 7, active: true } as FosterVolunteer;
repo.findOneBy.mockResolvedValue(volunteer);

const result = await service.findActiveOrFail(7);

expect(result).toBe(volunteer);
expect(repo.findOneBy).toHaveBeenCalledWith({ volunteerId: 7 });
});

it('throws NotFoundException when no volunteer with the id exists', async () => {
repo.findOneBy.mockResolvedValue(null);

await expect(service.findActiveOrFail(7)).rejects.toThrow(
NotFoundException,
);
});

it('throws BadRequestException when the volunteer is not active', async () => {
repo.findOneBy.mockResolvedValue({ volunteerId: 7, active: false });

await expect(service.findActiveOrFail(7)).rejects.toThrow(
BadRequestException,
);
});
});
});
Loading
Loading