Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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/donations/donations.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { DonationItem } from '../donationItems/donationItems.entity';
import { DonationItemsModule } from '../donationItems/donationItems.module';
import { Allocation } from '../allocations/allocations.entity';
import { AllocationModule } from '../allocations/allocations.module';
import { EmailsModule } from '../emails/email.module';
import { ManufacturerModule } from '../foodManufacturers/manufacturers.module';

@Module({
Expand All @@ -23,6 +24,7 @@ import { ManufacturerModule } from '../foodManufacturers/manufacturers.module';
forwardRef(() => AuthModule),
DonationItemsModule,
AllocationModule,
EmailsModule,
ManufacturerModule,
],
controllers: [DonationsController],
Expand Down
192 changes: 191 additions & 1 deletion apps/backend/src/donations/donations.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import {
ReplaceDonationItemsDto,
} from '../donationItems/dtos/create-donation-items.dto';
import { FoodType } from '../donationItems/types';
import { mock } from 'jest-mock-extended';
import { EmailsService } from '../emails/email.service';
import { emailTemplates } from '../emails/emailTemplates';

jest.setTimeout(60000);

Expand Down Expand Up @@ -132,11 +135,15 @@ const TODAYOfWeek = (iso: string): DayOfWeek => {
return days[new Date(iso).getDay()];
};

const mockEmailsService = mock<EmailsService>();

describe('DonationService', () => {
let service: DonationService;
let donationItemService: DonationItemsService;

beforeAll(async () => {
mockEmailsService.sendEmails.mockResolvedValue(undefined);

if (!testDataSource.isInitialized) {
await testDataSource.initialize();
}
Expand Down Expand Up @@ -168,6 +175,10 @@ describe('DonationService', () => {
provide: DataSource,
useValue: testDataSource,
},
{
provide: EmailsService,
useValue: mockEmailsService,
},
],
}).compile();

Expand All @@ -177,6 +188,7 @@ describe('DonationService', () => {
});

beforeEach(async () => {
mockEmailsService.sendEmails.mockClear();
await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`);
await testDataSource.query(`CREATE SCHEMA public`);
await testDataSource.runMigrations();
Expand Down Expand Up @@ -629,6 +641,184 @@ describe('DonationService', () => {
);
expect(donation.occurrencesRemaining).toEqual(3);
});

it('sends fmRecurringDonationReminder email with correct parameters when expired date is processed', async () => {
const pastDate = daysAgo(5);
const donationId = await insertDonation({
recurrence: RecurrenceEnum.WEEKLY,
recurrenceFreq: 1,
nextDonationDates: [pastDate],
occurrencesRemaining: 3,
});

const manufacturer = await testDataSource
.getRepository(FoodManufacturer)
.findOne({
where: { foodManufacturerName: 'FoodCorp Industries' },
relations: ['foodManufacturerRepresentative'],
});

if (!manufacturer)
throw new Error('Missing FoodCorp Industries manufacturer');

await service.handleRecurringDonations();

const message = emailTemplates.fmRecurringDonationReminder({
fmName: manufacturer.foodManufacturerName,
resubmitDonationId: donationId,
});

expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
expect(mockEmailsService.sendEmails).toHaveBeenCalledWith({
toEmail: manufacturer.foodManufacturerRepresentative.email,
subject: message.subject,
bodyHtml: message.bodyHTML,
});
});

it('skips recurrence update and logs warning when initial email fails', async () => {
const pastDate = daysAgo(5);
const donationId = await insertDonation({
recurrence: RecurrenceEnum.WEEKLY,
recurrenceFreq: 1,
nextDonationDates: [pastDate],
occurrencesRemaining: 3,
});

const warnSpy = jest.spyOn(service['logger'], 'warn');
mockEmailsService.sendEmails.mockRejectedValueOnce(
new Error('Email failed'),
);

await service.handleRecurringDonations();

expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Automated email failed to send. Skipping recurrence update for donation id ${donationId}`,
),
);

// donation state preserved — failed email means we skipped the update
const donation = await service.findOne(donationId);
expect(donation.occurrencesRemaining).toBe(3);
expect(donation.nextDonationDates).toHaveLength(1);
expect(donation.nextDonationDates?.[0].toDateString()).toEqual(
pastDate.toDateString(),
);

warnSpy.mockRestore();
});

it("processes other donations when one donation's initial email fails", async () => {
// 3 weekly donations whose replacement dates are all in the future
// (no cascading), each starting at occurrencesRemaining=3.
const donationId1 = await insertDonation({
recurrence: RecurrenceEnum.WEEKLY,
recurrenceFreq: 1,
nextDonationDates: [daysAgo(1)],
occurrencesRemaining: 3,
});
const donationId2 = await insertDonation({
recurrence: RecurrenceEnum.WEEKLY,
recurrenceFreq: 1,
nextDonationDates: [daysAgo(3)],
occurrencesRemaining: 3,
});
const donationId3 = await insertDonation({
recurrence: RecurrenceEnum.WEEKLY,
recurrenceFreq: 1,
nextDonationDates: [daysAgo(5)],
occurrencesRemaining: 3,
});

// Reject the first sendEmails call. Whichever donation getAll yields
// first will fail; the other two will succeed.
mockEmailsService.sendEmails.mockRejectedValueOnce(
new Error('Email failed'),
);

await service.handleRecurringDonations();

expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(3);

const donations = await Promise.all([
service.findOne(donationId1),
service.findOne(donationId2),
service.findOne(donationId3),
]);

// Exactly one donation should be unchanged (the one whose email failed)
// and the other two should be decremented from 3 → 2.
const remaining = donations.map((d) => d.occurrencesRemaining).sort();
expect(remaining).toEqual([2, 2, 3]);
});

it('sends multiple cascade emails when several replacement dates are also expired', async () => {
// 21-day-old weekly date cascades 3 times before landing in the future:
// initial send for daysAgo(21), then cascade sends for daysAgo(14),
// daysAgo(7), and daysAgo(0) — the next computed date (daysFromNow(7))
// exits the while loop. 4 emails total
const pastDate = daysAgo(21);
const donationId = await insertDonation({
recurrence: RecurrenceEnum.WEEKLY,
recurrenceFreq: 1,
nextDonationDates: [pastDate],
occurrencesRemaining: 5,
});

const manufacturer = await testDataSource
.getRepository(FoodManufacturer)
.findOne({
where: { foodManufacturerName: 'FoodCorp Industries' },
relations: ['foodManufacturerRepresentative'],
});

if (!manufacturer)
throw new Error('Missing FoodCorp Industries manufacturer');

await service.handleRecurringDonations();

const message = emailTemplates.fmRecurringDonationReminder({
fmName: manufacturer.foodManufacturerName,
resubmitDonationId: donationId,
});

expect(mockEmailsService.sendEmails).toHaveBeenCalledTimes(4);

const donation = await service.findOne(donationId);
expect(donation.occurrencesRemaining).toBe(1);
expect(donation.nextDonationDates).toHaveLength(1);
expect(donation.nextDonationDates?.[0].toDateString()).toEqual(
daysFromNow(7).toDateString(),
);
});

it('breaks out of cascade and logs warning when cascade email fails', async () => {
Comment thread
dburkhart07 marked this conversation as resolved.
// 14-day-old weekly date triggers the cascade — its replacement (7daysAgo) is also expired.
const pastDate = daysAgo(14);
const donationId = await insertDonation({
recurrence: RecurrenceEnum.WEEKLY,
recurrenceFreq: 1,
nextDonationDates: [pastDate],
occurrencesRemaining: 5,
});

const warnSpy = jest.spyOn(service['logger'], 'warn');
mockEmailsService.sendEmails
.mockResolvedValueOnce(undefined) // initial send (pastDate) succeeds
.mockRejectedValueOnce(new Error('Email failed')); // first cascade send fails → break

await service.handleRecurringDonations();

expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Cascading recalculation of next dates failed for donation id ${donationId}`,
),
);

warnSpy.mockRestore();
});
});
});

Expand Down Expand Up @@ -996,7 +1186,7 @@ describe('DonationService', () => {
});

it('throws when foodManufacturerId does not exist', async () => {
expect(
await expect(
service.create({
foodManufacturerId: 99999,
recurrence: RecurrenceEnum.NONE,
Expand Down
56 changes: 42 additions & 14 deletions apps/backend/src/donations/donations.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
BadRequestException,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
} from '@nestjs/common';
Expand All @@ -18,11 +19,12 @@ import { DonationItemsService } from '../donationItems/donationItems.service';
import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto';
import { DonationItem } from '../donationItems/donationItems.entity';
import { Allocation } from '../allocations/allocations.entity';
import { EmailsService } from '../emails/email.service';
import { emailTemplates } from '../emails/emailTemplates';

@Injectable()
export class DonationService {
private readonly logger = new Logger(DonationService.name);

constructor(
@InjectRepository(Donation) private repo: Repository<Donation>,
@InjectRepository(Allocation)
Expand All @@ -33,6 +35,7 @@ export class DonationService {
private manufacturerRepo: Repository<FoodManufacturer>,
private donationItemsService: DonationItemsService,
@InjectDataSource() private dataSource: DataSource,
private emailsService: EmailsService,
) {}

async findOne(donationId: number): Promise<Donation> {
Expand All @@ -50,7 +53,10 @@ export class DonationService {

async getAll(): Promise<Donation[]> {
return this.repo.find({
relations: ['foodManufacturer'],
relations: [
'foodManufacturer',
'foodManufacturer.foodManufacturerRepresentative',
],
});
}

Expand Down Expand Up @@ -207,13 +213,25 @@ export class DonationService {
break;
}

this.logger.log(`Placeholder for sending automated email`);

/**
* IMPORTANT: future logic below should only proceed if the email is successfully sent
*/
const emailSent = true;
if (!emailSent) continue;
let message = null;
try {
Comment thread
dburkhart07 marked this conversation as resolved.
message = emailTemplates.fmRecurringDonationReminder({
fmName: donation.foodManufacturer.foodManufacturerName,
resubmitDonationId: donation.donationId,
});

await this.emailsService.sendEmails({
toEmail:
donation.foodManufacturer.foodManufacturerRepresentative.email,
subject: message.subject,
bodyHtml: message.bodyHTML,
});
} catch {
this.logger.warn(
`Automated email failed to send. Skipping recurrence update for donation id ${donation.donationId}`,
);
continue;
}

dates.splice(i, 1);
i--;
Expand All @@ -229,11 +247,21 @@ export class DonationService {

// cascading recalculation of next dates when replacement dates are also expired
while (nextDate.getTime() <= today.getTime() && occurrences > 0) {
this.logger.log(
`Placeholder for sending automated email for replacement date`,
);
const cascadeEmailSent = true;
if (!cascadeEmailSent) break;
try {
Comment thread
dburkhart07 marked this conversation as resolved.
await this.emailsService.sendEmails({
toEmail:
donation.foodManufacturer.foodManufacturerRepresentative
.email,
subject: message.subject,
bodyHtml: message.bodyHTML,
});
} catch {
// Early escape to prevent getting stuck in while loop
this.logger.warn(
`Cascading recalculation of next dates failed for donation id ${donation.donationId} due to an email sending failure, exiting early`,
);
break;
}

occurrences -= 1;

Expand Down
Loading
Loading