diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 98c5a73e..26b4e0f4 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -42,4 +42,4 @@ jobs: node-version: 20 - run: yarn install --frozen-lockfile - run: yarn list strip-ansi string-width string-length - - run: npx jest \ No newline at end of file + - run: yarn test \ No newline at end of file diff --git a/apps/backend/src/donations/donations.service.spec.ts b/apps/backend/src/donations/donations.service.spec.ts index 9a7e265b..432270de 100644 --- a/apps/backend/src/donations/donations.service.spec.ts +++ b/apps/backend/src/donations/donations.service.spec.ts @@ -1,15 +1,61 @@ -import { Test } from '@nestjs/testing'; +import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; import { Donation } from './donations.entity'; import { DonationService } from './donations.service'; -import { mock } from 'jest-mock-extended'; import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; import { RecurrenceEnum, DayOfWeek } from './types'; import { RepeatOnDaysDto } from './dtos/create-donation.dto'; +import { testDataSource } from '../config/typeormTestDataSource'; -const mockDonationRepository = mock>(); -const mockFoodManufacturerRepository = mock>(); +jest.setTimeout(60000); + +const TODAY = new Date(); +TODAY.setHours(0, 0, 0, 0); +const MOCK_MONDAY = new Date(2025, 0, 6); + +const daysAgo = (numDays: number) => { + const date = new Date(TODAY); + date.setDate(date.getDate() - numDays); + date.setHours(0, 0, 0, 0); + return date; +}; + +const daysFromNow = (numDays: number) => { + const date = new Date(TODAY); + date.setDate(date.getDate() + numDays); + date.setHours(0, 0, 0, 0); + return date; +}; + +// insert a minimal donation and return its generated ID +async function insertDonation(overrides: { + recurrence: RecurrenceEnum; + recurrenceFreq: number; + nextDonationDates: Date[]; + occurrencesRemaining: number; +}): Promise { + // uses FoodCorp Industries manufacturer from test seed data + const result = await testDataSource.query( + `INSERT INTO donations + (food_manufacturer_id, status, recurrence, recurrence_freq, + next_donation_dates, occurrences_remaining) + VALUES + ( + (SELECT food_manufacturer_id FROM food_manufacturers + WHERE food_manufacturer_name = 'FoodCorp Industries' LIMIT 1), + 'available', + $1, $2, $3, $4 + ) + RETURNING donation_id`, + [ + overrides.recurrence, + overrides.recurrenceFreq, + overrides.nextDonationDates, + overrides.occurrencesRemaining, + ], + ); + return result[0].donation_id; +} const allFalse: RepeatOnDaysDto = { Sunday: false, @@ -21,11 +67,7 @@ const allFalse: RepeatOnDaysDto = { Saturday: false, }; -// Pin "today" to a known day so tests are deterministic. -// 2025-01-06 is a Monday. -const MOCK_MONDAY = new Date('2025-01-06T12:00:00.000Z'); - -const toDayOfWeek = (iso: string): DayOfWeek => { +const TODAYOfWeek = (iso: string): DayOfWeek => { const days: DayOfWeek[] = [ 'Sunday', 'Monday', @@ -42,18 +84,23 @@ describe('DonationService', () => { let service: DonationService; beforeAll(async () => { - mockDonationRepository.count.mockReset(); + if (!testDataSource.isInitialized) { + await testDataSource.initialize(); + } - const module = await Test.createTestingModule({ + await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); + + const module: TestingModule = await Test.createTestingModule({ providers: [ DonationService, { provide: getRepositoryToken(Donation), - useValue: mockDonationRepository, + useValue: testDataSource.getRepository(Donation), }, { provide: getRepositoryToken(FoodManufacturer), - useValue: mockFoodManufacturerRepository, + useValue: testDataSource.getRepository(FoodManufacturer), }, ], }).compile(); @@ -61,13 +108,21 @@ describe('DonationService', () => { service = module.get(DonationService); }); - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(MOCK_MONDAY); + afterEach(async () => { + await testDataSource.query(`DROP SCHEMA public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); }); - afterEach(() => { - jest.useRealTimers(); + beforeEach(async () => { + await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); + await testDataSource.runMigrations(); + }); + + afterAll(async () => { + if (testDataSource.isInitialized) { + await testDataSource.destroy(); + } }); it('should be defined', () => { @@ -75,19 +130,309 @@ describe('DonationService', () => { }); describe('getDonationCount', () => { - it.each([[0], [5]])('should return %i donations', async (count) => { - mockDonationRepository.count.mockResolvedValue(count); + it('returns total number of donations in the database', async () => { + const donationCount = await service.getNumberOfDonations(); + expect(donationCount).toEqual(4); + }); + }); + + describe('handleRecurringDonations', () => { + describe('no-op cases', () => { + it('skips donation with no nextDonationDates', async () => { + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [], + occurrencesRemaining: 3, + }); + + await service.handleRecurringDonations(); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates).toEqual([]); + expect(donation.occurrencesRemaining).toEqual(3); + }); + + it('skips donation whose nextDonationDates are all in the future', async () => { + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [daysFromNow(7)], + occurrencesRemaining: 3, + }); + + await service.handleRecurringDonations(); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates.length).toEqual(1); + expect(donation.occurrencesRemaining).toEqual(3); + }); + }); + + describe('single expired date', () => { + it('removes expired date and adds next weekly occurrence', async () => { + const pastDate = daysAgo(5); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate], + occurrencesRemaining: 3, + }); + + await service.handleRecurringDonations(); + + const expectedNextDate = new Date(pastDate); + expectedNextDate.setDate(expectedNextDate.getDate() + 7); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates).toHaveLength(1); + expect(donation.nextDonationDates[0].toDateString()).toEqual( + expectedNextDate.toDateString(), + ); + expect(donation.occurrencesRemaining).toEqual(2); + }); + + it('removes expired date and adds next monthly occurrence', async () => { + const pastDate = daysAgo(30); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.MONTHLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate], + occurrencesRemaining: 3, + }); + + await service.handleRecurringDonations(); + + const expectedNextDate = new Date(pastDate); + expectedNextDate.setMonth(expectedNextDate.getMonth() + 1); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates).toHaveLength(1); + expect(donation.nextDonationDates[0].toDateString()).toEqual( + expectedNextDate.toDateString(), + ); + expect(donation.occurrencesRemaining).toEqual(2); + }); + + it('removes expired date and adds next yearly occurrence', async () => { + const pastDate = daysAgo(7); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.YEARLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate], + occurrencesRemaining: 3, + }); + + await service.handleRecurringDonations(); + + const expectedNextDate = new Date(pastDate); + expectedNextDate.setFullYear(expectedNextDate.getFullYear() + 1); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates).toHaveLength(1); + expect(donation.nextDonationDates[0].toDateString()).toEqual( + expectedNextDate.toDateString(), + ); + expect(donation.occurrencesRemaining).toEqual(2); + }); + }); + + describe('expired and future dates coexisting', () => { + it('processes expired date and preserves the existing future date', async () => { + const pastDate = daysAgo(1); + const futureDate = daysFromNow(1); - const donationCount: number = await service.getNumberOfDonations(); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate, futureDate], + occurrencesRemaining: 3, + }); + + await service.handleRecurringDonations(); + + const donation = await service.findOne(donationId); + + const replacementDate = new Date(pastDate); + replacementDate.setDate(replacementDate.getDate() + 7); + + expect(donation.nextDonationDates).toHaveLength(2); + + const times = donation.nextDonationDates.map((d) => + new Date(d).getTime(), + ); + expect(times).toContain(futureDate.getTime()); + expect(times).toContain(replacementDate.getTime()); + + expect(donation.occurrencesRemaining).toEqual(2); + }); + + it('processes only past dates and leaves future dates intact', async () => { + const pastDate = daysAgo(7); + const futureDate = daysFromNow(7); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate, futureDate], + occurrencesRemaining: 3, + }); + + await service.handleRecurringDonations(); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates).toHaveLength(1); + + const times = donation.nextDonationDates.map((d) => + new Date(d).getTime(), + ); + expect(times).toContain(futureDate.getTime()); + + expect(donation.occurrencesRemaining).toEqual(1); + }); + }); + + describe('occurrence exhaustion', () => { + it(`removes expired date without adding replacement when it was the last occurrence`, async () => { + const pastDate = daysAgo(7); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate], + occurrencesRemaining: 1, + }); + + await service.handleRecurringDonations(); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates).toHaveLength(0); + expect(donation.occurrencesRemaining).toEqual(0); + }); + + it(`doesn't add replacement for non-recurring donation`, async () => { + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.NONE, + recurrenceFreq: null, + nextDonationDates: null, + occurrencesRemaining: null, + }); + await service.handleRecurringDonations(); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates).toBeNull(); + expect(donation.occurrencesRemaining).toBeNull(); + }); + + it('clears nextDonationDates when occurrencesRemaining is already 0', async () => { + const pastDate = daysAgo(7); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate], + occurrencesRemaining: 0, + }); + + await service.handleRecurringDonations(); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates).toHaveLength(0); + expect(donation.occurrencesRemaining).toEqual(0); + }); + }); + + describe('cascading recalculation', () => { + it('advances through multiple expired replacement dates until a future date is reached', async () => { + const pastDate1 = daysAgo(10); + const pastDate2 = daysAgo(3); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate1, pastDate2], + occurrencesRemaining: 4, + }); + + await service.handleRecurringDonations(); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates).toHaveLength(1); + expect(donation.occurrencesRemaining).toEqual(1); + expect( + new Date(donation.nextDonationDates[0]).getTime(), + ).toBeGreaterThan(new Date().getTime()); + }); + + it('stops advancing and schedules no replacement when occurrences run out mid-cascade', async () => { + const pastDate1 = daysAgo(14); + const pastDate2 = daysAgo(7); + const donationId = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate1, pastDate2], + occurrencesRemaining: 1, + }); + + await service.handleRecurringDonations(); + + const donation = await service.findOne(donationId); + expect(donation.nextDonationDates).toHaveLength(0); + expect(donation.occurrencesRemaining).toEqual(0); + }); + }); - expect(donationCount).toEqual(count); - expect(mockDonationRepository.count).toHaveBeenCalled(); + describe('multiple donations', () => { + it('processes each donation independently based on their recurrence rules', async () => { + const pastDate1 = daysAgo(14); + const pastDate2 = daysAgo(7); + const futureDate = daysFromNow(7); + + const donationId1 = await insertDonation({ + recurrence: RecurrenceEnum.WEEKLY, + recurrenceFreq: 3, + nextDonationDates: [pastDate1], + occurrencesRemaining: 2, + }); + + const donationId2 = await insertDonation({ + recurrence: RecurrenceEnum.MONTHLY, + recurrenceFreq: 1, + nextDonationDates: [pastDate2], + occurrencesRemaining: 1, + }); + + const donationId3 = await insertDonation({ + recurrence: RecurrenceEnum.YEARLY, + recurrenceFreq: 1, + nextDonationDates: [futureDate], + occurrencesRemaining: 3, + }); + + await service.handleRecurringDonations(); + + const donation1 = await service.findOne(donationId1); + const donation2 = await service.findOne(donationId2); + const donation3 = await service.findOne(donationId3); + + expect(donation1.nextDonationDates).toHaveLength(1); + expect( + new Date(donation1.nextDonationDates[0]).getTime(), + ).toBeGreaterThan(new Date().getTime()); + expect(donation1.occurrencesRemaining).toEqual(1); + + expect(donation2.nextDonationDates).toHaveLength(0); + expect(donation2.occurrencesRemaining).toEqual(0); + + expect(donation3.nextDonationDates).toHaveLength(1); + expect(new Date(donation3.nextDonationDates[0]).toDateString()).toEqual( + futureDate.toDateString(), + ); + expect(donation3.occurrencesRemaining).toEqual(3); + }); }); }); describe('generateNextDonationDates', () => { it('WEEKLY - returns empty array when no days are selected', async () => { const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.WEEKLY, allFalse, @@ -98,13 +443,14 @@ describe('DonationService', () => { it('WEEKLY - returns one date when exactly one day is selected (freq = 1)', async () => { const repeatOnDays: RepeatOnDaysDto = { ...allFalse, Wednesday: true }; const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.WEEKLY, repeatOnDays, ); expect(result).toHaveLength(1); - expect(toDayOfWeek(result[0])).toBe('Wednesday'); + expect(TODAYOfWeek(result[0])).toBe('Wednesday'); }); it('WEEKLY - returns dates only for selected days within the target week window', async () => { @@ -114,32 +460,34 @@ describe('DonationService', () => { Friday: true, }; const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.WEEKLY, repeatOnDays, ); expect(result).toHaveLength(2); - const resultDays = result.map(toDayOfWeek); + const resultDays = result.map(TODAYOfWeek); expect(resultDays).toContain('Wednesday'); expect(resultDays).toContain('Friday'); }); it('WEEKLY - offsets dates correctly when freq = 2', async () => { - // Today is Monday 2025-01-06, day 14 = Monday 2025-01-20, +2 for Wed = Jan 22. + // From date is Monday 2025-01-06, day 14 = Monday 2025-01-20, +2 for Wed = Jan 22. const repeatOnDays: RepeatOnDaysDto = { ...allFalse, Wednesday: true }; const result = await service.generateNextDonationDates( + MOCK_MONDAY, 2, RecurrenceEnum.WEEKLY, repeatOnDays, ); expect(result).toHaveLength(1); - expect(toDayOfWeek(result[0])).toBe('Wednesday'); + expect(TODAYOfWeek(result[0])).toBe('Wednesday'); const resultDate = new Date(result[0]); - expect(resultDate.getUTCDate()).toBe(22); - expect(resultDate.getUTCMonth()).toBe(0); + expect(resultDate.getDate()).toBe(22); + expect(resultDate.getMonth()).toBe(0); }); it('WEEKLY - returns dates in ascending order', async () => { @@ -150,6 +498,7 @@ describe('DonationService', () => { Saturday: true, }; const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.WEEKLY, repeatOnDays, @@ -159,9 +508,10 @@ describe('DonationService', () => { expect(timestamps).toEqual([...timestamps].sort((a, b) => a - b)); }); - it("WEEKLY - does not include today's DOW if selected", async () => { + it("WEEKLY - does not include TODAY's DOW if selected", async () => { const repeatOnDays: RepeatOnDaysDto = { ...allFalse, Monday: true }; const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.WEEKLY, repeatOnDays, @@ -172,6 +522,7 @@ describe('DonationService', () => { it('MONTHLY - returns exactly one date', async () => { const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.MONTHLY, null, @@ -181,6 +532,7 @@ describe('DonationService', () => { it('MONTHLY - adds correct number of months for freq = 1', async () => { const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.MONTHLY, null, @@ -188,13 +540,14 @@ describe('DonationService', () => { const resultDate = new Date(result[0]); // 2025-01-06 + 1 month = 2025-02-06 - expect(resultDate.getUTCFullYear()).toBe(2025); - expect(resultDate.getUTCMonth()).toBe(1); // February - expect(resultDate.getUTCDate()).toBe(6); + expect(resultDate.getFullYear()).toBe(2025); + expect(resultDate.getMonth()).toBe(1); // February + expect(resultDate.getDate()).toBe(6); }); it('MONTHLY - adds correct number of months for freq = 3', async () => { const result = await service.generateNextDonationDates( + MOCK_MONDAY, 3, RecurrenceEnum.MONTHLY, null, @@ -202,12 +555,13 @@ describe('DonationService', () => { const resultDate = new Date(result[0]); // 2025-01-06 + 3 months = 2025-04-06 - expect(resultDate.getUTCMonth()).toBe(3); // April - expect(resultDate.getUTCDate()).toBe(6); + expect(resultDate.getMonth()).toBe(3); // April + expect(resultDate.getDate()).toBe(6); }); it('MONTHLY - rolls over the year correctly', async () => { const result = await service.generateNextDonationDates( + MOCK_MONDAY, 12, RecurrenceEnum.MONTHLY, null, @@ -215,8 +569,8 @@ describe('DonationService', () => { const resultDate = new Date(result[0]); // 2025-01-06 + 12 months = 2026-01-06 - expect(resultDate.getUTCFullYear()).toBe(2026); - expect(resultDate.getUTCMonth()).toBe(0); // January + expect(resultDate.getFullYear()).toBe(2026); + expect(resultDate.getMonth()).toBe(0); // January }); it('MONTHLY - ignores repeatOnDays', async () => { @@ -226,11 +580,13 @@ describe('DonationService', () => { Friday: true, }; const withDays = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.MONTHLY, repeatOnDays, ); const withoutDays = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.MONTHLY, null, @@ -239,20 +595,21 @@ describe('DonationService', () => { expect(withDays).toEqual(withoutDays); }); - it('MONTHLY - clamps to 28th when today is the 29th', async () => { - jest.setSystemTime(new Date('2025-01-29T12:00:00.000Z')); + it('MONTHLY - clamps to 28th when TODAY is the 29th', async () => { const result = await service.generateNextDonationDates( + new Date(2025, 0, 29), 1, RecurrenceEnum.MONTHLY, null, ); - expect(new Date(result[0]).getUTCDate()).toBe(28); - expect(new Date(result[0]).getUTCMonth()).toBe(1); // February + expect(new Date(result[0]).getDate()).toBe(28); + expect(new Date(result[0]).getMonth()).toBe(1); // February }); it('YEARLY - returns exactly one date', async () => { const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.YEARLY, null, @@ -262,6 +619,7 @@ describe('DonationService', () => { it('YEARLY - adds correct number of years for freq = 1', async () => { const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.YEARLY, null, @@ -269,29 +627,32 @@ describe('DonationService', () => { const resultDate = new Date(result[0]); // 2025-01-06 + 1 year = 2026-01-06 - expect(resultDate.getUTCFullYear()).toBe(2026); - expect(resultDate.getUTCMonth()).toBe(0); // January - expect(resultDate.getUTCDate()).toBe(6); + expect(resultDate.getFullYear()).toBe(2026); + expect(resultDate.getMonth()).toBe(0); // January + expect(resultDate.getDate()).toBe(6); }); it('YEARLY - adds correct number of years for freq = 5', async () => { const result = await service.generateNextDonationDates( + MOCK_MONDAY, 5, RecurrenceEnum.YEARLY, null, ); - expect(new Date(result[0]).getUTCFullYear()).toBe(2030); + expect(new Date(result[0]).getFullYear()).toBe(2030); }); it('YEARLY - ignores repeatOnDays', async () => { const repeatOnDays: RepeatOnDaysDto = { ...allFalse, Wednesday: true }; const withDays = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.YEARLY, repeatOnDays, ); const withoutDays = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.YEARLY, null, @@ -300,21 +661,22 @@ describe('DonationService', () => { expect(withDays).toEqual(withoutDays); }); - it('YEARLY - clamps to 28th when today is the 29th', async () => { - jest.setSystemTime(new Date('2025-01-29T12:00:00.000Z')); + it('YEARLY - clamps to 28th when TODAY is the 29th', async () => { const result = await service.generateNextDonationDates( + new Date('2025-01-29T12:00:00.000Z'), 1, RecurrenceEnum.YEARLY, null, ); - expect(new Date(result[0]).getUTCFullYear()).toBe(2026); - expect(new Date(result[0]).getUTCDate()).toBe(28); - expect(new Date(result[0]).getUTCMonth()).toBe(0); // January + expect(new Date(result[0]).getFullYear()).toBe(2026); + expect(new Date(result[0]).getDate()).toBe(28); + expect(new Date(result[0]).getMonth()).toBe(0); // January }); it('NONE - returns empty array', async () => { const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.NONE, null, @@ -329,6 +691,7 @@ describe('DonationService', () => { Friday: true, }; const result = await service.generateNextDonationDates( + MOCK_MONDAY, 1, RecurrenceEnum.NONE, repeatOnDays, diff --git a/apps/backend/src/donations/donations.service.ts b/apps/backend/src/donations/donations.service.ts index 18e389b4..d63bebd9 100644 --- a/apps/backend/src/donations/donations.service.ts +++ b/apps/backend/src/donations/donations.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Donation } from './donations.entity'; @@ -9,6 +9,8 @@ import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity'; @Injectable() export class DonationService { + private readonly logger = new Logger(DonationService.name); + constructor( @InjectRepository(Donation) private repo: Repository, @InjectRepository(FoodManufacturer) @@ -53,6 +55,7 @@ export class DonationService { const nextDonationDates = donationData.recurrence !== RecurrenceEnum.NONE ? await this.generateNextDonationDates( + new Date(), donationData.recurrenceFreq, donationData.recurrence, donationData.repeatOnDays ?? null, @@ -87,16 +90,142 @@ export class DonationService { } async handleRecurringDonations(): Promise { - console.log('Accessing donation service from cron job'); - // TODO: Implement logic for sending reminder emails + const donations = await this.getAll(); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + for (const donation of donations) { + if ( + !donation.nextDonationDates || + donation.nextDonationDates.length === 0 + ) { + continue; + } + + if (donation.recurrence === RecurrenceEnum.NONE) continue; + + if ( + !donation.occurrencesRemaining || + donation.occurrencesRemaining <= 0 + ) { + await this.repo.update(donation.donationId, { + nextDonationDates: [], + occurrencesRemaining: 0, + }); + continue; + } + + let dates = [...donation.nextDonationDates].sort( + (a, b) => a.getTime() - b.getTime(), + ); + + let occurrences = donation.occurrencesRemaining; + let occurrencesUpdated = false; + + for (let i = 0; i < dates.length; i++) { + const currentDate = dates[i]; + + // all remaining dates are in future + if (currentDate.getTime() > today.getTime()) { + break; + } + + // recurrence has ended, clear nextDonationDates + if (occurrences <= 0) { + dates = []; + occurrencesUpdated = true; + 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; + + dates.splice(i, 1); + i--; + occurrences -= 1; + occurrencesUpdated = true; + + if (occurrences > 0) { + let nextDate = this.calculateNextDate( + currentDate, + donation.recurrence, + donation.recurrenceFreq, + ); + + // 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; + + occurrences -= 1; + + if (occurrences > 0) { + nextDate = this.calculateNextDate( + nextDate, + donation.recurrence, + donation.recurrenceFreq, + ); + } + } + + if (occurrences > 0) { + const alreadyExists = dates.some( + (date) => date.getTime() === nextDate.getTime(), + ); + if (!alreadyExists) { + dates.push(nextDate); + } + } + } + } + + if (occurrencesUpdated) { + dates.sort((a, b) => a.getTime() - b.getTime()); + + await this.repo.update(donation.donationId, { + nextDonationDates: dates, + occurrencesRemaining: occurrences, + }); + } + } + } + + private calculateNextDate( + currentDate: Date, + recurrence: RecurrenceEnum, + recurrenceFreq = 1, + ): Date { + const nextDate = new Date(currentDate); + switch (recurrence) { + case RecurrenceEnum.WEEKLY: + nextDate.setDate(nextDate.getDate() + 7 * recurrenceFreq); + break; + case RecurrenceEnum.MONTHLY: + nextDate.setMonth(nextDate.getMonth() + recurrenceFreq); + break; + case RecurrenceEnum.YEARLY: + nextDate.setFullYear(nextDate.getFullYear() + recurrenceFreq); + break; + default: + break; + } + return nextDate; } async generateNextDonationDates( + fromDate: Date, recurrenceFreq: number, recurrence: RecurrenceEnum, - repeatOnDays: RepeatOnDaysDto | null, + repeatOnDays: RepeatOnDaysDto | null = null, ): Promise { - const today = new Date(); const dates: string[] = []; if (recurrence === RecurrenceEnum.WEEKLY) { @@ -120,24 +249,24 @@ export class DonationService { const startDay = recurrenceFreq > 1 ? recurrenceFreq * 7 : 1; for (let i = startDay; i <= startDay + 6; i++) { - const nextDay = daysOfWeek[(today.getDay() + i) % 7]; + const nextDay = daysOfWeek[(fromDate.getDay() + i) % 7]; if (selectedDays.includes(nextDay)) { - const nextDate = new Date(today); - nextDate.setDate(today.getDate() + i); + const nextDate = new Date(fromDate); + nextDate.setDate(fromDate.getDate() + i); dates.push(nextDate.toISOString()); } } } else if (recurrence === RecurrenceEnum.MONTHLY) { - const nextDate = new Date(today); + const nextDate = new Date(fromDate); // Date clamp if the day is later than 28th if (nextDate.getDate() > 28) nextDate.setDate(28); - nextDate.setMonth(today.getMonth() + recurrenceFreq); + nextDate.setMonth(fromDate.getMonth() + recurrenceFreq); dates.push(nextDate.toISOString()); } else if (recurrence === RecurrenceEnum.YEARLY) { - const nextDate = new Date(today); + const nextDate = new Date(fromDate); // Date clamp if the day is later than 28th if (nextDate.getDate() > 28) nextDate.setDate(28); - nextDate.setFullYear(today.getFullYear() + recurrenceFreq); + nextDate.setFullYear(fromDate.getFullYear() + recurrenceFreq); dates.push(nextDate.toISOString()); } return dates; diff --git a/apps/backend/src/orders/order.service.spec.ts b/apps/backend/src/orders/order.service.spec.ts index a672cd29..bbea50f9 100644 --- a/apps/backend/src/orders/order.service.spec.ts +++ b/apps/backend/src/orders/order.service.spec.ts @@ -51,7 +51,8 @@ describe('OrdersService', () => { }); beforeEach(async () => { - // Run all migrations fresh for each test + await testDataSource.query(`DROP SCHEMA IF EXISTS public CASCADE`); + await testDataSource.query(`CREATE SCHEMA public`); await testDataSource.runMigrations(); }); diff --git a/package.json b/package.json index 2bc442e8..4f5f2efd 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "format": "prettier --no-error-on-unmatched-pattern --write apps/{frontend,backend}/src/**/*.{js,ts,tsx}", "lint:check": "eslint apps/frontend --ext .ts,.tsx && eslint apps/backend --ext .ts,.tsx", "lint": "eslint apps/frontend --ext .ts,.tsx --fix && eslint apps/backend --ext .ts,.tsx --fix", - "test": "jest", + "test": "jest --runInBand", "prepush": "yarn run format:check && yarn run lint:check", "prepush:fix": "yarn run format && yarn run lint", "prepare": "husky install",