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
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';
import { MatchesModule } from './matches/matches.module';
import { CoordinatorsModule } from './coordinators/coordinators.module';
import { VolunteersModule } from './volunteers/volunteers.module';
Expand All @@ -19,6 +20,7 @@ import { VolunteersModule } from './volunteers/volunteers.module';
configService.getOrThrow('typeorm'),
}),
CognitoModule,
RecommendationsModule,
MatchesModule,
CoordinatorsModule,
VolunteersModule,
Expand Down
11 changes: 11 additions & 0 deletions apps/backend/src/recommendations/dto/create-recommendation.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { IsInt, IsPositive } from 'class-validator';
Comment thread
Juwang110 marked this conversation as resolved.

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 { NotFoundException } from '@nestjs/common';
import { RecommendationsController } from './recommendations.controller';
import { RecommendationsService } from './recommendations.service';
import { Recommendation } from './recommendations.entity';
import { CreateRecommendationDTO } from './dto/create-recommendation.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,28 @@ 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('does not create a recommendation when the volunteer does not exist or is not active', async () => {
const error = new NotFoundException();
volunteersService.findActiveOrFail.mockRejectedValue(error);

await expect(controller.createRecommendation(body)).rejects.toThrow(
error,
);
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 './dto/create-recommendation.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,
Comment thread
Juwang110 marked this conversation as resolved.
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
66 changes: 65 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,56 @@ describe('RecommendationsService', () => {
it('should be defined', () => {
expect(service).toBeDefined();
});

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

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

it('reactivates an inactive recommendation for the same pair', async () => {
const result = await service.create({
volunteerId: 7,
chameleonAnimalId: 42,
});

expect(repo.upsert).toHaveBeenCalledWith(
expect.objectContaining({ isActive: true }),
['volunteerId', 'chameleonAnimalId'],
);
expect(result.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']);
});
});
});
26 changes: 26 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,37 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Recommendation } from './recommendations.entity';
import { CreateRecommendationDTO } from './dto/create-recommendation.dto';

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

/**
* Recommends a Chameleon Animal to a Volunteer.
*
* (volunteerId, chameleonAnimalId) is the primary key, so recommending the
* same animal again reactivates the existing row instead of duplicating it.
* This upserts rather than save()-ing so that two concurrent requests for
* the same pair can't race into a primary key violation. Does not check
* that the Volunteer exists or is active; callers are expected to.
*
* @param dto - The Volunteer and Chameleon Animal IDs to link.
* @returns The active recommendation as persisted.
* @throws {EntityNotFoundError} If the row can't be read back after the upsert.
*/
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 });
}
}
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`);
}
}
Loading