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
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();
});
});
});
55 changes: 55 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,60 @@ 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 } });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

we only use the ids, so we should just be able to put the itemRepo.find and map into one call and then delete existingItems altogether, and just use existingIds on 163

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) });
}

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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@Yurika-Kan can you explain the rationale behind making these required? the case im considering:

if i make a new donation with 2 items where i dont fill out the ozPerItem and estimatedValue, the next time i go to update the donation, i cant just make an edit to one of them, i have to update all fields for all values. i think they should be allowed to go in and just update the ozPerItem for one item if thats all they have.

lmk if im missing something though!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

To my understanding we cannot now make new donation items without ozPerItem and estimatedValue?

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.

They wanted to make all those fields now required upon creation so this was to keep consistency with that request

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.

ie oz, donation val, food rescue

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

okay, then in that case, while it wasnt directly in the ticket, i think we should make an update to the donationItems entity, and write a new migration to fix it in the database and fix the frontend create donations so everything is consistent again

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I asked Yurika specifically about the entity/db and she said it wasn't necessary at the time @Yurika-Kan thoughts?

{ 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
32 changes: 31 additions & 1 deletion apps/backend/src/donations/donations.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,29 @@ 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';
import { CheckOwnership, pipeNullable } from '../auth/ownership.decorator';
import {
CheckOwnership,
OwnerIdResolver,
pipeNullable,
} from '../auth/ownership.decorator';
import { FoodManufacturersService } from '../foodManufacturers/manufacturers.service';
import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity';

const resolveDonationAuthorizedUserIds: OwnerIdResolver = ({
entityId,
services,
}) =>
pipeNullable(
() => services.get(DonationService).findOne(entityId),
(donation: Donation) => [
donation.foodManufacturer.foodManufacturerRepresentative.id,
],
);

@Controller('donations')
export class DonationsController {
constructor(private donationService: DonationService) {}
Expand Down Expand Up @@ -118,6 +134,20 @@ export class DonationsController {
await this.donationService.updateDonationItemDetails(donationId, body);
}

@Roles(Role.FOODMANUFACTURER)
@CheckOwnership({
Comment thread
Juwang110 marked this conversation as resolved.
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);
}

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