Skip to content
Open
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/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';

@Module({
imports: [
Expand All @@ -22,6 +23,7 @@ import { AllocationModule } from '../allocations/allocations.module';
forwardRef(() => AuthModule),
DonationItemsModule,
AllocationModule,
EmailsModule,
],
controllers: [DonationsController],
providers: [DonationService, DonationsSchedulerService],
Expand Down
150 changes: 150 additions & 0 deletions 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 @@ -628,6 +640,144 @@ 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(
[manufacturer.foodManufacturerRepresentative.email],
message.subject,
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('breaks out of cascade and logs warning when cascade email fails', async () => {
// 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
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 @@ -206,13 +212,24 @@ 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(
[donation.foodManufacturer.foodManufacturerRepresentative.email],
message.subject,
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 @@ -228,11 +245,22 @@ 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(
[
donation.foodManufacturer.foodManufacturerRepresentative
.email,
],
message.subject,
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}, exiting early`,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we mention the automated email failure here?

);
break;
}

occurrences -= 1;

Expand Down
Loading
Loading