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
4 changes: 3 additions & 1 deletion apps/backend/src/allocations/allocations.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ export class Allocation {
@Column({ name: 'item_id', type: 'int' })
itemId!: number;

@ManyToOne(() => DonationItem, (item) => item.allocations)
@ManyToOne(() => DonationItem, (item) => item.allocations, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'item_id' })
item!: DonationItem;

Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/config/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { DropDonationTotalColumns1772241115031 } from '../migrations/17722411150
import { FixTrackingLinks1773041840374 } from '../migrations/1773041840374-FixTrackingLinks';
import { CleanupRequestsAndAllocations1771821377918 } from '../migrations/1771821377918-CleanupRequestsAndAllocations';
import { AddDonationItemConfirmation1774140453305 } from '../migrations/1774140453305-AddDonationItemConfirmation';
import { DonationItemsAllocationsOnDeleteCascade1774214910101 } from '../migrations/1774214910101-DonationItemsOnDeleteCascade';

const schemaMigrations = [
User1725726359198,
Expand Down Expand Up @@ -78,6 +79,7 @@ const schemaMigrations = [
FixTrackingLinks1773041840374,
CleanupRequestsAndAllocations1771821377918,
AddDonationItemConfirmation1774140453305,
DonationItemsAllocationsOnDeleteCascade1774214910101,
];

export default schemaMigrations;
2 changes: 1 addition & 1 deletion apps/backend/src/donationItems/donationItems.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export class DonationItem {
@Column({ name: 'donation_id', type: 'int' })
donationId!: number;

@ManyToOne(() => Donation, { nullable: false })
@ManyToOne(() => Donation, { nullable: false, onDelete: 'CASCADE' })
@JoinColumn({ name: 'donation_id', referencedColumnName: 'donationId' })
donation!: Donation;

Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/donationItems/donationItems.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ import { Donation } from '../donations/donations.entity';
imports: [TypeOrmModule.forFeature([DonationItem, Donation]), AuthModule],
controllers: [DonationItemsController],
providers: [DonationItemsService],
exports: [DonationItemsService],
})
export class DonationItemsModule {}
13 changes: 13 additions & 0 deletions apps/backend/src/donationItems/dtos/create-donation-items.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,16 @@ export class CreateMultipleDonationItemsDto {
@Type(() => CreateDonationItemDto)
items!: CreateDonationItemDto[];
}

export class ReplaceDonationItemDto extends CreateDonationItemDto {
@IsOptional()
@IsNumber()
id?: number;
}

export class ReplaceDonationItemsDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ReplaceDonationItemDto)
items!: ReplaceDonationItemDto[];
}
60 changes: 60 additions & 0 deletions apps/backend/src/donations/donations.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { mock } from 'jest-mock-extended';
import { Donation } from './donations.entity';
import { CreateDonationDto } from './dtos/create-donation.dto';
import { DonationStatus, RecurrenceEnum } from './types';
import { DonationItem } from '../donationItems/donationItems.entity';
import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto';
import { FoodType } from '../donationItems/types';

const mockDonationService = mock<DonationService>();

Expand Down Expand Up @@ -120,4 +123,61 @@ describe('DonationsController', () => {
expect(mockDonationService.fulfill).toHaveBeenCalledWith(donationId);
});
});

describe('PUT /:donationId/items', () => {
it('should call donationService.replaceDonationItems and return updated donation', async () => {
const donationId = 1;

const replaceBody = {
items: [
{
id: 1,
itemName: 'Apples',
quantity: 10,
foodType: FoodType.DAIRY_FREE_ALTERNATIVES,
},
{
itemName: 'Oranges',
quantity: 5,
foodType: FoodType.DAIRY_FREE_ALTERNATIVES,
},
],
};

const updatedDonation: Partial<Donation> = {
donationId,
donationItems: [
{ itemId: 1, itemName: 'Apples', quantity: 10 } as DonationItem,
{ itemId: 2, itemName: 'Oranges', quantity: 5 } as DonationItem,
],
status: DonationStatus.AVAILABLE,
};

mockDonationService.replaceDonationItems.mockResolvedValueOnce(
updatedDonation as Donation,
);

const result = await controller.replaceDonationItems(
donationId,
replaceBody as ReplaceDonationItemsDto,
);

expect(result).toEqual(updatedDonation);
expect(mockDonationService.replaceDonationItems).toHaveBeenCalledWith(
donationId,
replaceBody,
);
});
});

describe('DELETE /:donationId', () => {
it('should call donationService.delete with the correct id', async () => {
const donationId = 1;

await controller.deleteDonation(donationId);

expect(mockDonationService.delete).toHaveBeenCalledWith(donationId);
expect(mockDonationService.delete).toHaveBeenCalledTimes(1);
});
});
});
18 changes: 18 additions & 0 deletions apps/backend/src/donations/donations.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import {
Patch,
Param,
ParseIntPipe,
Put,
Delete,
} from '@nestjs/common';
import { ApiBody } from '@nestjs/swagger';
import { Donation } from './donations.entity';
import { DonationService } from './donations.service';
import { RecurrenceEnum } from './types';
import { CreateDonationDto } from './dtos/create-donation.dto';
import { ReplaceDonationItemsDto } from '../donationItems/dtos/create-donation-items.dto';

@Controller('donations')
export class DonationsController {
Expand Down Expand Up @@ -77,4 +80,19 @@ export class DonationsController {
): Promise<Donation> {
return this.donationService.fulfill(donationId);
}

@Put('/:donationId/items')
async replaceDonationItems(
@Param('donationId', ParseIntPipe) donationId: number,
@Body() body: ReplaceDonationItemsDto,
): Promise<Donation> {
return this.donationService.replaceDonationItems(donationId, body);
}

@Delete('/:donationId')
async deleteDonation(
@Param('donationId', ParseIntPipe) donationId: number,
): Promise<void> {
return this.donationService.delete(donationId);
}
}
5 changes: 5 additions & 0 deletions apps/backend/src/donations/donations.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import {
CreateDateColumn,
JoinColumn,
ManyToOne,
OneToMany,
} from 'typeorm';
import { DonationStatus, RecurrenceEnum } from './types';
import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity';
import { DonationItem } from '../donationItems/donationItems.entity';

@Entity('donations')
export class Donation {
Expand Down Expand Up @@ -58,4 +60,7 @@ export class Donation {

@Column({ name: 'occurrences_remaining', type: 'int', nullable: true })
occurrencesRemaining!: number | null;

@OneToMany(() => DonationItem, (item) => item.donation)
donationItems!: DonationItem[];
}
17 changes: 16 additions & 1 deletion apps/backend/src/donations/donations.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,25 @@ import { DonationsController } from './donations.controller';
import { AuthModule } from '../auth/auth.module';
import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity';
import { DonationsSchedulerService } from './donations.scheduler';
import { DonationItem } from '../donationItems/donationItems.entity';
import { DonationItemsModule } from '../donationItems/donationItems.module';
import { Allocation } from '../allocations/allocations.entity';
import { AllocationModule } from '../allocations/allocations.module';

@Module({
imports: [TypeOrmModule.forFeature([Donation, FoodManufacturer]), AuthModule],
imports: [
TypeOrmModule.forFeature([
Donation,
FoodManufacturer,
DonationItem,
Allocation,
]),
AuthModule,
DonationItemsModule,
AllocationModule,
],
controllers: [DonationsController],
providers: [DonationService, DonationsSchedulerService],
exports: [DonationService],
})
export class DonationModule {}
143 changes: 143 additions & 0 deletions apps/backend/src/donations/donations.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ import { RecurrenceEnum, DayOfWeek, DonationStatus } from './types';
import { RepeatOnDaysDto } from './dtos/create-donation.dto';
import { testDataSource } from '../config/typeormTestDataSource';
import { NotFoundException } from '@nestjs/common';
import { DonationItemsService } from '../donationItems/donationItems.service';
import { DonationItem } from '../donationItems/donationItems.entity';
import { Allocation } from '../allocations/allocations.entity';
import { DataSource } from 'typeorm';
import {
ReplaceDonationItemDto,
ReplaceDonationItemsDto,
} from '../donationItems/dtos/create-donation-items.dto';
import { FoodType } from '../donationItems/types';

jest.setTimeout(60000);

Expand Down Expand Up @@ -83,6 +92,7 @@ const TODAYOfWeek = (iso: string): DayOfWeek => {

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

beforeAll(async () => {
if (!testDataSource.isInitialized) {
Expand All @@ -95,6 +105,7 @@ describe('DonationService', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DonationService,
DonationItemsService,
{
provide: getRepositoryToken(Donation),
useValue: testDataSource.getRepository(Donation),
Expand All @@ -103,10 +114,24 @@ describe('DonationService', () => {
provide: getRepositoryToken(FoodManufacturer),
useValue: testDataSource.getRepository(FoodManufacturer),
},
{
provide: getRepositoryToken(DonationItem),
useValue: testDataSource.getRepository(DonationItem),
},
{
provide: getRepositoryToken(Allocation),
useValue: testDataSource.getRepository(Allocation),
},
{
provide: DataSource,
useValue: testDataSource,
},
],
}).compile();

service = module.get<DonationService>(DonationService);
donationItemService =
module.get<DonationItemsService>(DonationItemsService);
});

beforeEach(async () => {
Expand Down Expand Up @@ -802,4 +827,122 @@ describe('DonationService', () => {
expect(result).toHaveLength(0);
});
});

describe('replaceDonationItems', () => {
it('should replace donation items for an available donation', async () => {
const donationId = 1;

// (update item1, remove item2, remove item3, add item 4)
const body = {
items: [
{
id: 1,
itemName: 'Green Apples',
quantity: 15,
} as Partial<ReplaceDonationItemDto>,
{
itemName: 'Bananas',
quantity: 20,
foodType: FoodType.DAIRY_FREE_ALTERNATIVES,
} as Partial<ReplaceDonationItemDto>,
],
} as ReplaceDonationItemsDto;

const updatedDonation = await service.replaceDonationItems(
donationId,
body,
);

expect(updatedDonation).toBeDefined();
expect(updatedDonation.donationItems).toHaveLength(2);

const updatedItemNames = updatedDonation.donationItems.map(
(i) => i.itemName,
);
expect(updatedItemNames).toContain('Green Apples'); // updated
expect(updatedItemNames).toContain('Bananas'); // new
expect(updatedItemNames).not.toContain('Canned Green Beans'); // deleted
expect(updatedItemNames).not.toContain('Whole Wheat Bread'); // deleted
});

it('should throw NotFoundException if donation does not exist', async () => {
const body = { items: [] };
await expect(service.replaceDonationItems(9999, body)).rejects.toThrow(
`Donation 9999 not found`,
);
});

it('should throw BadRequestException if donation is not AVAILABLE', async () => {
// Donation with status MATCHED
const donationId = 2;

const body = { items: [] };
await expect(
service.replaceDonationItems(donationId, body),
).rejects.toThrow('Only available donations can be deleted');
});

it('should throw NotFoundException if trying to update an item that does not exist within current donation', async () => {
const donationId = 1;

const body = {
items: [
{
id: 9999,
itemName: 'Nonexistent',
quantity: 1,
} as Partial<DonationItem>,
],
} as ReplaceDonationItemsDto;

await expect(
service.replaceDonationItems(donationId, body),
).rejects.toThrow(
`Donation item 9999 for Donation ${donationId} not found`,
);
});
});

describe('delete', () => {
it('should delete an available donation and associated donation items', async () => {
const allocationRepo = testDataSource.getRepository(Allocation);

const donationId = 3;

const donationBefore = await service.findOne(donationId);
expect(donationBefore).toBeDefined();
expect(donationBefore.status).toBe(DonationStatus.AVAILABLE);

await service.delete(donationId);

await expect(service.findOne(donationId)).rejects.toThrow(
`Donation ${donationId} not found`,
);

const items = await donationItemService.getAllDonationItems(donationId);
expect(items).toHaveLength(0);

const allocations = await allocationRepo.find({
where: {
item: { donation: { donationId } },
},
});
expect(allocations).toHaveLength(0);
});

it('should throw NotFoundException if donation does not exist', async () => {
await expect(service.delete(9999)).rejects.toThrow(
`Donation 9999 not found`,
);
});

it('should throw BadRequestException if donation is not AVAILABLE', async () => {
// donation with status MATCHED
const donationId = 2;

await expect(service.delete(donationId)).rejects.toThrow(
'Only available donations can be deleted',
);
});
});
});
Loading
Loading