From 9cc6bdc245ea1f79799f21cd2a9fb497b9815d8e Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Fri, 26 Dec 2025 20:17:03 -0500 Subject: [PATCH 01/10] Expanded test cases for repo throwing errors, invalid fields, ect. for application service --- .../applications/application.service.spec.ts | 384 ++++++++++++++++++ 1 file changed, 384 insertions(+) diff --git a/apps/backend/src/applications/application.service.spec.ts b/apps/backend/src/applications/application.service.spec.ts index 3ea2a66b0..5b1cc491e 100644 --- a/apps/backend/src/applications/application.service.spec.ts +++ b/apps/backend/src/applications/application.service.spec.ts @@ -73,6 +73,51 @@ describe('ApplicationsService', () => { expect(repository.find).toHaveBeenCalled(); expect(result).toEqual(mockApplications); }); + + it('should return an empty array if the repo returns one', async () => { + mockRepository.find.mockResolvedValue([]); + + const result = await service.findAll(); + + expect(repository.find).toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it('should handle returning an application with no changes when optional fields are ommitted', async () => { + const mockApplications: Application[] = [ + { + appId: 1, + appStatus: AppStatus.APP_SUBMITTED, + daysAvailable: 'Monday, Tuesday', + experienceType: ExperienceType.BS, + fileUploads: [], + interest: InterestArea.NURSING, + license: null, + isInternational: false, + isLearner: false, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + weeklyHours: 20, + }, + ]; + + mockRepository.find.mockResolvedValue(mockApplications); + + const result = await service.findAll(); + + expect(repository.find).toHaveBeenCalled(); + expect(result).toEqual(mockApplications); + }); + + it('should pass along any repo errors without information loss', async () => { + mockRepository.find.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + + await expect(service.findAll()).rejects.toThrow( + `There was a problem retrieving the info`, + ); + }); }); describe('findById', () => { @@ -115,6 +160,66 @@ describe('ApplicationsService', () => { where: { appId: nonExistentId }, }); }); + + it('should not return an application from the repo if the id is not the same as asked for', async () => { + const mockApplication: Application = { + appId: 1, + appStatus: AppStatus.APP_SUBMITTED, + daysAvailable: 'Monday, Tuesday', + experienceType: ExperienceType.BS, + fileUploads: [], + interest: InterestArea.NURSING, + license: null, + isInternational: false, + isLearner: false, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + referred: false, + referredEmail: null, + weeklyHours: 20, + }; + + mockRepository.findOne.mockResolvedValue(mockApplication); + + const result = await service.findById(10); + + expect(repository.findOne).toHaveBeenCalledWith({ where: { appId: 10 } }); + expect(repository.findOne).toThrow(); + }); + + it('should handle returning an application with no changes when optional fields are ommitted', async () => { + const mockApplication: Application = { + appId: 1, + appStatus: AppStatus.APP_SUBMITTED, + daysAvailable: 'Monday, Tuesday', + experienceType: ExperienceType.BS, + fileUploads: [], + interest: InterestArea.NURSING, + license: null, + isInternational: false, + isLearner: false, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + weeklyHours: 20, + }; + + mockRepository.findOne.mockResolvedValue(mockApplication); + + const result = await service.findById(1); + + expect(repository.findOne).toHaveBeenCalledWith({ where: { appId: 1 } }); + expect(result).toEqual(mockApplication); + }); + + it('should pass along any repo errors without information loss', async () => { + mockRepository.findOne.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + + await expect(service.findById(1)).rejects.toThrow( + new Error(`There was a problem retrieving the info`), + ); + }); }); describe('create', () => { @@ -147,6 +252,209 @@ describe('ApplicationsService', () => { expect(repository.save).toHaveBeenCalled(); expect(result).toEqual(savedApplication); }); + + it('should save with no changes when optional fields are ommitted', async () => { + const mockApplication: CreateApplicationDto = { + appStatus: AppStatus.APP_SUBMITTED, + daysAvailable: 'Monday, Tuesday', + experienceType: ExperienceType.BS, + fileUploads: [], + interest: InterestArea.NURSING, + license: null, + isInternational: false, + isLearner: false, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + weeklyHours: 20, + }; + + const savedApplication: Application = { + appId: 1, + ...mockApplication, + }; + + mockRepository.save.mockResolvedValue(savedApplication); + + const result = await service.create(mockApplication); + + expect(repository.save).toHaveBeenCalled(); + expect(result).toEqual(savedApplication); + }); + + it('should pass along any repo errors without information loss', async () => { + mockRepository.save.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + const mockApplication: CreateApplicationDto = { + appStatus: AppStatus.APP_SUBMITTED, + daysAvailable: 'Monday, Tuesday', + experienceType: ExperienceType.BS, + fileUploads: [], + interest: InterestArea.NURSING, + license: null, + isInternational: false, + isLearner: false, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + weeklyHours: 20, + }; + + await expect(service.create(mockApplication)).rejects.toThrow( + new Error(`There was a problem retrieving the info`), + ); + }); + + it('should not accept an invalid daysAvailable that is not in days of the week', async () => { + const createApplicationDto: CreateApplicationDto = { + appStatus: AppStatus.APP_SUBMITTED, + daysAvailable: 'Christmas, Thanksgiving', + experienceType: ExperienceType.BS, + fileUploads: [], + interest: InterestArea.NURSING, + license: null, + isInternational: false, + isLearner: false, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + referred: false, + referredEmail: null, + weeklyHours: 20, + }; + + const savedApplication: Application = { + appId: 1, + ...createApplicationDto, + }; + + mockRepository.save.mockResolvedValue(savedApplication); + await expect(service.create(createApplicationDto)).rejects.toThrow(); + }); + + it('should not accept a phone number that is too long', async () => { + const createApplicationDto: CreateApplicationDto = { + appStatus: AppStatus.APP_SUBMITTED, + daysAvailable: 'Monday, Tuesday', + experienceType: ExperienceType.BS, + fileUploads: [], + interest: InterestArea.NURSING, + license: null, + isInternational: false, + isLearner: false, + phone: '123-456-78901231', + school: School.HARVARD_MEDICAL_SCHOOL, + referred: false, + referredEmail: null, + weeklyHours: 20, + }; + + const savedApplication: Application = { + appId: 1, + ...createApplicationDto, + }; + + mockRepository.save.mockResolvedValue(savedApplication); + await expect(service.create(createApplicationDto)).rejects.toThrow(); + }); + it('should not accept a phone number that is too short', async () => { + const createApplicationDto: CreateApplicationDto = { + appStatus: AppStatus.APP_SUBMITTED, + daysAvailable: 'Monday, Tuesday', + experienceType: ExperienceType.BS, + fileUploads: [], + interest: InterestArea.NURSING, + license: null, + isInternational: false, + isLearner: false, + phone: '123-4562', + school: School.HARVARD_MEDICAL_SCHOOL, + referred: false, + referredEmail: null, + weeklyHours: 20, + }; + + const savedApplication: Application = { + appId: 1, + ...createApplicationDto, + }; + + mockRepository.save.mockResolvedValue(savedApplication); + await expect(service.create(createApplicationDto)).rejects.toThrow(); + }); + it('should not accept a phone number that is the right length but not in ###-###-#### format', async () => { + const createApplicationDto: CreateApplicationDto = { + appStatus: AppStatus.APP_SUBMITTED, + daysAvailable: 'Monday, Tuesday', + experienceType: ExperienceType.BS, + fileUploads: [], + interest: InterestArea.NURSING, + license: null, + isInternational: false, + isLearner: false, + phone: '123-456-8-90', + school: School.HARVARD_MEDICAL_SCHOOL, + referred: false, + referredEmail: null, + weeklyHours: 20, + }; + + const savedApplication: Application = { + appId: 1, + ...createApplicationDto, + }; + + mockRepository.save.mockResolvedValue(savedApplication); + await expect(service.create(createApplicationDto)).rejects.toThrow(); + }); + it('should not accept 0 weekly hours', async () => { + const createApplicationDto: CreateApplicationDto = { + appStatus: AppStatus.APP_SUBMITTED, + daysAvailable: 'Monday, Tuesday', + experienceType: ExperienceType.BS, + fileUploads: [], + interest: InterestArea.NURSING, + license: null, + isInternational: false, + isLearner: false, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + referred: false, + referredEmail: null, + weeklyHours: 0, + }; + + const savedApplication: Application = { + appId: 1, + ...createApplicationDto, + }; + + mockRepository.save.mockResolvedValue(savedApplication); + await expect(service.create(createApplicationDto)).rejects.toThrow(); + }); + it('should not accept negative weekly hours', async () => { + const createApplicationDto: CreateApplicationDto = { + appStatus: AppStatus.APP_SUBMITTED, + daysAvailable: 'Monday, Tuesday', + experienceType: ExperienceType.BS, + fileUploads: [], + interest: InterestArea.NURSING, + license: null, + isInternational: false, + isLearner: false, + phone: '123-456-78901231', + school: School.HARVARD_MEDICAL_SCHOOL, + referred: false, + referredEmail: null, + weeklyHours: -5, + }; + + const savedApplication: Application = { + appId: 1, + ...createApplicationDto, + }; + + mockRepository.save.mockResolvedValue(savedApplication); + await expect(service.create(createApplicationDto)).rejects.toThrow(); + }); }); describe('update', () => { @@ -242,6 +550,82 @@ describe('ApplicationsService', () => { }); expect(repository.save).not.toHaveBeenCalled(); }); + + it('should pass along any repo errors from retrieval without information loss when saving a new interest', async () => { + mockRepository.findOne.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + + await expect( + service.update(1, { interest: InterestArea.HARM_REDUCTION }), + ).rejects.toThrow(new Error(`There was a problem retrieving the info`)); + }); + + it('should pass along any repo errors from retrieval without information loss when saving a new application status', async () => { + mockRepository.findOne.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + + await expect( + service.update(1, { appStatus: AppStatus.IN_REVIEW }), + ).rejects.toThrow(new Error(`There was a problem retrieving the info`)); + }); + + it('should pass along any repo errors from saving the new info without information loss when saving a new interest', async () => { + const mockApplication: Application = { + appId: 1, + appStatus: AppStatus.APP_SUBMITTED, + daysAvailable: 'Monday, Tuesday', + experienceType: ExperienceType.BS, + fileUploads: [], + interest: InterestArea.NURSING, + license: null, + isInternational: false, + isLearner: false, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + referred: false, + referredEmail: null, + weeklyHours: 20, + }; + + mockRepository.findOne.mockResolvedValue(mockApplication); + mockRepository.save.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + + await expect( + service.update(1, { interest: InterestArea.HARM_REDUCTION }), + ).rejects.toThrow(new Error(`There was a problem retrieving the info`)); + }); + + it('should pass along any repo errors from saving the new info without information loss when saving a new application status', async () => { + const mockApplication: Application = { + appId: 1, + appStatus: AppStatus.APP_SUBMITTED, + daysAvailable: 'Monday, Tuesday', + experienceType: ExperienceType.BS, + fileUploads: [], + interest: InterestArea.NURSING, + license: null, + isInternational: false, + isLearner: false, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + referred: false, + referredEmail: null, + weeklyHours: 20, + }; + + mockRepository.findOne.mockResolvedValue(mockApplication); + mockRepository.save.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + + await expect( + service.update(1, { appStatus: AppStatus.IN_REVIEW }), + ).rejects.toThrow(new Error(`There was a problem retrieving the info`)); + }); }); describe('delete', () => { From f48c4a857a48d5d11a9f6460f8ac1af8e2e90ffd Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Fri, 26 Dec 2025 20:24:49 -0500 Subject: [PATCH 02/10] Added case for handling service error --- .../backend/src/learners/learners.controller.spec.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/backend/src/learners/learners.controller.spec.ts b/apps/backend/src/learners/learners.controller.spec.ts index fc6f97567..166a6cdcd 100644 --- a/apps/backend/src/learners/learners.controller.spec.ts +++ b/apps/backend/src/learners/learners.controller.spec.ts @@ -123,6 +123,18 @@ describe('LearnersController', () => { expect(result).toEqual([]); }); + + it('should error out without information loss if the service throws an error', async () => { + jest + .spyOn(mockLearnersService, 'findAll') + .mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + + await expect(controller.getAllLearners()).rejects.toThrow( + `There was a problem retrieving the info`, + ); + }); }); describe('getLearner', () => { From 1265b83f271f9525524c83190501283dd9111689 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:04:08 -0500 Subject: [PATCH 03/10] Added cases to all methods for checking handling of repo errors --- .../src/learners/learners.service.spec.ts | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/apps/backend/src/learners/learners.service.spec.ts b/apps/backend/src/learners/learners.service.spec.ts index 63a7ba977..7380d26a7 100644 --- a/apps/backend/src/learners/learners.service.spec.ts +++ b/apps/backend/src/learners/learners.service.spec.ts @@ -94,6 +94,52 @@ describe('LearnersService', () => { ), ).rejects.toThrow('Start date must be before end date'); }); + + it('should error out without information loss if the repository throws an error during create', async () => { + const createData = { + appId: 1, + name: 'John Doe', + startDate: new Date('2024-01-01'), + endDate: new Date('2024-06-30'), + }; + + jest + .spyOn(mockLearnersRepository, 'create') + .mockImplementationOnce(() => { + throw new Error('There was a problem retrieving the info'); + }); + + await expect( + service.create( + createData.appId, + createData.name, + createData.startDate, + createData.endDate, + ), + ).rejects.toThrow(`There was a problem retrieving the info`); + }); + + it('should error out without information loss if the repository throws an error during save', async () => { + const createData = { + appId: 1, + name: 'John Doe', + startDate: new Date('2024-01-01'), + endDate: new Date('2024-06-30'), + }; + + jest.spyOn(mockLearnersRepository, 'save').mockImplementationOnce(() => { + throw new Error('There was a problem saving the info'); + }); + + await expect( + service.create( + createData.appId, + createData.name, + createData.startDate, + createData.endDate, + ), + ).rejects.toThrow(`There was a problem saving the info`); + }); }); describe('findOne', () => { @@ -122,6 +168,18 @@ describe('LearnersService', () => { 'Learner with ID 999 not found', ); }); + + it('should error out without information loss if the repository throws an error during retrieval', async () => { + jest + .spyOn(mockLearnersRepository, 'findOneBy') + .mockRejectedValueOnce( + new Error('There was a problem retrieving the info'), + ); + + await expect(service.findOne(1)).rejects.toThrow( + 'There was a problem retrieving the info', + ); + }); }); describe('findAll', () => { @@ -142,6 +200,18 @@ describe('LearnersService', () => { expect(result).toEqual([]); }); + + it('should error out without information loss if the repository throws an error during retrieval', async () => { + jest + .spyOn(mockLearnersRepository, 'find') + .mockRejectedValueOnce( + new Error('There was a problem retrieving the info'), + ); + + await expect(service.findAll()).rejects.toThrow( + 'There was a problem retrieving the info', + ); + }); }); describe('findByAppId', () => { @@ -170,6 +240,18 @@ describe('LearnersService', () => { 'Valid app ID is required', ); }); + + it('should error out without information loss if the repository throws an error during retrieval', async () => { + jest + .spyOn(mockLearnersRepository, 'find') + .mockRejectedValueOnce( + new Error('There was a problem retrieving the info'), + ); + + await expect(service.findByAppId(8)).rejects.toThrow( + 'There was a problem retrieving the info', + ); + }); }); describe('updateStartDate', () => { @@ -221,6 +303,33 @@ describe('LearnersService', () => { 'Start date is required', ); }); + + it('should error out without information loss if the repository throws an error during retrieval', async () => { + jest + .spyOn(mockLearnersRepository, 'findOneBy') + .mockRejectedValueOnce( + new Error('There was a problem retrieving the info'), + ); + + await expect( + service.updateStartDate(999, updatedStartDate), + ).rejects.toThrow('There was a problem retrieving the info'); + }); + + it('should error out without information loss if the repository throws an error during save', async () => { + jest + .spyOn(mockLearnersRepository, 'findOneBy') + .mockResolvedValue(learner1); + jest + .spyOn(mockLearnersRepository, 'save') + .mockRejectedValueOnce( + new Error('There was a problem saving the info'), + ); + + await expect( + service.updateStartDate(1, updatedStartDate), + ).rejects.toThrow('There was a problem saving the info'); + }); }); describe('updateEndDate', () => { @@ -272,5 +381,32 @@ describe('LearnersService', () => { 'End date is required', ); }); + + it('should error out without information loss if the repository throws an error during retrieval', async () => { + jest + .spyOn(mockLearnersRepository, 'findOneBy') + .mockRejectedValueOnce( + new Error('There was a problem retrieving the info'), + ); + + await expect(service.updateEndDate(999, updatedEndDate)).rejects.toThrow( + 'There was a problem retrieving the info', + ); + }); + + it('should error out without information loss if the repository throws an error during save', async () => { + jest + .spyOn(mockLearnersRepository, 'findOneBy') + .mockResolvedValue(learner1); + jest + .spyOn(mockLearnersRepository, 'save') + .mockRejectedValueOnce( + new Error('There was a problem saving the info'), + ); + + await expect(service.updateEndDate(1, updatedEndDate)).rejects.toThrow( + 'There was a problem saving the info', + ); + }); }); }); From 1f54adae698dd6d6438b01d1ba65a559d3fc5a65 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Fri, 26 Dec 2025 21:32:39 -0500 Subject: [PATCH 04/10] Add tests for error handling in AdminsService Expanded AdminsService tests to ensure repository errors are passed along without information loss during create, find, update, and remove operations. --- apps/backend/src/users/admins.service.spec.ts | 115 +++++++++++++++++- 1 file changed, 112 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/users/admins.service.spec.ts b/apps/backend/src/users/admins.service.spec.ts index 941f7b957..e59324c24 100644 --- a/apps/backend/src/users/admins.service.spec.ts +++ b/apps/backend/src/users/admins.service.spec.ts @@ -76,12 +76,44 @@ describe('AdminsService', () => { }; mockRepository.create.mockReturnValue(mockAdmin); - mockRepository.save.mockRejectedValue(new Error('Database error')); + mockRepository.save.mockRejectedValueOnce(new Error('Database error')); await expect(service.create(createAdminDto)).rejects.toThrow( 'Database error', ); }); + it('should pass along any repo errors without information loss during create', async () => { + const createAdminDto: CreateAdminDto = { + name: 'John Doe', + email: 'john@example.com', + site: Site.FENWAY, + }; + + mockRepository.create.mockImplementationOnce(() => { + return new Error('There was a problem creating the entry'); + }); + + await expect(service.create(createAdminDto)).rejects.toThrow( + 'There was a problem creating the entry', + ); + }); + + it('should pass along any repo errors without information loss during save', async () => { + const createAdminDto: CreateAdminDto = { + name: 'John Doe', + email: 'john@example.com', + site: Site.FENWAY, + }; + + mockRepository.create.mockReturnValue(mockAdmin); + mockRepository.save.mockRejectedValueOnce( + new Error('There was a problem saving the entry'), + ); + + await expect(service.create(createAdminDto)).rejects.toThrow( + 'There was a problem saving the entry', + ); + }); }); describe('findAll', () => { @@ -90,7 +122,7 @@ describe('AdminsService', () => { mockAdmin, { ...mockAdmin, id: 2, email: 'jane@example.com' }, ]; - mockRepository.find.mockResolvedValue(mockAdmins); + mockRepository.find.mockResolvedValueOnce(mockAdmins); const result = await service.findAll(); @@ -99,12 +131,22 @@ describe('AdminsService', () => { }); it('should return empty array when no admins exist', async () => { - mockRepository.find.mockResolvedValue([]); + mockRepository.find.mockResolvedValueOnce([]); const result = await service.findAll(); expect(result).toEqual([]); }); + + it('should pass along any repo errors without information loss during retrieval', async () => { + mockRepository.find.mockResolvedValueOnce( + new Error('There was a problem retrieving the entries'), + ); + + await expect(service.findAll()).rejects.toThrow( + 'There was a problem retrieving the entries', + ); + }); }); describe('findOne', () => { @@ -124,6 +166,16 @@ describe('AdminsService', () => { new NotFoundException('Admin with ID 999 not found'), ); }); + + it('should pass along any repo errors without information loss during retrieval', async () => { + mockRepository.findOne.mockResolvedValueOnce( + new Error('There was a problem retrieving the entry'), + ); + + await expect(service.findOne(1)).rejects.toThrow( + 'There was a problem retrieving the entry', + ); + }); }); describe('findByEmail', () => { @@ -145,6 +197,16 @@ describe('AdminsService', () => { expect(result).toBeNull(); }); + + it('should pass along any repo errors without information loss during retrieval', async () => { + mockRepository.findOne.mockResolvedValueOnce( + new Error('There was a problem retrieving the entries'), + ); + + await expect(service.findByEmail('n')).rejects.toThrow( + 'There was a problem retrieving the entries', + ); + }); }); describe('findBySite', () => { @@ -217,6 +279,33 @@ describe('AdminsService', () => { expect(result.email).toBe('valid@example.com'); }); + + it('should pass along any repo errors without information loss during retrieval', async () => { + const updateEmailDto: UpdateAdminEmailDto = { + email: 'valid@example.com', + }; + + mockRepository.findOne.mockResolvedValueOnce( + new Error('There was a problem retrieving the entry'), + ); + + await expect(service.updateEmail(1, updateEmailDto)).rejects.toThrow( + 'There was a problem retrieving the entry', + ); + }); + + it('should pass along any repo errors without information loss during saving', async () => { + const updateEmailDto: UpdateAdminEmailDto = { + email: 'valid@example.com', + }; + mockRepository.findOne.mockResolvedValue(mockAdmin); + mockRepository.save.mockResolvedValueOnce( + new Error('There was a problem saving the entry'), + ); + await expect(service.updateEmail(1, updateEmailDto)).rejects.toThrow( + 'There was a problem saving the entry', + ); + }); }); describe('remove', () => { @@ -237,6 +326,26 @@ describe('AdminsService', () => { new NotFoundException('Admin with ID 999 not found'), ); }); + + it('should pass along any repo errors without information loss during retrieval', async () => { + mockRepository.findOne.mockResolvedValueOnce( + new Error('There was a problem retrieving the entry'), + ); + + await expect(service.remove(1)).rejects.toThrow( + 'There was a problem retrieving the entry', + ); + }); + + it('should pass along any repo errors without information loss during saving', async () => { + mockRepository.findOne.mockResolvedValue(mockAdmin); + mockRepository.remove.mockResolvedValueOnce( + new Error('There was a problem saving the entry'), + ); + await expect(service.remove(1)).rejects.toThrow( + 'There was a problem saving the entry', + ); + }); }); describe('edge cases', () => { From 52611c184bdd9cc24fcc47f6b2fde4cce4a74182 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Sat, 27 Dec 2025 12:35:22 -0500 Subject: [PATCH 05/10] Added automatic cleanup and check using SDK for real S3 bucket upload test --- .../backend/src/aws-s3/aws-s3.service.spec.ts | 118 +++++++++++++++++- 1 file changed, 115 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/aws-s3/aws-s3.service.spec.ts b/apps/backend/src/aws-s3/aws-s3.service.spec.ts index e5e3adefc..48514c65b 100644 --- a/apps/backend/src/aws-s3/aws-s3.service.spec.ts +++ b/apps/backend/src/aws-s3/aws-s3.service.spec.ts @@ -1,9 +1,17 @@ import * as dotenv from 'dotenv'; import path from 'path'; -dotenv.config({ path: path.join(__dirname, '../../../.env') }); +dotenv.config({ path: path.join(__dirname, '../../../../.env') }); -import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +import { + DeleteObjectsCommand, + S3Client, + S3ServiceException, + waitUntilObjectNotExists, + PutObjectCommand, + GetObjectCommand, + NoSuchKey, +} from '@aws-sdk/client-s3'; import { AWSS3Service } from './aws-s3.service'; import { mockClient } from 'aws-sdk-client-mock'; import axios from 'axios'; @@ -59,7 +67,7 @@ describe('AWSS3Service', () => { ).rejects.toThrow('File upload to AWS failed: Error: fail'); }); - // take off ".skip" to run this test but do so sparingly + // take off ".skip" to run this test. It does cleanup automatically but READ/WRITES can still pile up it.skip('should actually upload a file to S3 (integration)', async () => { s3Mock.restore(); const fileContent = `integration-test-content-${Date.now()}`; @@ -71,6 +79,7 @@ describe('AWSS3Service', () => { const url = await integrationService.upload(buffer, fileName, mimeType); console.log('Uploaded file URL:', url); expect(url).toContain(fileName); + try { const response = await axios.get(url); expect(response.status).toBe(200); @@ -79,7 +88,110 @@ describe('AWSS3Service', () => { throw new Error( `Failed to fetch the uploaded file from S3. Error: ${error.message}.`, ); + } finally { + // cleanup uploaded object(s) from S3 using helper + try { + await deleteObjects({ + bucketName: process.env.AWS_BUCKET_NAME, + keys: [fileName], + }); + } catch (cleanupErr) { + // don't mask test failure — log cleanup issues + // eslint-disable-next-line no-unsafe-finally + throw new Error( + 'Failed to clean up S3 objects after integration test:' + cleanupErr, + ); + } + + try { + await getObjectFromS3({ + bucketName: process.env.AWS_BUCKET_NAME, + key: fileName, + }); + } catch (err) { + if (err instanceof NoSuchKey) { + console.log( + 'object with filename ' + + fileName + + ' is successfully no longer in the bucket. No manual steps required', + ); + } else { + console.log( + 'There was an error trying to check if the object is no longer in the bucket. Please check the bucket manually to ensure proper cleanup.', + ); + } + } } }, 15000); + // MAKE SURE TO CLEAN UP THE FILES FROM OUR S3 BUCKET AFTER RUNNING THE INTEGRATION TEST }); + +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +/** + * Delete multiple objects from an S3 bucket. + * @param {{ bucketName: string, keys: string[] }} + */ +const deleteObjects = async ({ bucketName, keys }) => { + const client = new S3Client({ + region: process.env.AWS_REGION, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, + }); + + try { + const { Deleted } = await client.send( + new DeleteObjectsCommand({ + Bucket: bucketName, + Delete: { + Objects: keys.map((k) => ({ Key: k })), + }, + }), + ); + for (const k of keys) { + await waitUntilObjectNotExists( + { client, maxWaitTime: 30 }, + { Bucket: bucketName, Key: k }, + ); + } + console.log( + `Successfully deleted ${Deleted.length} objects from S3 bucket. Deleted objects:`, + ); + console.log(Deleted.map((d) => ` • ${d.Key}`).join('\n')); + } catch (caught) { + if ( + caught instanceof S3ServiceException && + caught.name === 'NoSuchBucket' + ) { + throw new Error( + `Error from S3 while deleting objects from ${bucketName}. The bucket doesn't exist.`, + ); + } else if (caught instanceof S3ServiceException) { + throw new Error( + `Error from S3 while deleting objects from ${bucketName}. ${caught.name}: ${caught.message}`, + ); + } else { + throw caught; + } + } +}; + +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +/** + * Get a single object from a specified S3 bucket. + * @param {{ bucketName: string, key: string }} + */ +const getObjectFromS3 = async ({ bucketName, key }) => { + const client = new S3Client({}); + const response = await client.send( + new GetObjectCommand({ + Bucket: bucketName, + Key: key, + }), + ); + // The Body object also has 'transformToByteArray' and 'transformToWebStream' methods. + const str = await response.Body.transformToString(); + console.log(str); +}; From 97b7899e2b9554cfa12cc6f49945d2f9784586d4 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Sat, 27 Dec 2025 12:41:58 -0500 Subject: [PATCH 06/10] Throw error instead of logging in S3 test cleanup Replaces a console.log with a thrown error when S3 object cleanup verification fails in the integration test. Also updates copyright notice to include SPDX license identifier. --- apps/backend/src/aws-s3/aws-s3.service.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/aws-s3/aws-s3.service.spec.ts b/apps/backend/src/aws-s3/aws-s3.service.spec.ts index 48514c65b..4dab88ca1 100644 --- a/apps/backend/src/aws-s3/aws-s3.service.spec.ts +++ b/apps/backend/src/aws-s3/aws-s3.service.spec.ts @@ -116,7 +116,8 @@ describe('AWSS3Service', () => { ' is successfully no longer in the bucket. No manual steps required', ); } else { - console.log( + // eslint-disable-next-line no-unsafe-finally + throw new Error( 'There was an error trying to check if the object is no longer in the bucket. Please check the bucket manually to ensure proper cleanup.', ); } @@ -127,7 +128,8 @@ describe('AWSS3Service', () => { // MAKE SURE TO CLEAN UP THE FILES FROM OUR S3 BUCKET AFTER RUNNING THE INTEGRATION TEST }); -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: Apache-2.0 +// For the following code to the end of the file, which has been modified: /** * Delete multiple objects from an S3 bucket. * @param {{ bucketName: string, keys: string[] }} @@ -178,7 +180,6 @@ const deleteObjects = async ({ bucketName, keys }) => { } }; -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. /** * Get a single object from a specified S3 bucket. * @param {{ bucketName: string, key: string }} From 96bee98f118d38bc15defca1a9ba6afc6b5277cb Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Sat, 27 Dec 2025 12:50:13 -0500 Subject: [PATCH 07/10] Checking proper error handling when SESWrapper throws an error --- apps/backend/src/util/email/email.service.spec.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/backend/src/util/email/email.service.spec.ts b/apps/backend/src/util/email/email.service.spec.ts index 6745b953e..d9bd60bdc 100644 --- a/apps/backend/src/util/email/email.service.spec.ts +++ b/apps/backend/src/util/email/email.service.spec.ts @@ -34,4 +34,13 @@ describe('EmailService', () => { await service.queueEmail('recipient@email.com', 'Subject', '