Skip to content
Merged
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
128 changes: 128 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,131 @@ 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 () => {
Comment thread
Juwang110 marked this conversation as resolved.
const itemsBefore = await service.getAllDonationItems(donationId);
expect(itemsBefore).toHaveLength(2);

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);
Comment thread
Juwang110 marked this conversation as resolved.
// updated itemA + inserted one new + deleted itemB => count unchanged at 2
expect(items).toHaveLength(itemsBefore.length);
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);

// itemB was omitted from the body, so it should be deleted
expect(items.some((i) => i.itemId === itemB)).toBe(false);
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();
});
});
});
59 changes: 59 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,64 @@ export class DonationItemsService {
return confirmedDetailsForAnItem;
}

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

const existingIds = new Set(
(
await itemRepo.find({
where: { donationId },
select: { itemId: true },
})
).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 = [...existingIds].filter((id) => !providedIds.has(id));

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

const itemsToSave = body.map((dto) =>
itemRepo.create({
...(dto.itemId !== undefined
? { itemId: dto.itemId }
: { donationId, reservedQuantity: 0 }),
itemName: dto.itemName,
quantity: dto.quantity,
ozPerItem: dto.ozPerItem,
estimatedValue: dto.estimatedValue,
foodType: dto.foodType,
foodRescue: dto.foodRescue,
detailsConfirmed: true,
}),
);

await itemRepo.save(itemsToSave);
}

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(
Comment thread
Juwang110 marked this conversation as resolved.
{ 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';
import { AuthenticatedRequest } from '../auth/authenticated-request';
import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service';

Expand Down Expand Up @@ -127,6 +129,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
15 changes: 15 additions & 0 deletions apps/backend/src/donations/donations.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,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 @@ -137,6 +138,20 @@ export class DonationsController {
await this.donationService.updateDonationItemDetails(donationId, body);
}

@Roles(Role.FOODMANUFACTURER)
@CheckOwnership({
idParam: 'donationId',
resolver: resolveDonationAuthorizedUserIds,
})
@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);
}

@Roles(Role.FOODMANUFACTURER)
@CheckOwnership({
idParam: 'donationId',
Expand Down
Loading
Loading