-
Notifications
You must be signed in to change notification settings - Fork 0
[SSF-211] promote volunteer to admin #187
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b372971
5b60321
d932f0a
378d9e5
d453689
388487c
5fcfab4
2d30e74
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,7 +6,7 @@ import { userSchemaDto } from './dtos/userSchema.dto'; | |
| import { Test, TestingModule } from '@nestjs/testing'; | ||
| import { mock } from 'jest-mock-extended'; | ||
| import { UpdateUserInfoDto } from './dtos/update-user-info.dto'; | ||
| import { BadRequestException } from '@nestjs/common'; | ||
| import { BadRequestException, NotFoundException } from '@nestjs/common'; | ||
| import { AuthenticatedRequest } from '../auth/authenticated-request'; | ||
|
|
||
| const mockUserService = mock<UsersService>(); | ||
|
|
@@ -31,6 +31,7 @@ describe('UsersController', () => { | |
| mockUserService.create.mockReset(); | ||
| mockUserService.getUserDashboardStats.mockReset(); | ||
| mockUserService.getRecentPendingApplications.mockReset(); | ||
| mockUserService.promoteVolunteerToAdmin.mockReset(); | ||
|
|
||
| const module: TestingModule = await Test.createTestingModule({ | ||
| controllers: [UsersController], | ||
|
|
@@ -211,4 +212,36 @@ describe('UsersController', () => { | |
| expect(result).toEqual([]); | ||
| }); | ||
| }); | ||
|
|
||
| describe('PATCH /:id/promote-volunteer', () => { | ||
| it('should promote volunteer to admin successfully', async () => { | ||
| mockUserService.promoteVolunteerToAdmin.mockResolvedValueOnce(undefined); | ||
|
|
||
| await controller.promoteToAdmin(1); | ||
|
|
||
| expect(mockUserService.promoteVolunteerToAdmin).toHaveBeenCalledWith(1); | ||
| }); | ||
|
|
||
| it('should throw NotFoundException from service when user not found', async () => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we generally havent been testing for the sad cases in the controller, since primarily the service will handle bad input logic. controller tests for us are primarily just to make sure that the service is called once, and with the right parameters. cuz of that, can we delete this test and the one below? |
||
| mockUserService.promoteVolunteerToAdmin.mockRejectedValueOnce( | ||
| new NotFoundException('User 999 not found'), | ||
| ); | ||
|
|
||
| await expect(controller.promoteToAdmin(999)).rejects.toThrow( | ||
| new NotFoundException('User 999 not found'), | ||
| ); | ||
| }); | ||
|
|
||
| it('should throw BadRequestException from service when user is not a volunteer', async () => { | ||
| mockUserService.promoteVolunteerToAdmin.mockRejectedValueOnce( | ||
| new BadRequestException( | ||
| 'User 1 is not a volunteer. Current role: admin', | ||
| ), | ||
| ); | ||
|
|
||
| await expect(controller.promoteToAdmin(1)).rejects.toThrow( | ||
| BadRequestException, | ||
| ); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,8 @@ jest.setTimeout(60000); | |
|
|
||
| const mockAuthService = { | ||
| adminCreateUser: jest.fn().mockResolvedValue('mock-sub'), | ||
| addUserToGroup: jest.fn().mockResolvedValue(undefined), | ||
| removeUserFromGroup: jest.fn().mockResolvedValue(undefined), | ||
| }; | ||
| const mockEmailsService = mock<EmailsService>(); | ||
|
|
||
|
|
@@ -125,6 +127,8 @@ describe('UsersService', () => { | |
|
|
||
| beforeEach(async () => { | ||
| mockAuthService.adminCreateUser.mockClear(); | ||
| mockAuthService.addUserToGroup.mockClear(); | ||
| mockAuthService.removeUserFromGroup.mockClear(); | ||
| mockEmailsService.sendEmails.mockClear(); | ||
| await testDataSource.runMigrations(); | ||
| }); | ||
|
|
@@ -730,4 +734,116 @@ describe('UsersService', () => { | |
| expect(types).toContain('food_manufacturer'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('promoteVolunteerToAdmin', () => { | ||
| it('should promote volunteer to admin successfully', async () => { | ||
| const userRepo = testDataSource.getRepository(User); | ||
| const volunteers = await userRepo.find({ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. in our dummy data, user id 7 is a volunteer i believe (from line 553). can we just use that for all of our promote checks here instead of finding all volunteers? |
||
| where: { role: Role.VOLUNTEER }, | ||
| }); | ||
| expect(volunteers.length).toBeGreaterThan(0); | ||
| const volunteer = volunteers[0]; | ||
|
|
||
| await service.promoteVolunteerToAdmin(volunteer.id); | ||
|
|
||
| const updatedUser = await userRepo.findOne({ | ||
| where: { id: volunteer.id }, | ||
| }); | ||
| expect(updatedUser!.role).toBe(Role.ADMIN); | ||
| }); | ||
|
|
||
| it('should clear volunteer pantry assignments after promotion', async () => { | ||
| const volunteer = await testDataSource.getRepository(User).findOne({ | ||
| where: { role: Role.VOLUNTEER }, | ||
| relations: ['pantries'], | ||
| }); | ||
| expect(volunteer).toBeDefined(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can we make sure hat the volunteer had pantries to begin with before, to show that the call removes them? |
||
|
|
||
| await service.promoteVolunteerToAdmin(volunteer!.id); | ||
|
|
||
| const assignments = await testDataSource.query( | ||
| `SELECT * FROM volunteer_assignments WHERE volunteer_id = $1`, | ||
| [volunteer!.id], | ||
| ); | ||
| expect(assignments).toHaveLength(0); | ||
| }); | ||
|
|
||
| it('should call Cognito addUserToGroup and removeUserFromGroup', async () => { | ||
| const volunteer = await testDataSource.getRepository(User).findOne({ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. see above comment about using id instead |
||
| where: { role: Role.VOLUNTEER }, | ||
| }); | ||
| expect(volunteer).toBeDefined(); | ||
|
|
||
| await service.promoteVolunteerToAdmin(volunteer!.id); | ||
|
|
||
| expect(mockAuthService.addUserToGroup).toHaveBeenCalledWith( | ||
| volunteer!.email, | ||
| 'admin', | ||
| ); | ||
| expect(mockAuthService.removeUserFromGroup).toHaveBeenCalledWith( | ||
| volunteer!.email, | ||
| 'volunteer', | ||
| ); | ||
| }); | ||
|
|
||
| it('should throw NotFoundException when user does not exist', async () => { | ||
| await expect(service.promoteVolunteerToAdmin(99999)).rejects.toThrow( | ||
| NotFoundException, | ||
| ); | ||
| }); | ||
|
|
||
| it('should throw BadRequestException when user is already admin', async () => { | ||
| const admin = await testDataSource.getRepository(User).findOne({ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same thing here, user id 1 should be an admin |
||
| where: { role: Role.ADMIN }, | ||
| }); | ||
| expect(admin).toBeDefined(); | ||
|
|
||
| await expect(service.promoteVolunteerToAdmin(admin!.id)).rejects.toThrow( | ||
| BadRequestException, | ||
| ); | ||
| }); | ||
|
|
||
| it('should throw BadRequestException when user is pantry', async () => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: can we rephrase this to "when user is not a volunteer" since thats really what we want to test (any other role will work though). |
||
| const pantryUser = await testDataSource.getRepository(User).findOne({ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same thing here with this search, can we just use id instead? user id 10 should be a pantry |
||
| where: { role: Role.PANTRY }, | ||
| }); | ||
| expect(pantryUser).toBeDefined(); | ||
|
|
||
| await expect( | ||
| service.promoteVolunteerToAdmin(pantryUser!.id), | ||
| ).rejects.toThrow(BadRequestException); | ||
| }); | ||
|
|
||
| it('should throw BadRequestException when user is food manufacturer', async () => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can delete this, the above test covers our cases |
||
| const fmUser = await testDataSource.getRepository(User).findOne({ | ||
| where: { role: Role.FOODMANUFACTURER }, | ||
| }); | ||
| expect(fmUser).toBeDefined(); | ||
|
|
||
| await expect(service.promoteVolunteerToAdmin(fmUser!.id)).rejects.toThrow( | ||
| BadRequestException, | ||
| ); | ||
| }); | ||
|
|
||
| it('should rollback if Cognito fails', async () => { | ||
| const userRepo = testDataSource.getRepository(User); | ||
| const volunteer = await userRepo.findOne({ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same here |
||
| where: { role: Role.VOLUNTEER }, | ||
| }); | ||
| expect(volunteer).toBeDefined(); | ||
|
|
||
| mockAuthService.addUserToGroup.mockRejectedValueOnce( | ||
| new InternalServerErrorException('Cognito error'), | ||
| ); | ||
|
|
||
| await expect( | ||
| service.promoteVolunteerToAdmin(volunteer!.id), | ||
| ).rejects.toThrow(InternalServerErrorException); | ||
|
|
||
| const userAfter = await userRepo.findOne({ | ||
| where: { id: volunteer!.id }, | ||
| }); | ||
| expect(userAfter!.role).toBe(Role.VOLUNTEER); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,8 +7,8 @@ import { | |
| InternalServerErrorException, | ||
| NotFoundException, | ||
| } from '@nestjs/common'; | ||
| import { InjectRepository } from '@nestjs/typeorm'; | ||
| import { Between, In, Repository } from 'typeorm'; | ||
| import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; | ||
| import { Between, DataSource, In, Repository } from 'typeorm'; | ||
| import { User } from './users.entity'; | ||
| import { PendingApplication, Role } from './types'; | ||
| import { validateId } from '../utils/validation.utils'; | ||
|
|
@@ -44,6 +44,8 @@ export class UsersService { | |
| private pantryRepo: Repository<Pantry>, | ||
| @InjectRepository(FoodManufacturer) | ||
| private fmRepo: Repository<FoodManufacturer>, | ||
| @InjectDataSource() | ||
| private dataSource: DataSource, | ||
| private authService: AuthService, | ||
| private emailsService: EmailsService, | ||
| @Inject(forwardRef(() => PantriesService)) | ||
|
|
@@ -298,4 +300,34 @@ export class UsersService { | |
| throw new BadRequestException(`Unsupported role: ${user.role}`); | ||
| } | ||
| } | ||
|
|
||
| async promoteVolunteerToAdmin(userId: number): Promise<void> { | ||
| validateId(userId, 'User'); | ||
|
|
||
| const user = await this.repo.findOne({ | ||
| where: { id: userId }, | ||
| relations: ['pantries'], | ||
| }); | ||
|
|
||
| if (!user) { | ||
| throw new NotFoundException(`User ${userId} not found`); | ||
| } | ||
|
|
||
| if (user.role !== Role.VOLUNTEER) { | ||
| throw new BadRequestException( | ||
| `User ${userId} is not a volunteer. Current role: ${user.role}`, | ||
| ); | ||
| } | ||
|
|
||
| await this.dataSource.transaction(async (transactionManager) => { | ||
| const userRepo = transactionManager.getRepository(User); | ||
|
|
||
| user.role = Role.ADMIN; | ||
| user.pantries = []; | ||
| await userRepo.save(user); | ||
|
|
||
| await this.authService.addUserToGroup(user.email, 'admin'); | ||
| await this.authService.removeUserFromGroup(user.email, 'volunteer'); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if this removeUserFromGroup call fails, then our database will be inconsistent with Cognito (we will have an admin user in Cognito, but this will rollback and give us a volunteer in the database. For this second call, can we do a try catch and, in the catch, run await this.authService.removeUserFromGroup(user.email, 'admin'); so that we remove the just added admin, and then throw an error so that everything rolls back? can we also add a specific test for this? |
||
| }); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
see slack comments