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
134 changes: 134 additions & 0 deletions apps/backend/src/donationItems/donationItems.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import { testDataSource } from '../config/typeormTestDataSource';
import { CreateDonationItemDto } from './dtos/create-donation-items.dto';
import { UpdateDonationItemDetailsDto } from './dtos/update-donation-item-details.dto';
import { ReplaceDonationItemDto } from './dtos/replace-donation-item.dto';

jest.setTimeout(60000);

Expand Down Expand Up @@ -562,4 +563,137 @@ describe('DonationItemsService', () => {
expect(item?.detailsConfirmed).toBe(true);
});
});

describe('editItems', () => {
const makeItem = (
overrides: Partial<ReplaceDonationItemDto> = {},
): ReplaceDonationItemDto => ({
itemName: 'Edited Item',
quantity: 20,
ozPerItem: 8,
estimatedValue: 3.5,
foodType: FoodType.QUINOA,
foodRescue: true,
...overrides,
});

const donationId = 3;
const itemA = 7;
const itemB = 8;

beforeEach(async () => {
await testDataSource.query(
`DELETE FROM allocations WHERE item_id IN ($1, $2)`,
[itemA, itemB],
);
});

it('updates existing items, inserts new items, and deletes omitted items', async () => {
await testDataSource.transaction((tm) =>
service.editItems(
donationId,
[
makeItem({
itemId: itemA,
itemName: 'Item A Updated',
quantity: 99,
}),
makeItem({ itemName: 'Brand New Item' }),
],
tm,
),
);

const items = await service.getAllDonationItems(donationId);
expect(items).toHaveLength(2);

const names = items.map((i) => i.itemName).sort();
expect(names).toEqual(['Brand New Item', 'Item A Updated']);

const updated = items.find((i) => i.itemId === itemA) as DonationItem;
expect(updated.quantity).toBe(99);
expect(updated.foodRescue).toBe(true);
expect(updated.foodType).toBe(FoodType.QUINOA);
expect(Number(updated.ozPerItem)).toBe(8);
expect(Number(updated.estimatedValue)).toBe(3.5);
expect(updated.detailsConfirmed).toBe(true);

const inserted = items.find(
(i) => i.itemName === 'Brand New Item',
) as DonationItem;
expect(inserted.donationId).toBe(donationId);
expect(inserted.reservedQuantity).toBe(0);
expect(inserted.detailsConfirmed).toBe(true);

await expect(service.findOne(itemB)).rejects.toThrow();
});

it('deletes all existing items absent from the body', async () => {
await testDataSource.transaction((tm) =>
service.editItems(donationId, [makeItem({ itemName: 'Only New' })], tm),
);

const items = await service.getAllDonationItems(donationId);
expect(items).toHaveLength(1);
expect(items[0].itemName).toBe('Only New');

await expect(service.findOne(itemA)).rejects.toThrow();
await expect(service.findOne(itemB)).rejects.toThrow();
});

it('throws BadRequestException when an itemId does not belong to the donation', async () => {
const foreignItemId = 11;

await expect(
testDataSource.transaction((tm) =>
service.editItems(
donationId,
[makeItem({ itemId: foreignItemId })],
tm,
),
),
).rejects.toThrow(
new BadRequestException(
`Donation item ${foreignItemId} does not belong to Donation ${donationId}`,
),
);
});

it('throws BadRequestException when the same itemId appears twice', async () => {
await expect(
testDataSource.transaction((tm) =>
service.editItems(
donationId,
[makeItem({ itemId: itemA }), makeItem({ itemId: itemA })],
tm,
),
),
).rejects.toThrow(
new BadRequestException(`Duplicate itemId ${itemA} in request`),
);
});

it('rolls back all changes when one item fails to persist within the transaction', async () => {
await expect(
testDataSource.transaction((tm) =>
service.editItems(
donationId,
[
makeItem({ itemId: itemA, itemName: 'Item A Updated' }),
makeItem({ itemName: 'a'.repeat(1000) }), // exceeds varchar(255)
],
tm,
),
),
).rejects.toThrow();

const items = await service.getAllDonationItems(donationId);
expect(items).toHaveLength(2);

const a = items.find((i) => i.itemId === itemA);
expect(a?.itemName).toBe('Rice (5lb bag)');
const b = items.find((i) => i.itemId === itemB);
expect(b).toBeDefined();
});
});
});
65 changes: 65 additions & 0 deletions apps/backend/src/donationItems/donationItems.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { FoodType } from './types';
import { Donation } from '../donations/donations.entity';
import { CreateDonationItemDto } from './dtos/create-donation-items.dto';
import { UpdateDonationItemDetailsDto } from './dtos/update-donation-item-details.dto';
import { ReplaceDonationItemDto } from './dtos/replace-donation-item.dto';

@Injectable()
export class DonationItemsService {
Expand Down Expand Up @@ -131,6 +132,70 @@ export class DonationItemsService {
return confirmedDetailsForAnItem;
}

async editItems(
donationId: number,
body: ReplaceDonationItemDto[],
transactionManager: EntityManager,
): Promise<void> {
const itemRepo = transactionManager.getRepository(DonationItem);

const existingItems = await itemRepo.find({ where: { donationId } });
const existingIds = new Set(existingItems.map((item) => item.itemId));

const providedIds = new Set<number>();
for (const dto of body) {
if (dto.itemId === undefined) continue;

if (providedIds.has(dto.itemId)) {
throw new BadRequestException(
`Duplicate itemId ${dto.itemId} in request`,
);
}
providedIds.add(dto.itemId);

if (!existingIds.has(dto.itemId)) {
throw new BadRequestException(
`Donation item ${dto.itemId} does not belong to Donation ${donationId}`,
);
}
}

const idsToDelete = existingItems
.map((item) => item.itemId)
.filter((id) => !providedIds.has(id));

if (idsToDelete.length > 0) {
await itemRepo.delete({ itemId: In(idsToDelete) });
}

for (const dto of body) {
if (dto.itemId !== undefined) {
await itemRepo.update(dto.itemId, {
itemName: dto.itemName,
quantity: dto.quantity,
ozPerItem: dto.ozPerItem,
estimatedValue: dto.estimatedValue,
foodType: dto.foodType,
foodRescue: dto.foodRescue,
detailsConfirmed: true,
});
} else {
const newItem = itemRepo.create({
donationId,
itemName: dto.itemName,
quantity: dto.quantity,
reservedQuantity: 0,
ozPerItem: dto.ozPerItem,
estimatedValue: dto.estimatedValue,
foodType: dto.foodType,
foodRescue: dto.foodRescue,
detailsConfirmed: true,
});
await itemRepo.save(newItem);
}
}
}

async createMultiple(
savedDonation: Donation,
items: CreateDonationItemDto[],
Expand Down
49 changes: 49 additions & 0 deletions apps/backend/src/donationItems/dtos/replace-donation-item.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
IsNumber,
IsString,
Min,
IsEnum,
IsNotEmpty,
Length,
IsOptional,
IsInt,
IsBoolean,
} from 'class-validator';
import { FoodType } from '../types';

// itemId present = update row, else add
export class ReplaceDonationItemDto {
@IsOptional()
@IsInt()
@Min(1)
itemId?: number;

@IsString()
@IsNotEmpty()
@Length(1, 255)
itemName!: string;

@IsInt()
@Min(1)
quantity!: number;

@IsNumber(
{ maxDecimalPlaces: 2 },
{ message: 'ozPerItem must have at most 2 decimal places' },
)
@Min(0.01)
ozPerItem!: number;

@IsNumber(
{ maxDecimalPlaces: 2 },
{ message: 'estimatedValue must have at most 2 decimal places' },
)
@Min(0.01)
estimatedValue!: number;

@IsEnum(FoodType)
foodType!: FoodType;

@IsBoolean()
foodRescue!: boolean;
}
34 changes: 34 additions & 0 deletions apps/backend/src/donations/donations.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { CreateDonationDto } from './dtos/create-donation.dto';
import { CreateDonationItemDto } from '../donationItems/dtos/create-donation-items.dto';
import { DonationStatus, RecurrenceEnum } from './types';
import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto';
import { ReplaceDonationItemDto } from '../donationItems/dtos/replace-donation-item.dto';
import { FoodType } from '../donationItems/types';

const mockDonationService = mock<DonationService>();

Expand Down Expand Up @@ -130,6 +132,38 @@ describe('DonationsController', () => {
});
});

describe('PATCH /:donationId/item', () => {
it('calls editDonationItems with the correct donationId and body', async () => {
const donationId = 1;
const body: ReplaceDonationItemDto[] = [
{
itemName: 'Brand New Item',
quantity: 10,
ozPerItem: 5,
estimatedValue: 2,
foodType: FoodType.QUINOA,
foodRescue: false,
},
{
itemId: 3,
itemName: 'Existing Item Updated',
quantity: 5,
ozPerItem: 8,
estimatedValue: 3,
foodType: FoodType.GRANOLA,
foodRescue: true,
},
];

await controller.editDonationItems(donationId, body);

expect(mockDonationService.editDonationItems).toHaveBeenCalledWith(
donationId,
body,
);
});
});

describe('DELETE /:donationId', () => {
it('should call donationService.delete with the correct id', async () => {
const donationId = 1;
Expand Down
26 changes: 26 additions & 0 deletions apps/backend/src/donations/donations.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { DonationService } from './donations.service';
import { RecurrenceEnum } from './types';
import { CreateDonationDto } from './dtos/create-donation.dto';
import { UpdateDonationItemDetailsDto } from '../donationItems/dtos/update-donation-item-details.dto';
import { ReplaceDonationItemDto } from '../donationItems/dtos/replace-donation-item.dto';
import { FoodType } from '../donationItems/types';
import { Roles } from '../auth/roles.decorator';
import { Role } from '../users/types';
Expand Down Expand Up @@ -118,6 +119,31 @@ export class DonationsController {
await this.donationService.updateDonationItemDetails(donationId, body);
}

@Roles(Role.FOODMANUFACTURER)
@CheckOwnership({
idParam: 'donationId',
resolver: async ({ entityId, services }) => {
return pipeNullable(
() => services.get(DonationService).findOne(entityId),
(donation: Donation) =>
services
.get(FoodManufacturersService)
.findOne(donation.foodManufacturer.foodManufacturerId),
(manufacturer: FoodManufacturer) => [
manufacturer.foodManufacturerRepresentative.id,
],
);
},
})
@Patch('/:donationId/item')
async editDonationItems(
@Param('donationId', ParseIntPipe) donationId: number,
@Body(new ParseArrayPipe({ items: ReplaceDonationItemDto }))
body: ReplaceDonationItemDto[],
): Promise<void> {
await this.donationService.editDonationItems(donationId, body);
}

@Delete('/:donationId')
async deleteDonation(
@Param('donationId', ParseIntPipe) donationId: number,
Expand Down
Loading
Loading