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
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[];
}
21 changes: 21 additions & 0 deletions apps/backend/src/foodManufacturers/dtos/donation-details-dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { FoodType } from '../../donationItems/types';
import { Donation } from '../../donations/donations.entity';

export class DonationItemWithAllocatedQuantityDto {
itemId!: number;
itemName!: string;
foodType!: FoodType;
allocatedQuantity!: number;
}

export class DonationOrderDetailsDto {
orderId!: number;
pantryId!: number;
pantryName!: string;
}

export class DonationDetailsDto {
donation!: Donation;
associatedPendingOrders!: DonationOrderDetailsDto[];
relevantDonationItems!: DonationItemWithAllocatedQuantityDto[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { Donation } from '../donations/donations.entity';
import { UpdateFoodManufacturerApplicationDto } from './dtos/update-manufacturer-application.dto';
import { NotFoundException } from '@nestjs/common';
import { AuthenticatedRequest } from '../auth/authenticated-request';
import { DonationDetailsDto } from './dtos/donation-details-dto';
import { FoodType } from '../donationItems/types';

const mockManufacturersService = mock<FoodManufacturersService>();

Expand Down Expand Up @@ -87,7 +89,7 @@ describe('FoodManufacturersController', () => {
});

describe('GET /:foodManufacturerId/donations', () => {
it('should return donations for a given food manufacturer', async () => {
it('should return donation details for a given food manufacturer', async () => {
const mockDonations: Partial<Donation>[] = [
{
donationId: 1,
Expand All @@ -98,14 +100,48 @@ describe('FoodManufacturersController', () => {
foodManufacturer: { foodManufacturerId: 1 } as FoodManufacturer,
},
];
const mockDonationDetails: DonationDetailsDto[] = [
{
donation: mockDonations[0] as Donation,
associatedPendingOrders: [
{
orderId: 1,
pantryId: 2,
pantryName: 'Community Food Pantry',
},
],
relevantDonationItems: [
{
itemId: 1,
itemName: 'Almond Breeze Almond Milk',
foodType: FoodType.DAIRY_FREE_ALTERNATIVES,
allocatedQuantity: 10,
},
],
},
{
donation: mockDonations[1] as Donation,
associatedPendingOrders: [],
relevantDonationItems: [],
},
];

const req = { user: { id: 1 } };

mockManufacturersService.getFMDonations.mockResolvedValue(
mockDonations as Donation[],
mockDonationDetails,
);

const result = await controller.getFoodManufacturerDonations(1);
const result = await controller.getFoodManufacturerDonations(
req as AuthenticatedRequest,
1,
);

expect(result).toBe(mockDonations);
expect(mockManufacturersService.getFMDonations).toHaveBeenCalledWith(1);
expect(result).toBe(mockDonationDetails);
expect(mockManufacturersService.getFMDonations).toHaveBeenCalledWith(
1,
1,
);
});
});

Expand Down
11 changes: 8 additions & 3 deletions apps/backend/src/foodManufacturers/manufacturers.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ import { FoodManufacturer } from './manufacturers.entity';
import { FoodManufacturerApplicationDto } from './dtos/manufacturer-application.dto';
import { ApiBody } from '@nestjs/swagger';
import { Allergen, DonateWastedFood, ManufacturerAttribute } from './types';
import { Donation } from '../donations/donations.entity';
import { Public } from '../auth/public.decorator';
import { UpdateFoodManufacturerApplicationDto } from './dtos/update-manufacturer-application.dto';
import { Roles } from '../auth/roles.decorator';
import { Role } from '../users/types';
import { AuthenticatedRequest } from '../auth/authenticated-request';
import { DonationDetailsDto } from './dtos/donation-details-dto';

@Controller('manufacturers')
export class FoodManufacturersController {
Expand All @@ -37,11 +37,16 @@ export class FoodManufacturersController {
return this.foodManufacturersService.findOne(foodManufacturerId);
}

@Roles(Role.FOODMANUFACTURER)
@Get('/:foodManufacturerId/donations')
async getFoodManufacturerDonations(
@Req() req: AuthenticatedRequest,
@Param('foodManufacturerId', ParseIntPipe) foodManufacturerId: number,
): Promise<Donation[]> {
return this.foodManufacturersService.getFMDonations(foodManufacturerId);
): Promise<DonationDetailsDto[]> {
return this.foodManufacturersService.getFMDonations(
foodManufacturerId,
req.user.id,
);
}

@ApiBody({
Expand Down
161 changes: 148 additions & 13 deletions apps/backend/src/foodManufacturers/manufacturers.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { FoodManufacturersService } from './manufacturers.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { FoodManufacturer } from './manufacturers.entity';
import {
BadRequestException,
ConflictException,
InternalServerErrorException,
NotFoundException,
Expand All @@ -24,6 +25,8 @@ import { PantriesService } from '../pantries/pantries.service';
import { mock } from 'jest-mock-extended';
import { emailTemplates, SSF_PARTNER_EMAIL } from '../emails/emailTemplates';
import { Allergen, DonateWastedFood, ManufacturerAttribute } from './types';
import { FoodType } from '../donationItems/types';
import { DonationStatus } from '../donations/types';

jest.setTimeout(60000);

Expand Down Expand Up @@ -362,24 +365,156 @@ describe('FoodManufacturersService', () => {
});

describe('getFMDonations', () => {
it('returns donations for an existing manufacturer', async () => {
const donations = await service.getFMDonations(1);
expect(Array.isArray(donations)).toBe(true);
const fmRepId1 = 3;
const fmRepId2 = 4;
const fmId1 = 1;
const fmId2 = 2;
const availableDonationId = 1;
const fulfilledDonationId = 4;
const matchingDonationId = 3;

it('throws NotFoundException for non-existent manufacturer', async () => {
await expect(service.getFMDonations(9999, fmRepId1)).rejects.toThrow(
new NotFoundException('Food Manufacturer 9999 not found'),
);
});

it('returns empty array for manufacturer with no donations', async () => {
await service.addFoodManufacturer(dto);
const saved = await testDataSource
.getRepository(FoodManufacturer)
.findOne({ where: { foodManufacturerName: 'Test Manufacturer' } });
const donations = await service.getFMDonations(saved!.foodManufacturerId);
expect(donations).toEqual([]);
it('throws BadRequestException when user is not the representative of the food manufacturer', async () => {
await expect(service.getFMDonations(fmId1, fmRepId2)).rejects.toThrow(
new BadRequestException(
`User ${fmRepId2} is not allowed to access donations for Food Manufacturer ${fmId1}`,
),
);
});

it('throws NotFoundException for non-existent manufacturer', async () => {
await expect(service.getFMDonations(9999)).rejects.toThrow(
new NotFoundException('Food Manufacturer 9999 not found'),
it('returns empty array when manufacturer has no matched donations', async () => {
const result = await service.getFMDonations(fmId1, fmRepId1);
expect(result).toEqual([]);
});

it('returns matched donations with empty orders and items when no pending orders exist', async () => {
await testDataSource.query(
`UPDATE public.donations SET status = 'matched'
WHERE donation_id = $1`,
[availableDonationId],
);

const result = await service.getFMDonations(fmId1, fmRepId1);

expect(result).toHaveLength(1);
expect(result[0].associatedPendingOrders).toEqual([]);
expect(result[0].relevantDonationItems).toEqual([]);
});

it('returns pending orders with correct pantry info for matched donations', async () => {
await testDataSource.query(
`UPDATE public.donations SET status = 'matched' WHERE donation_id = $1`,
[fulfilledDonationId],
);

const result = await service.getFMDonations(fmId1, fmRepId1);

expect(result).toHaveLength(1);
expect(result[0].associatedPendingOrders).toHaveLength(1);

const order = result[0].associatedPendingOrders[0];
expect(order.pantryName).toBe('Community Food Pantry Downtown');
expect(order.pantryId).toBe(1);
expect(order.orderId).toBeDefined();
});

it('returns unconfirmed donation items used in pending orders', async () => {
await testDataSource.query(
`UPDATE public.donations SET status = 'matched' WHERE donation_id = $1`,
[fulfilledDonationId],
);

const result = await service.getFMDonations(fmId1, fmRepId1);

expect(result[0].relevantDonationItems).toHaveLength(1);
const item = result[0].relevantDonationItems[0];
expect(item.itemName).toBe('Cereal Boxes');
expect(item.allocatedQuantity).toBe(75);
expect(item.foodType).toBe(FoodType.GLUTEN_FREE_BREAD);
});

it('excludes donation items where detailsConfirmed is true', async () => {
await testDataSource.query(
`UPDATE public.donations SET status = 'matched' WHERE donation_id = $1`,
[fulfilledDonationId],
);

await testDataSource.query(
`UPDATE public.donation_items SET details_confirmed = true
WHERE item_name = 'Cereal Boxes'`,
);

const result = await service.getFMDonations(fmId1, fmRepId1);

expect(result[0].relevantDonationItems).toEqual([]);
});

it('excludes donation items not used in any pending order', async () => {
await testDataSource.query(
`UPDATE public.donations SET status = 'matched' WHERE donation_id = $1`,
[availableDonationId],
);

const result = await service.getFMDonations(fmId1, fmRepId1);

expect(result[0].relevantDonationItems).toEqual([]);
});

it('correctly sums allocatedQuantity across multiple pending orders for the same item', async () => {
await testDataSource.query(
`UPDATE public.donations SET status = 'matched' WHERE donation_id = $1`,
[matchingDonationId],
);

const almondMilkItemId = (
await testDataSource.query(
`SELECT item_id FROM public.donation_items WHERE item_name = 'Almond Milk' ORDER BY item_id DESC LIMIT 1`,
)
)[0].item_id;

const requestId = (
await testDataSource.query(
`SELECT request_id FROM public.food_requests
WHERE additional_information LIKE '%breakfast items%' LIMIT 1`,
)
)[0].request_id;

const newOrder = await testDataSource.query(
`INSERT INTO public.orders (request_id, food_manufacturer_id, status, created_at)
VALUES ($1, $2, 'pending', NOW()) RETURNING order_id`,
[requestId, fmId2],
);

await testDataSource.query(
`INSERT INTO public.allocations (order_id, item_id, allocated_quantity)
VALUES ($1, $2, 5)`,
[newOrder[0].order_id, almondMilkItemId],
);

const result = await service.getFMDonations(fmId2, fmRepId2);

const almond = result[0].relevantDonationItems.find(
(i) => i.itemName === 'Almond Milk',
);
expect(almond?.allocatedQuantity).toBe(15); // 10 + 5
});

it('only returns matched donations, not available or fulfilled ones', async () => {
await testDataSource.query(
`UPDATE public.donations SET status = 'matched' WHERE donation_id = $1`,
[fulfilledDonationId],
);

const result = await service.getFMDonations(fmId1, fmRepId1);

expect(result).toHaveLength(1);
expect(result[0].donation.donationId).toBe(fulfilledDonationId);
expect(result[0].donation.status).toBe(DonationStatus.MATCHED);
});
});
});
Loading
Loading