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
3 changes: 3 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
allowBuilds:
mongodb-memory-server: set this to true or false
unrs-resolver: set this to true or false
43 changes: 43 additions & 0 deletions src/controllers/deliveryController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Request, Response, NextFunction } from 'express';
import { DeliveryService, allowedStatuses, isValidDeliveryStatus } from '../services/deliveryService';
import { HttpError } from '../utils/httpError';

interface UpdateDeliveryStatusRequest extends Request {
params: {
id: string;
};
body: {
status?: string;
};
}

export const updateDeliveryStatus = async (
req: UpdateDeliveryStatusRequest,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const { id } = req.params;
const { status } = req.body;

if (!status || typeof status !== 'string') {
throw new HttpError(400, 'Status is required and must be a string');
}

if (!isValidDeliveryStatus(status)) {
throw new HttpError(
400,
`Status must be one of: ${allowedStatuses.join(', ')}`,
);
}

const delivery = await DeliveryService.updateDeliveryStatus(id, status);

res.status(200).json({
status: 'success',
data: delivery,
});
} catch (error) {
next(error);
}
};
54 changes: 54 additions & 0 deletions src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { NextFunction, Request, Response } from 'express';
import jwt, { JwtPayload } from 'jsonwebtoken';
import { HttpError } from '../utils/httpError';

const jwtSecret = process.env.JWT_SECRET || 'changeme';

export interface AuthenticatedRequest extends Request {
user?: JwtPayload & {
role?: string;
sub?: string;
};
}

export const authenticate = (
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
): void => {
const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith('Bearer ')) {
next(new HttpError(401, 'Authorization header missing or malformed'));
return;
}

const token = authHeader.split(' ')[1];

try {
const payload = jwt.verify(token, jwtSecret);

if (typeof payload === 'string') {
next(new HttpError(401, 'Invalid authorization token'));
return;
}

req.user = payload;
next();
} catch (error) {
next(new HttpError(401, 'Invalid or expired authorization token'));
}
};

export const authorize = (allowedRoles: string[] = ['driver', 'admin']) => {
return (req: AuthenticatedRequest, res: Response, next: NextFunction): void => {
const role = req.user?.role;

if (!role || !allowedRoles.includes(role)) {
next(new HttpError(403, 'Insufficient permissions to perform this action'));
return;
}

next();
};
};
35 changes: 35 additions & 0 deletions src/models/deliveryModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Document, model, Schema } from 'mongoose';

export type DeliveryStatus = 'pending' | 'assigned' | 'picked_up' | 'in_transit' | 'delivered';

export interface DeliveryDocument extends Document {
customerName: string;
pickupLocation: string;
dropoffLocation: string;
packageDetails: string;
status: DeliveryStatus;
assignedDriver?: string;
createdAt: Date;
updatedAt: Date;
}

const deliverySchema = new Schema<DeliveryDocument>(
{
customerName: { type: String, required: true, trim: true },
pickupLocation: { type: String, required: true, trim: true },
dropoffLocation: { type: String, required: true, trim: true },
packageDetails: { type: String, required: true, trim: true },
status: {
type: String,
enum: ['pending', 'assigned', 'picked_up', 'in_transit', 'delivered'],
default: 'pending',
required: true,
},
assignedDriver: { type: String, default: null },
},
{
timestamps: true,
},
);

export const Delivery = model<DeliveryDocument>('Delivery', deliverySchema);
9 changes: 9 additions & 0 deletions src/routes/deliveries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Router } from 'express';
import { authenticate, authorize } from '../middleware/auth';
import { updateDeliveryStatus } from '../controllers/deliveryController';

const router = Router();

router.put('/:id/status', authenticate, authorize(['driver', 'admin']), updateDeliveryStatus);

export default router;
5 changes: 2 additions & 3 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { Router } from 'express';
import deliveryRoutes from './deliveries';

const router = Router();

// Define your routes here
// router.use('/auth', authRoutes);
// router.use('/users', userRoutes);
router.use('/deliveries', deliveryRoutes);

export default router;
52 changes: 52 additions & 0 deletions src/services/deliveryService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { isValidObjectId } from 'mongoose';
import { Delivery, DeliveryDocument, DeliveryStatus } from '../models/deliveryModel';
import { HttpError } from '../utils/httpError';

const statusTransitions: Record<DeliveryStatus, DeliveryStatus[]> = {
pending: ['assigned'],
assigned: ['picked_up'],
picked_up: ['in_transit'],
in_transit: ['delivered'],
delivered: [],
};

export const allowedStatuses = Object.keys(statusTransitions) as DeliveryStatus[];

export const isValidDeliveryStatus = (status: string): status is DeliveryStatus => {
return allowedStatuses.includes(status as DeliveryStatus);
};

const isValidTransition = (current: DeliveryStatus, next: DeliveryStatus): boolean => {
return statusTransitions[current].includes(next);
};

export class DeliveryService {
static async updateDeliveryStatus(id: string, status: DeliveryStatus): Promise<DeliveryDocument> {
if (!isValidObjectId(id)) {
throw new HttpError(400, 'Invalid delivery id');
}

const delivery = await Delivery.findById(id);

if (!delivery) {
throw new HttpError(404, 'Delivery not found');
}

if (delivery.status === status) {
return delivery;
}

if (!isValidTransition(delivery.status, status)) {
const availableNext = statusTransitions[delivery.status].join(', ') || 'none';
throw new HttpError(
400,
`Invalid status transition from '${delivery.status}' to '${status}'. Allowed next statuses: ${availableNext}`,
);
}

delivery.status = status;
await delivery.save();

return delivery;
}
}
10 changes: 10 additions & 0 deletions src/utils/httpError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export class HttpError extends Error {
statusCode: number;

constructor(statusCode: number, message: string) {
super(message);
this.statusCode = statusCode;
Object.setPrototypeOf(this, HttpError.prototype);
Error.captureStackTrace?.(this, this.constructor);
}
}
99 changes: 99 additions & 0 deletions tests/deliveryStatusUpdate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import request from 'supertest';
import mongoose from 'mongoose';
import jwt from 'jsonwebtoken';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { Delivery } from '../src/models/deliveryModel';

jest.setTimeout(60000);

jest.mock('../src/config/logger', () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
}));

describe('Delivery Status Update API', () => {
let app: any;
let mongoServer: MongoMemoryServer;
const jwtSecret = 'test-secret';

const buildToken = (role = 'driver'): string => {
return jwt.sign({ sub: 'test-user-id', role }, jwtSecret, { expiresIn: '1h' });
};

beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
process.env.MONGODB_URI = mongoServer.getUri();
process.env.JWT_SECRET = jwtSecret;

const imported = await import('../src/app');
app = imported.default;
});

afterAll(async () => {
await mongoose.connection.dropDatabase();
await mongoose.disconnect();
await mongoServer.stop();
});

afterEach(async () => {
await Delivery.deleteMany({});
});

it('returns 401 when authorization token is missing', async () => {
const response = await request(app)
.put('/api/v1/deliveries/507f1f77bcf86cd799439011/status')
.send({ status: 'assigned' });

expect(response.status).toBe(401);
expect(response.body).toHaveProperty('status', 'error');
expect(response.body.message).toMatch(/Authorization header missing or malformed/i);
});

it('updates delivery status after a valid transition', async () => {
const delivery = await Delivery.create({
customerName: 'Alice',
pickupLocation: '123 Market St',
dropoffLocation: '456 Park Ave',
packageDetails: 'Small parcel',
status: 'pending',
});

const token = buildToken('driver');

const response = await request(app)
.put(`/api/v1/deliveries/${delivery._id}/status`)
.set('Authorization', `Bearer ${token}`)
.send({ status: 'assigned' });

expect(response.status).toBe(200);
expect(response.body).toHaveProperty('status', 'success');
expect(response.body.data).toHaveProperty('status', 'assigned');

const updated = await Delivery.findById(delivery._id);
expect(updated).not.toBeNull();
expect(updated?.status).toBe('assigned');
});

it('returns 400 for invalid status transitions', async () => {
const delivery = await Delivery.create({
customerName: 'Alice',
pickupLocation: '123 Market St',
dropoffLocation: '456 Park Ave',
packageDetails: 'Small parcel',
status: 'pending',
});

const token = buildToken('driver');

const response = await request(app)
.put(`/api/v1/deliveries/${delivery._id}/status`)
.set('Authorization', `Bearer ${token}`)
.send({ status: 'delivered' });

expect(response.status).toBe(400);
expect(response.body).toHaveProperty('status', 'error');
expect(response.body.message).toMatch(/Invalid status transition/i);
});
});