diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..bc4d724 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + mongodb-memory-server: set this to true or false + unrs-resolver: set this to true or false diff --git a/src/controllers/deliveryController.ts b/src/controllers/deliveryController.ts new file mode 100644 index 0000000..6e9aee9 --- /dev/null +++ b/src/controllers/deliveryController.ts @@ -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 => { + 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); + } +}; diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..3164547 --- /dev/null +++ b/src/middleware/auth.ts @@ -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(); + }; +}; diff --git a/src/models/deliveryModel.ts b/src/models/deliveryModel.ts new file mode 100644 index 0000000..d75f9ee --- /dev/null +++ b/src/models/deliveryModel.ts @@ -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( + { + 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('Delivery', deliverySchema); diff --git a/src/routes/deliveries.ts b/src/routes/deliveries.ts new file mode 100644 index 0000000..b5bf14b --- /dev/null +++ b/src/routes/deliveries.ts @@ -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; diff --git a/src/routes/index.ts b/src/routes/index.ts index 68e0f11..4d7aef5 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -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; diff --git a/src/services/deliveryService.ts b/src/services/deliveryService.ts new file mode 100644 index 0000000..23a5719 --- /dev/null +++ b/src/services/deliveryService.ts @@ -0,0 +1,52 @@ +import { isValidObjectId } from 'mongoose'; +import { Delivery, DeliveryDocument, DeliveryStatus } from '../models/deliveryModel'; +import { HttpError } from '../utils/httpError'; + +const statusTransitions: Record = { + 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 { + 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; + } +} diff --git a/src/utils/httpError.ts b/src/utils/httpError.ts new file mode 100644 index 0000000..7b3f1b2 --- /dev/null +++ b/src/utils/httpError.ts @@ -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); + } +} diff --git a/tests/deliveryStatusUpdate.test.ts b/tests/deliveryStatusUpdate.test.ts new file mode 100644 index 0000000..4ecf63c --- /dev/null +++ b/tests/deliveryStatusUpdate.test.ts @@ -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); + }); +});