Skip to content
147 changes: 119 additions & 28 deletions apps/backend/src/donationItems/donationItems.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { FoodType } from './types';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { testDataSource } from '../config/typeormTestDataSource';
import { CreateDonationItemDto } from './dtos/create-donation-items.dto';
import { ConfirmDonationItemDetailsDto } from './dtos/confirm-donation-item-details.dto';
import { UpdateDonationItemDetailsDto } from './dtos/update-donation-item-details.dto';

jest.setTimeout(60000);

Expand Down Expand Up @@ -214,6 +214,7 @@ describe('DonationItemsService', () => {
expect(Number(beans.estimatedValue)).toEqual(2.99);
expect(beans.foodType).toEqual(FoodType.DRIED_BEANS);
expect(beans.foodRescue).toEqual(false);
expect(beans.detailsConfirmed).toEqual(true);

expect(rice.itemId).toBeDefined();
expect(rice.donationId).toEqual(donation.donationId);
Expand All @@ -224,6 +225,7 @@ describe('DonationItemsService', () => {
expect(Number(rice.estimatedValue)).toEqual(4.99);
expect(rice.foodType).toEqual(FoodType.GRANOLA);
expect(rice.foodRescue).toEqual(true);
expect(rice.detailsConfirmed).toEqual(true);
});

it('creates items with optional fields omitted', async () => {
Expand All @@ -249,6 +251,48 @@ describe('DonationItemsService', () => {
expect(result[0].itemId).toBeDefined();
expect(result[0].ozPerItem).toBeNull();
expect(result[0].estimatedValue).toBeNull();
expect(result[0].detailsConfirmed).toEqual(false);
});

it('sets detailsConfirmed to true only when both ozPerItem and estimatedValue are provided', async () => {
const donation = await getSeedDonation();
const transactionManager = testDataSource.createEntityManager();

const mixedItems: CreateDonationItemDto[] = [
{
itemName: 'Both Fields',
quantity: 4,
ozPerItem: 12,
estimatedValue: 3.5,
foodType: FoodType.DRIED_BEANS,
foodRescue: false,
},
{
itemName: 'Missing Estimated Value',
quantity: 2,
ozPerItem: 8,
foodType: FoodType.DRIED_BEANS,
foodRescue: false,
},
{
itemName: 'Missing Oz Per Item',
quantity: 6,
estimatedValue: 1.99,
foodType: FoodType.DRIED_BEANS,
foodRescue: false,
},
];

const result = await service.createMultiple(
donation,
mixedItems,
transactionManager,
);

const byName = Object.fromEntries(result.map((i) => [i.itemName, i]));
expect(byName['Both Fields'].detailsConfirmed).toEqual(true);
expect(byName['Missing Estimated Value'].detailsConfirmed).toEqual(false);
expect(byName['Missing Oz Per Item'].detailsConfirmed).toEqual(false);
});

it('rolls back all items when one fails within a transaction', async () => {
Expand Down Expand Up @@ -297,8 +341,8 @@ describe('DonationItemsService', () => {
});
});

describe('confirmItemDetails', () => {
const makeDto = (itemId: number): ConfirmDonationItemDetailsDto => ({
describe('updateItemDetails', () => {
const makeDto = (itemId: number): UpdateDonationItemDetailsDto => ({
itemId,
ozPerItem: 5.0,
estimatedValue: 10.0,
Expand Down Expand Up @@ -339,7 +383,7 @@ describe('DonationItemsService', () => {
const donationId = await insertMatchedDonation();
await expect(
testDataSource.transaction((tm) =>
service.confirmItemDetails(donationId, [makeDto(99999)], tm),
service.updateItemDetails(donationId, [makeDto(99999)], tm),
),
).rejects.toThrow(new NotFoundException('Donation item 99999 not found'));
});
Expand All @@ -349,7 +393,7 @@ describe('DonationItemsService', () => {
// Item 1 belongs to donation 1, not the new donation
await expect(
testDataSource.transaction((tm) =>
service.confirmItemDetails(donationId, [makeDto(1)], tm),
service.updateItemDetails(donationId, [makeDto(1)], tm),
),
).rejects.toThrow(
new BadRequestException(
Expand All @@ -358,38 +402,19 @@ describe('DonationItemsService', () => {
);
});

it('throws BadRequestException when an item in the body is already confirmed', async () => {
const donationId = await insertMatchedDonation();
const itemId = await insertDonationItem(donationId, 10, 10);
await testDataSource.query(
`UPDATE donation_items SET details_confirmed = true WHERE item_id = $1`,
[itemId],
);

await expect(
testDataSource.transaction((tm) =>
service.confirmItemDetails(donationId, [makeDto(itemId)], tm),
),
).rejects.toThrow(
new BadRequestException(
`Donation item ${itemId} has already been confirmed`,
),
);
});

it('updates fields and sets detailsConfirmed to true for a single item', async () => {
const donationId = await insertMatchedDonation();
const itemId = await insertDonationItem(donationId, 10, 5);

const dto: ConfirmDonationItemDetailsDto = {
const dto: UpdateDonationItemDetailsDto = {
itemId,
ozPerItem: 8.5,
estimatedValue: 12.0,
foodRescue: false,
};

await testDataSource.transaction((tm) =>
service.confirmItemDetails(donationId, [dto], tm),
service.updateItemDetails(donationId, [dto], tm),
);

const item = await testDataSource
Expand All @@ -407,7 +432,7 @@ describe('DonationItemsService', () => {
const itemId2 = await insertDonationItem(donationId, 20, 10);

await testDataSource.transaction((tm) =>
service.confirmItemDetails(
service.updateItemDetails(
donationId,
[
{
Expand Down Expand Up @@ -452,7 +477,7 @@ describe('DonationItemsService', () => {
// Second dto references item 1 which belongs to donation 1, not ours
await expect(
testDataSource.transaction((tm) =>
service.confirmItemDetails(
service.updateItemDetails(
donationId,
[makeDto(itemId), makeDto(1)],
tm,
Expand All @@ -470,5 +495,71 @@ describe('DonationItemsService', () => {
expect(item?.detailsConfirmed).toBe(false);
expect(item?.ozPerItem).toBeNull();
});

it('returns false and does not confirm when only some fields are provided', async () => {
const donationId = await insertMatchedDonation();
const itemId = await insertDonationItem(donationId, 10, 5);

const result = await testDataSource.transaction((tm) =>
service.updateItemDetails(donationId, [{ itemId, ozPerItem: 8.5 }], tm),
);

expect(result).toBe(false);
const item = await testDataSource
.getRepository(DonationItem)
.findOneBy({ itemId });
expect(Number(item?.ozPerItem)).toBe(8.5);
expect(item?.estimatedValue).toBeNull();
expect(item?.detailsConfirmed).toBe(false);
});

it('confirms item on a second call that supplies the remaining fields', async () => {
const donationId = await insertMatchedDonation();
const itemId = await insertDonationItem(donationId, 10, 5);

const firstResult = await testDataSource.transaction((tm) =>
service.updateItemDetails(donationId, [{ itemId, ozPerItem: 8.5 }], tm),
);
expect(firstResult).toBe(false);

const secondResult = await testDataSource.transaction((tm) =>
service.updateItemDetails(
donationId,
[{ itemId, estimatedValue: 12.0, foodRescue: true }],
tm,
),
);
expect(secondResult).toBe(true);

const item = await testDataSource
.getRepository(DonationItem)
.findOneBy({ itemId });
expect(Number(item?.ozPerItem)).toBe(8.5);
expect(Number(item?.estimatedValue)).toBe(12.0);
expect(item?.foodRescue).toBe(true);
expect(item?.detailsConfirmed).toBe(true);
});

it('allows updating an already-confirmed item without throwing', async () => {
const donationId = await insertMatchedDonation();
const itemId = await insertDonationItem(donationId, 10, 5);
await testDataSource.query(
`UPDATE donation_items
SET details_confirmed = true, oz_per_item = 5.0, estimated_value = 10.0
WHERE item_id = $1`,
[itemId],
);

const result = await testDataSource.transaction((tm) =>
service.updateItemDetails(donationId, [{ itemId, ozPerItem: 9.0 }], tm),
);

expect(result).toBe(true);
const item = await testDataSource
.getRepository(DonationItem)
.findOneBy({ itemId });
expect(Number(item?.ozPerItem)).toBe(9.0);
expect(item?.detailsConfirmed).toBe(true);
});
});
});
44 changes: 29 additions & 15 deletions apps/backend/src/donationItems/donationItems.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@ import { DonationItem } from './donationItems.entity';
import { validateId } from '../utils/validation.utils';
import { FoodType } from './types';
import { Donation } from '../donations/donations.entity';
import { DonationStatus } from '../donations/types';
import { CreateDonationItemDto } from './dtos/create-donation-items.dto';
import { ConfirmDonationItemDetailsDto } from './dtos/confirm-donation-item-details.dto';
import { UpdateDonationItemDetailsDto } from './dtos/update-donation-item-details.dto';

@Injectable()
export class DonationItemsService {
Expand Down Expand Up @@ -103,14 +102,16 @@ export class DonationItemsService {
return this.repo.save(donationItem);
}

async confirmItemDetails(
async updateItemDetails(
donationId: number,
body: ConfirmDonationItemDetailsDto[],
body: UpdateDonationItemDetailsDto[],
transactionManager: EntityManager,
): Promise<void> {
): Promise<boolean> {
const donationItemTransactionRepo =
transactionManager.getRepository(DonationItem);

let confirmedDetailsForAnItem = false;

for (const dto of body) {
const item = await donationItemTransactionRepo.findOneBy({
itemId: dto.itemId,
Expand All @@ -126,19 +127,31 @@ export class DonationItemsService {
);
}

if (item.detailsConfirmed) {
throw new BadRequestException(
`Donation item ${dto.itemId} has already been confirmed`,
);
const updateData: Partial<DonationItem> = {};
if (dto.ozPerItem !== undefined) updateData.ozPerItem = dto.ozPerItem;
if (dto.estimatedValue !== undefined)
updateData.estimatedValue = dto.estimatedValue;
if (dto.foodRescue !== undefined) updateData.foodRescue = dto.foodRescue;

// If included in DTO, keep it, otherwise use whatever is in the DB (could be null)
const resultingOzPerItem =
updateData.ozPerItem !== undefined
? updateData.ozPerItem
: item.ozPerItem;
const resultingEstimatedValue =
updateData.estimatedValue !== undefined
? updateData.estimatedValue
: item.estimatedValue;

if (resultingOzPerItem != null && resultingEstimatedValue != null) {
updateData.detailsConfirmed = true;
confirmedDetailsForAnItem = true;
}

await donationItemTransactionRepo.update(dto.itemId, {
ozPerItem: dto.ozPerItem,
estimatedValue: dto.estimatedValue,
foodRescue: dto.foodRescue,
detailsConfirmed: true,
});
await donationItemTransactionRepo.update(dto.itemId, updateData);
}

return confirmedDetailsForAnItem;
}

async createMultiple(
Expand All @@ -158,6 +171,7 @@ export class DonationItemsService {
estimatedValue: item.estimatedValue,
foodType: item.foodType,
foodRescue: item.foodRescue,
detailsConfirmed: item.ozPerItem != null && item.estimatedValue != null,
}),
);
return transactionRepo.save(donationItems);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
import { IsNumber, Min, IsBoolean, IsInt } from 'class-validator';
import { IsNumber, Min, IsBoolean, IsInt, IsOptional } from 'class-validator';

export class ConfirmDonationItemDetailsDto {
export class UpdateDonationItemDetailsDto {
@IsInt()
Comment thread
dburkhart07 marked this conversation as resolved.
@Min(1)
itemId!: number;

@IsOptional()
@IsNumber(
{ maxDecimalPlaces: 2 },
{ message: 'Oz per item must have at most 2 decimal places' },
)
@Min(0.01, { message: 'Oz per item must be at least 0.01' })
ozPerItem!: number;
ozPerItem?: number;

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

@IsOptional()
@IsBoolean()
foodRescue!: boolean;
foodRescue?: boolean;
}
Loading
Loading