-
Notifications
You must be signed in to change notification settings - Fork 1
Lapsed donor logic #80
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
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 |
|---|---|---|
|
|
@@ -32,6 +32,13 @@ describe('DonationsRepository', () => { | |
| }; | ||
|
|
||
| beforeEach(async () => { | ||
| const mockSubQueryBuilder = { | ||
| select: jest.fn().mockReturnThis(), | ||
| from: jest.fn().mockReturnThis(), | ||
| where: jest.fn().mockReturnThis(), | ||
| andWhere: jest.fn().mockReturnThis(), | ||
| getQuery: jest.fn().mockReturnValue('(subquery)'), | ||
| } as unknown as jest.Mocked<SelectQueryBuilder<unknown>>; | ||
| // Create mock query builder with all necessary methods | ||
|
Contributor
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. comment could be removed |
||
| mockQueryBuilder = { | ||
| where: jest.fn().mockReturnThis(), | ||
|
|
@@ -43,9 +50,19 @@ describe('DonationsRepository', () => { | |
| limit: jest.fn().mockReturnThis(), | ||
| select: jest.fn().mockReturnThis(), | ||
| addSelect: jest.fn().mockReturnThis(), | ||
|
|
||
| // added for findLapsedDonors | ||
| groupBy: jest.fn().mockReturnThis(), | ||
| having: jest.fn().mockReturnThis(), | ||
| setParameter: jest.fn().mockReturnThis(), | ||
| subQuery: jest.fn().mockReturnValue(mockSubQueryBuilder), | ||
|
|
||
| getManyAndCount: jest.fn(), | ||
| getMany: jest.fn(), | ||
| getRawOne: jest.fn(), | ||
|
|
||
| // used by findLapsedDonors | ||
| getRawMany: jest.fn(), | ||
| } as unknown as jest.Mocked<SelectQueryBuilder<Donation>>; | ||
|
|
||
| // Create mock TypeORM repository | ||
|
|
@@ -217,6 +234,70 @@ describe('DonationsRepository', () => { | |
| }); | ||
| }); | ||
|
|
||
| describe('findLapsedDonors', () => { | ||
| it('should filter to SUCCEEDED donations and apply cutoff via HAVING MAX(createdAt)', async () => { | ||
| mockQueryBuilder.getRawMany.mockResolvedValue([ | ||
| { email: 'alice@example.com' }, | ||
| ]); | ||
|
|
||
| const result = await repository.findLapsedDonors(6); | ||
|
|
||
| expect(mockTypeOrmRepo.createQueryBuilder).toHaveBeenCalledWith( | ||
| 'donation', | ||
| ); | ||
|
|
||
| expect(mockQueryBuilder.where).toHaveBeenCalledWith( | ||
| 'donation.status = :succeededStatus', | ||
| { succeededStatus: DonationStatus.SUCCEEDED }, | ||
| ); | ||
|
|
||
| expect(mockQueryBuilder.having).toHaveBeenCalledWith( | ||
| 'MAX(donation.createdAt) < :cutoff', | ||
| expect.objectContaining({ cutoff: expect.any(Date) }), | ||
| ); | ||
|
|
||
| expect(result).toEqual(['alice@example.com']); | ||
| }); | ||
|
|
||
| it('should return unique, normalized emails (trim + lowercase)', async () => { | ||
| mockQueryBuilder.getRawMany.mockResolvedValue([ | ||
| { email: 'ALICE@EXAMPLE.COM' }, | ||
| { email: 'alice@example.com' }, | ||
| { email: ' Alice@Example.com ' }, | ||
| ]); | ||
|
|
||
| const result = await repository.findLapsedDonors(6); | ||
|
|
||
| expect(result).toEqual(['alice@example.com']); | ||
| }); | ||
|
|
||
| it('should exclude donors with recurring donations using NOT EXISTS subquery', async () => { | ||
| mockQueryBuilder.getRawMany.mockResolvedValue([ | ||
| { email: 'x@example.com' }, | ||
| ]); | ||
|
|
||
| await repository.findLapsedDonors(6); | ||
|
|
||
| expect(mockQueryBuilder.subQuery).toHaveBeenCalled(); | ||
| expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( | ||
| expect.stringContaining('NOT EXISTS'), | ||
| ); | ||
| expect(mockQueryBuilder.setParameter).toHaveBeenCalledWith( | ||
| 'recurringType', | ||
| DonationType.RECURRING, | ||
| ); | ||
| }); | ||
|
|
||
| it('should throw when numMonths is not positive', async () => { | ||
| await expect(repository.findLapsedDonors(0)).rejects.toThrow( | ||
| 'numMonths must be a positive number', | ||
| ); | ||
| await expect(repository.findLapsedDonors(-1)).rejects.toThrow( | ||
| 'numMonths must be a positive number', | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('searchByDonorNameOrEmail', () => { | ||
| it('should search by donor name or email with default limit', async () => { | ||
| const mockResults = [mockDonation]; | ||
|
|
@@ -379,6 +460,74 @@ describe('DonationsRepository', () => { | |
| }); | ||
| }); | ||
|
|
||
| describe('findLapsedDonors', () => { | ||
| it('should filter to SUCCEEDED donations and apply cutoff via HAVING MAX(createdAt)', async () => { | ||
| mockQueryBuilder.getRawMany.mockResolvedValue([ | ||
| { email: 'alice@example.com' }, | ||
| ]); | ||
|
|
||
| const result = await repository.findLapsedDonors(6); | ||
|
|
||
| expect(mockTypeOrmRepo.createQueryBuilder).toHaveBeenCalledWith( | ||
| 'donation', | ||
| ); | ||
|
|
||
| expect(mockQueryBuilder.where).toHaveBeenCalledWith( | ||
| 'donation.status = :succeededStatus', | ||
| { succeededStatus: DonationStatus.SUCCEEDED }, | ||
| ); | ||
|
|
||
| expect(mockQueryBuilder.having).toHaveBeenCalledWith( | ||
| 'MAX(donation.createdAt) < :cutoff', | ||
| expect.objectContaining({ | ||
| cutoff: expect.any(Date), | ||
| }), | ||
| ); | ||
|
|
||
| expect(result).toEqual(['alice@example.com']); | ||
| }); | ||
|
|
||
| it('should return unique, normalized emails (trim + lowercase)', async () => { | ||
| mockQueryBuilder.getRawMany.mockResolvedValue([ | ||
| { email: 'ALICE@EXAMPLE.COM' }, | ||
| { email: 'alice@example.com' }, | ||
| { email: ' Alice@Example.com ' }, | ||
| ]); | ||
|
|
||
| const result = await repository.findLapsedDonors(6); | ||
|
|
||
| expect(result).toEqual(['alice@example.com']); | ||
| }); | ||
|
|
||
| it('should exclude donors with recurring donations using NOT EXISTS subquery', async () => { | ||
| mockQueryBuilder.getRawMany.mockResolvedValue([ | ||
| { email: 'x@example.com' }, | ||
| ]); | ||
|
|
||
| await repository.findLapsedDonors(6); | ||
|
|
||
| expect(mockQueryBuilder.subQuery).toHaveBeenCalled(); | ||
|
|
||
| expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( | ||
| expect.stringContaining('NOT EXISTS'), | ||
| ); | ||
|
|
||
| expect(mockQueryBuilder.setParameter).toHaveBeenCalledWith( | ||
| 'recurringType', | ||
| DonationType.RECURRING, | ||
| ); | ||
| }); | ||
|
|
||
| it('should throw when numMonths is not positive', async () => { | ||
| await expect(repository.findLapsedDonors(0)).rejects.toThrow( | ||
| 'numMonths must be a positive number', | ||
| ); | ||
| await expect(repository.findLapsedDonors(-2)).rejects.toThrow( | ||
| 'numMonths must be a positive number', | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('deleteById', () => { | ||
| it('should delete donation by id', async () => { | ||
| mockTypeOrmRepo.delete.mockResolvedValue({ | ||
|
|
||
|
Collaborator
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. QueryBuilder is stateful, so when we are using it |
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.
For lapsed donor we also need auth since this endpoint deals with sensitive donor data. Fix is to add two lines under here:
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
otherwise, someone can call this endpoint and get info without auth.