Skip to content
Open
2 changes: 2 additions & 0 deletions apps/backend/src/config/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { RemoveUnusedStatuses1764816885341 } from '../migrations/1764816885341-R
import { UpdatePantryFields1763762628431 } from '../migrations/1763762628431-UpdatePantryFields';
import { PopulateDummyData1768501812134 } from '../migrations/1768501812134-populateDummyData';
import { RemovePantryFromOrders1769316004958 } from '../migrations/1769316004958-RemovePantryFromOrders';
import { UpdateOrderEntity1769990652833 } from '../migrations/1769990652833-UpdateOrderEntity';

const schemaMigrations = [
User1725726359198,
Expand Down Expand Up @@ -54,6 +55,7 @@ const schemaMigrations = [
RemoveUnusedStatuses1764816885341,
PopulateDummyData1768501812134,
RemovePantryFromOrders1769316004958,
UpdateOrderEntity1769990652833,
];

export default schemaMigrations;
24 changes: 24 additions & 0 deletions apps/backend/src/migrations/1769990652833-UpdateOrderEntity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class UpdateOrderEntity1769990652833 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE orders
ADD COLUMN IF NOT EXISTS tracking_link VARCHAR(255),
ADD COLUMN IF NOT EXISTS shipping_cost NUMERIC(10,2);

UPDATE orders
SET tracking_link = 'www.samplelink/samplelink',
shipping_cost = 20.00
WHERE status = 'delivered' OR status = 'shipped' AND shipped_at IS NOT NULL;
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE orders
DROP COLUMN IF EXISTS tracking_link,
DROP COLUMN IF EXISTS shipping_cost;
`);
}
}
15 changes: 15 additions & 0 deletions apps/backend/src/orders/dtos/tracking-cost.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { IsUrl, IsNumber, Min, IsOptional } from 'class-validator';

export class TrackingCostDto {
@IsUrl({}, { message: 'Tracking link must be a valid URL' })
@IsOptional()
trackingLink?: string;

@IsNumber(
{ maxDecimalPlaces: 2 },
{ message: 'Shipping cost must have at most 2 decimal places' },
)
@Min(0, { message: 'Shipping cost cannot be negative' })
@IsOptional()
shippingCost?: number;
}
166 changes: 166 additions & 0 deletions apps/backend/src/orders/order.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { mock } from 'jest-mock-extended';
import { OrderStatus } from './types';
import { FoodRequest } from '../foodRequests/request.entity';
import { Pantry } from '../pantries/pantries.entity';
import { TrackingCostDto } from './dtos/tracking-cost.dto';
import { BadRequestException } from '@nestjs/common';
import { FoodManufacturer } from '../foodManufacturers/manufacturer.entity';

const mockOrdersService = mock<OrdersService>();
const mockAllocationsService = mock<AllocationsService>();
Expand All @@ -27,21 +30,29 @@ describe('OrdersController', () => {
{ requestId: 3, pantry: mockPantries[2] as Pantry },
];

const mockFoodManufacturer: Partial<FoodManufacturer> = {
foodManufacturerId: 1,
foodManufacturerName: 'Test FM',
};

const mockOrders: Partial<Order>[] = [
{
orderId: 1,
status: OrderStatus.PENDING,
request: mockRequests[0] as FoodRequest,
foodManufacturer: mockFoodManufacturer as FoodManufacturer,
},
{
orderId: 2,
status: OrderStatus.DELIVERED,
request: mockRequests[1] as FoodRequest,
foodManufacturer: mockFoodManufacturer as FoodManufacturer,
},
{
orderId: 3,
status: OrderStatus.SHIPPED,
request: mockRequests[2] as FoodRequest,
foodManufacturer: mockFoodManufacturer as FoodManufacturer,
},
];

Expand Down Expand Up @@ -85,6 +96,122 @@ describe('OrdersController', () => {
});
});

describe('getCurrentOrders', () => {
it('should call ordersService.getCurrentOrders and return orders', async () => {
mockOrdersService.getCurrentOrders.mockResolvedValueOnce([
mockOrders[0],
mockOrders[2],
] as Order[]);

const result = await controller.getCurrentOrders();

expect(result).toEqual([mockOrders[0], mockOrders[2]] as Order[]);
expect(mockOrdersService.getCurrentOrders).toHaveBeenCalled();
});
});

describe('getPastOrders', () => {
it('should call ordersService.getPastOrders and return orders', async () => {
mockOrdersService.getPastOrders.mockResolvedValueOnce([
mockOrders[1],
] as Order[]);

const result = await controller.getPastOrders();

expect(result).toEqual([mockOrders[1]] as Order[]);
expect(mockOrdersService.getPastOrders).toHaveBeenCalled();
});
});

describe('getPantryFromOrder', () => {
it('should call ordersService.findOrderPantry and return pantry', async () => {
const orderId = 1;
mockOrdersService.findOrderPantry.mockResolvedValueOnce(
mockPantries[0] as Pantry,
);

const result = await controller.getPantryFromOrder(orderId);

expect(result).toEqual(mockPantries[0] as Pantry);
expect(mockOrdersService.findOrderPantry).toHaveBeenCalledWith(orderId);
});

it('should throw for non-numeric input', async () => {
const invalidInput = NaN;
mockOrdersService.findOrderPantry.mockRejectedValue(
new BadRequestException(
'Validation failed (numeric string is expected)',
),
);

await expect(controller.getPantryFromOrder(invalidInput)).rejects.toThrow(
new BadRequestException(
'Validation failed (numeric string is expected)',
),
);
});
});

describe('getRequestFromOrder', () => {
it('should call ordersService.findOrderFoodRequest and return food request', async () => {
const orderId = 1;
mockOrdersService.findOrderFoodRequest.mockResolvedValueOnce(
mockRequests[0] as FoodRequest,
);

const result = await controller.getRequestFromOrder(orderId);

expect(result).toEqual(mockRequests[0] as Pantry);
expect(mockOrdersService.findOrderFoodRequest).toHaveBeenCalledWith(
orderId,
);
});
});

describe('getManufacturerFromOrder', () => {
it('should call ordersService.findOrderFoodManufacturer and return FM', async () => {
const orderId = 1;
mockOrdersService.findOrderFoodManufacturer.mockResolvedValueOnce(
mockFoodManufacturer as FoodManufacturer,
);

const result = await controller.getManufacturerFromOrder(orderId);

expect(result).toEqual(mockFoodManufacturer as FoodManufacturer);
expect(mockOrdersService.findOrderFoodManufacturer).toHaveBeenCalledWith(
orderId,
);
});
});

describe('getOrder', () => {
it('should call ordersService.findOne and return order', async () => {
const orderId = 1;
mockOrdersService.findOne.mockResolvedValueOnce(mockOrders[0] as Order);

const result = await controller.getOrder(orderId);

expect(result).toEqual(mockOrders[0] as Order);
expect(mockOrdersService.findOne).toHaveBeenCalledWith(orderId);
});
});

describe('getOrderByRequestId', () => {
it('should call ordersService.findOrderByRequest and return order', async () => {
const requestId = 1;
mockOrdersService.findOrderByRequest.mockResolvedValueOnce(
mockOrders[0] as Order,
);

const result = await controller.getOrderByRequestId(requestId);

expect(result).toEqual(mockOrders[0] as Order);
expect(mockOrdersService.findOrderByRequest).toHaveBeenCalledWith(
requestId,
);
});
});

describe('getAllAllocationsByOrder', () => {
it('should call allocationsService.getAllAllocationsByOrder and return allocations', async () => {
const orderId = 1;
Expand All @@ -100,4 +227,43 @@ describe('OrdersController', () => {
).toHaveBeenCalledWith(orderId);
});
});

describe('updateStatus', () => {
it('should call ordersService.updateStatus', async () => {
const status = OrderStatus.DELIVERED;
const orderId = 1;

await controller.updateStatus(orderId, status);

expect(mockOrdersService.updateStatus).toHaveBeenCalledWith(
orderId,
status,
);
});

it('should throw with invalid status', async () => {
const invalidStatus = 'invalid status';
const orderId = 1;

await expect(
controller.updateStatus(orderId, invalidStatus),
).rejects.toThrow(new BadRequestException('Invalid status'));
});
});

describe('updateTrackingCostInfo', () => {
it('should call ordersService.updateTrackingCostInfo with correct parameters', async () => {
const orderId = 1;
const trackingLink = 'www.samplelink/samplelink';
const shippingCost = 15.99;
const dto: TrackingCostDto = { trackingLink, shippingCost };

await controller.updateTrackingCostInfo(orderId, dto);

expect(mockOrdersService.updateTrackingCostInfo).toHaveBeenCalledWith(
orderId,
dto,
);
});
});
});
11 changes: 11 additions & 0 deletions apps/backend/src/orders/order.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Body,
Query,
BadRequestException,
ValidationPipe,
} from '@nestjs/common';
import { OrdersService } from './order.service';
import { Order } from './order.entity';
Expand All @@ -15,6 +16,7 @@ import { FoodManufacturer } from '../foodManufacturers/manufacturer.entity';
import { FoodRequest } from '../foodRequests/request.entity';
import { AllocationsService } from '../allocations/allocations.service';
import { OrderStatus } from './types';
import { TrackingCostDto } from './dtos/tracking-cost.dto';

@Controller('orders')
export class OrdersController {
Expand Down Expand Up @@ -99,4 +101,13 @@ export class OrdersController {
}
return this.ordersService.updateStatus(orderId, newStatus as OrderStatus);
}

@Patch('/:orderId/update-tracking-cost-info')
async updateTrackingCostInfo(
@Param('orderId', ParseIntPipe) orderId: number,
@Body(new ValidationPipe())
dto: TrackingCostDto,
): Promise<void> {
return this.ordersService.updateTrackingCostInfo(orderId, dto);
}
}
39 changes: 28 additions & 11 deletions apps/backend/src/orders/order.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,27 @@ import { Allocation } from '../allocations/allocations.entity';
@Entity('orders')
export class Order {
@PrimaryGeneratedColumn({ name: 'order_id' })
orderId: number;
orderId!: number;

@ManyToOne(() => FoodRequest, { nullable: false })
@JoinColumn({
name: 'request_id',
referencedColumnName: 'requestId',
})
request: FoodRequest;
request!: FoodRequest;

@Column({ name: 'request_id' })
requestId: number;
requestId!: number;

@ManyToOne(() => FoodManufacturer, { nullable: false })
@ManyToOne(() => FoodManufacturer, { nullable: true })
@JoinColumn({
name: 'shipped_by',
referencedColumnName: 'foodManufacturerId',
})
foodManufacturer: FoodManufacturer;
foodManufacturer?: FoodManufacturer;

@Column({ name: 'shipped_by', nullable: true })
shippedBy: number;
shippedBy?: number;

@Column({
name: 'status',
Expand All @@ -44,29 +44,46 @@ export class Order {
enum: OrderStatus,
default: OrderStatus.PENDING,
})
status: OrderStatus;
status!: OrderStatus;

@CreateDateColumn({
name: 'created_at',
type: 'timestamp',
default: () => 'NOW()',
})
createdAt: Date;
createdAt!: Date;

@Column({
name: 'shipped_at',
type: 'timestamp',
nullable: true,
})
shippedAt: Date | null;
shippedAt?: Date;

@Column({
name: 'delivered_at',
type: 'timestamp',
nullable: true,
})
deliveredAt: Date | null;
deliveredAt?: Date;

@OneToMany(() => Allocation, (allocation) => allocation.order)
allocations: Allocation[];
allocations!: Allocation[];

@Column({
name: 'tracking_link',
type: 'varchar',
length: 255,
nullable: true,
})
trackingLink?: string;

@Column({
name: 'shipping_cost',
type: 'numeric',
precision: 10,
scale: 2,
nullable: true,
})
shippingCost?: number;
}
Loading