diff --git a/src/controllers/delivery.controller.ts b/src/controllers/delivery.controller.ts new file mode 100644 index 0000000..013f8c8 --- /dev/null +++ b/src/controllers/delivery.controller.ts @@ -0,0 +1,152 @@ +import { Request, Response, NextFunction } from 'express'; +import httpStatus from 'http-status-codes'; +import { DeliveryStatus } from '../models/Delivery'; +import { + deliveryService, + CreateDeliveryInput, + UpdateDeliveryInput, + DeliveryFilter, +} from '../services/delivery.service'; + +interface AuthenticatedRequest extends Request { + user?: { id: string }; +} + +export class DeliveryController { + async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const input: CreateDeliveryInput = { + trackingNumber: req.body.trackingNumber, + customer: req.body.customer, + pickup: req.body.pickup, + dropoff: req.body.dropoff, + package: req.body.package, + deliveryFee: req.body.deliveryFee, + escrowAmount: req.body.escrowAmount, + notes: req.body.notes, + }; + + const delivery = await deliveryService.create(input); + res.status(httpStatus.CREATED).json({ + status: 'success', + data: delivery, + }); + } catch (error) { + next(error); + } + } + + async getById(req: Request, res: Response, next: NextFunction): Promise { + try { + const delivery = await deliveryService.getById(req.params.id); + res.status(httpStatus.OK).json({ + status: 'success', + data: delivery, + }); + } catch (error) { + next(error); + } + } + + async list(req: Request, res: Response, next: NextFunction): Promise { + try { + const statusParam = req.query.status as string | undefined; + const validatedStatus = Object.values(DeliveryStatus).includes(statusParam as DeliveryStatus) + ? (statusParam as DeliveryStatus) + : undefined; + + const filters: DeliveryFilter = { + status: validatedStatus, + driver: req.query.driver as string | undefined, + search: req.query.search as string | undefined, + page: req.query.page ? parseInt(req.query.page as string, 10) : 1, + limit: req.query.limit ? parseInt(req.query.limit as string, 10) : 10, + }; + + const result = await deliveryService.list(filters); + res.status(httpStatus.OK).json({ + status: 'success', + data: result.data, + meta: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }, + }); + } catch (error) { + next(error); + } + } + + async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const input: UpdateDeliveryInput = { + status: req.body.status, + driver: req.body.driver, + estimatedDistance: req.body.estimatedDistance, + estimatedDuration: req.body.estimatedDuration, + stellarTransactionId: req.body.stellarTransactionId, + notes: req.body.notes, + }; + + const delivery = await deliveryService.update(req.params.id, input); + res.status(httpStatus.OK).json({ + status: 'success', + data: delivery, + }); + } catch (error) { + next(error); + } + } + + async archive(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = (req as AuthenticatedRequest).user?.id; + const delivery = await deliveryService.archive(req.params.id, userId); + res.status(httpStatus.OK).json({ + status: 'success', + data: delivery, + message: 'Delivery archived successfully', + }); + } catch (error) { + next(error); + } + } + + async restore(req: Request, res: Response, next: NextFunction): Promise { + try { + const delivery = await deliveryService.restore(req.params.id); + res.status(httpStatus.OK).json({ + status: 'success', + data: delivery, + message: 'Delivery restored successfully', + }); + } catch (error) { + next(error); + } + } + + async listArchived(req: Request, res: Response, next: NextFunction): Promise { + try { + const page = req.query.page ? parseInt(req.query.page as string, 10) : 1; + const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 10; + + const result = await deliveryService.listArchived(page, limit); + res.status(httpStatus.OK).json({ + status: 'success', + data: result.data, + meta: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }, + }); + } catch (error) { + next(error); + } + } +} + +export const deliveryController = new DeliveryController(); diff --git a/src/models/Delivery.ts b/src/models/Delivery.ts index e7d6b4d..c432a32 100644 --- a/src/models/Delivery.ts +++ b/src/models/Delivery.ts @@ -1,187 +1,210 @@ -import { Schema, model, Document, Types } from 'mongoose'; - -// ─── Enums ──────────────────────────────────────────────────────────────────── +import mongoose, { Document, Schema, Model, Query } from 'mongoose'; +import logger from '../config/logger'; export enum DeliveryStatus { - PENDING = 'pending', // Created off-chain, not yet submitted to Soroban - ON_CHAIN = 'on_chain', // Transaction submitted and confirmed on Stellar - IN_TRANSIT = 'in_transit', // Courier has picked up the package - DELIVERED = 'delivered', // Package delivered, escrow can be released - DISPUTED = 'disputed', // A dispute has been raised - CANCELLED = 'cancelled', // Delivery was cancelled before pick-up + PENDING = 'Pending', + ASSIGNED = 'Assigned', + PICKED_UP = 'Picked Up', + IN_TRANSIT = 'In Transit', + DELIVERED = 'Delivered', + CANCELLED = 'Cancelled', } -// ─── Sub-document interfaces ─────────────────────────────────────────────────── +export enum DeliverySize { + SMALL = 'Small', + MEDIUM = 'Medium', + LARGE = 'Large', + EXTRA_LARGE = 'Extra Large', +} -export interface IAddress { - street: string; +export interface ILocation { + address: string; city: string; state: string; - postalCode: string; - country: string; + zipCode: string; + lat?: number; + lng?: number; + instructions?: string; } -export interface IPackageDetails { - weight: number; // kg - dimensions?: { - length: number; // cm - width: number; // cm - height: number; // cm - }; +export interface IPackage { description: string; - fragile: boolean; + weight: number; + size: DeliverySize; + itemValue?: number; + isFragile: boolean; + requiresSignature: boolean; } -export interface IEscrow { - amount: number; // Amount in XLM - stellarAsset: string; // Asset code, e.g. 'XLM' or 'USDC' - contractId?: string; // Soroban contract ID — populated after on-chain creation - txHash?: string; // Stellar transaction hash — populated after on-chain creation -} - -// ─── Main document interface ─────────────────────────────────────────────────── - export interface IDelivery extends Document { - _id: Types.ObjectId; trackingNumber: string; - status: DeliveryStatus; - - sender: { + customer: { name: string; - email: string; phone: string; - stellarAddress: string; - address: IAddress; + email?: string; }; - - recipient: { - name: string; - email: string; - phone: string; - stellarAddress: string; - address: IAddress; - }; - - packageDetails: IPackageDetails; - escrow: IEscrow; - - estimatedDeliveryDate?: Date; - actualDeliveryDate?: Date; + pickup: ILocation; + dropoff: ILocation; + package: IPackage; + status: DeliveryStatus; + driver?: mongoose.Types.ObjectId; + estimatedDistance?: number; + estimatedDuration?: number; + deliveryFee: number; + escrowAmount: number; + stellarTransactionId?: string; notes?: string; - + isDeleted: boolean; + deletedAt: Date | null; + deletedBy?: mongoose.Types.ObjectId; createdAt: Date; updatedAt: Date; + + softDelete(userId?: string): Promise; + restore(): Promise; } -// ─── Sub-document schemas ────────────────────────────────────────────────────── +export interface IDeliveryModel extends Model { + findAvailable(): Promise; +} -const AddressSchema = new Schema( +const locationSchema = new Schema( { - street: { type: String, required: true, trim: true }, + address: { type: String, required: true, trim: true }, city: { type: String, required: true, trim: true }, state: { type: String, required: true, trim: true }, - postalCode: { type: String, required: true, trim: true }, - country: { type: String, required: true, trim: true }, + zipCode: { type: String, required: true, trim: true }, + lat: { type: Number }, + lng: { type: Number }, + instructions: { type: String, trim: true }, }, { _id: false }, ); -const PackageDetailsSchema = new Schema( +const packageSchema = new Schema( { + description: { type: String, required: true, trim: true }, weight: { type: Number, required: true, min: 0 }, - dimensions: { - length: { type: Number, min: 0 }, - width: { type: Number, min: 0 }, - height: { type: Number, min: 0 }, - }, - description: { type: String, required: true, trim: true, maxlength: 500 }, - fragile: { type: Boolean, required: true, default: false }, - }, - { _id: false }, -); - -const EscrowSchema = new Schema( - { - amount: { type: Number, required: true, min: 0 }, - stellarAsset: { type: String, required: true, trim: true, default: 'XLM' }, - contractId: { type: String, trim: true }, - txHash: { type: String, trim: true }, + size: { type: String, enum: Object.values(DeliverySize), required: true }, + itemValue: { type: Number, min: 0 }, + isFragile: { type: Boolean, default: false }, + requiresSignature: { type: Boolean, default: false }, }, { _id: false }, ); -// ─── Main schema ─────────────────────────────────────────────────────────────── - -const DeliverySchema = new Schema( +const deliverySchema = new Schema( { trackingNumber: { type: String, required: true, unique: true, - uppercase: true, trim: true, index: true, }, - + customer: { + name: { type: String, required: true, trim: true }, + phone: { type: String, required: true, trim: true }, + email: { type: String, trim: true, lowercase: true }, + }, + pickup: { type: locationSchema, required: true }, + dropoff: { type: locationSchema, required: true }, + package: { type: packageSchema, required: true }, status: { type: String, enum: Object.values(DeliveryStatus), - required: true, default: DeliveryStatus.PENDING, index: true, }, - - sender: { - name: { type: String, required: true, trim: true }, - email: { - type: String, - required: true, - trim: true, - lowercase: true, - match: [/^\S+@\S+\.\S+$/, 'Please provide a valid sender email address'], - }, - phone: { type: String, required: true, trim: true }, - stellarAddress: { type: String, required: true, trim: true }, - address: { type: AddressSchema, required: true }, + driver: { + type: Schema.Types.ObjectId, + ref: 'User', + index: true, }, - - recipient: { - name: { type: String, required: true, trim: true }, - email: { - type: String, - required: true, - trim: true, - lowercase: true, - match: [/^\S+@\S+\.\S+$/, 'Please provide a valid recipient email address'], - }, - phone: { type: String, required: true, trim: true }, - stellarAddress: { type: String, required: true, trim: true }, - address: { type: AddressSchema, required: true }, + estimatedDistance: { type: Number, min: 0 }, + estimatedDuration: { type: Number, min: 0 }, + deliveryFee: { type: Number, required: true, min: 0 }, + escrowAmount: { type: Number, required: true, min: 0 }, + stellarTransactionId: { type: String, trim: true }, + notes: { type: String, trim: true }, + isDeleted: { type: Boolean, default: false, index: true }, + deletedAt: { type: Date, default: null }, + deletedBy: { + type: Schema.Types.ObjectId, + ref: 'User', }, - - packageDetails: { type: PackageDetailsSchema, required: true }, - escrow: { type: EscrowSchema, required: true }, - - estimatedDeliveryDate: { type: Date }, - actualDeliveryDate: { type: Date }, - notes: { type: String, trim: true, maxlength: 1000 }, }, { - timestamps: true, // auto-manages createdAt / updatedAt + timestamps: true, toJSON: { - virtuals: true, - transform: (_doc, ret) => { - // Expose id as a plain string instead of keeping only _id - ret.id = ret._id.toString(); + transform(_doc: unknown, ret: Record): void { delete ret.__v; - return ret; }, }, }, ); -// ─── Model ───────────────────────────────────────────────────────────────────── - -const Delivery = model('Delivery', DeliverySchema); +deliverySchema.pre>('find', function (next) { + if ((this.getOptions() as Record).includeDeleted) { + return next(); + } + this.where({ isDeleted: false }); + next(); +}); + +deliverySchema.pre>('findOne', function (next) { + if ((this.getOptions() as Record).includeDeleted) { + return next(); + } + this.where({ isDeleted: false }); + next(); +}); + +deliverySchema.pre>('findOneAndUpdate', function (next) { + if ((this.getOptions() as Record).includeDeleted) { + return next(); + } + this.where({ isDeleted: false }); + next(); +}); + +deliverySchema.pre>('countDocuments', function (next) { + if ((this.getOptions() as Record).includeDeleted) { + return next(); + } + this.where({ isDeleted: false }); + next(); +}); + +deliverySchema.methods.softDelete = async function ( + this: IDelivery, + userId?: string, +): Promise { + this.isDeleted = true; + this.deletedAt = new Date(); + if (userId) { + this.deletedBy = new mongoose.Types.ObjectId(userId); + } + logger.info(`Delivery ${this.trackingNumber} soft-deleted`); + return this.save(); +}; + +deliverySchema.methods.restore = async function (this: IDelivery): Promise { + this.isDeleted = false; + this.deletedAt = null; + this.deletedBy = undefined; + logger.info(`Delivery ${this.trackingNumber} restored`); + return this.save(); +}; + +deliverySchema.statics.findAvailable = async function (): Promise { + return this.find({ status: DeliveryStatus.PENDING, driver: null }).sort({ createdAt: -1 }).exec(); +}; + +deliverySchema.index({ status: 1, isDeleted: 1 }); +deliverySchema.index({ 'customer.phone': 1 }); +deliverySchema.index({ createdAt: -1 }); + +const Delivery = mongoose.model('Delivery', deliverySchema); export default Delivery; diff --git a/src/routes/delivery.routes.ts b/src/routes/delivery.routes.ts new file mode 100644 index 0000000..f0b783e --- /dev/null +++ b/src/routes/delivery.routes.ts @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { deliveryController } from '../controllers/delivery.controller'; + +const router = Router(); + +router.post('/', deliveryController.create.bind(deliveryController)); +router.get('/', deliveryController.list.bind(deliveryController)); +router.get('/archived', deliveryController.listArchived.bind(deliveryController)); +router.get('/:id', deliveryController.getById.bind(deliveryController)); +router.patch('/:id', deliveryController.update.bind(deliveryController)); +router.patch('/:id/archive', deliveryController.archive.bind(deliveryController)); +router.patch('/:id/restore', deliveryController.restore.bind(deliveryController)); + +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index 4d7aef5..08d6870 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import deliveryRoutes from './deliveries'; +import deliveryRoutes from './delivery.routes'; const router = Router(); diff --git a/src/server.ts b/src/server.ts index ec2c7ad..0bddd59 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,3 +1,7 @@ +import dotenv from 'dotenv'; + +dotenv.config(); + import app from './app'; import logger from './config/logger'; import env from './config/env'; diff --git a/src/services/delivery.service.ts b/src/services/delivery.service.ts new file mode 100644 index 0000000..a350e8d --- /dev/null +++ b/src/services/delivery.service.ts @@ -0,0 +1,185 @@ +import { Types } from 'mongoose'; +import httpStatus from 'http-status-codes'; +import Delivery, { IDelivery, DeliveryStatus, ILocation, IPackage } from '../models/Delivery'; +import { AppError } from '../utils/AppError'; +import logger from '../config/logger'; + +export interface CreateDeliveryInput { + trackingNumber: string; + customer: { + name: string; + phone: string; + email?: string; + }; + pickup: ILocation; + dropoff: ILocation; + package: IPackage; + deliveryFee: number; + escrowAmount: number; + notes?: string; +} + +export interface UpdateDeliveryInput { + status?: DeliveryStatus; + driver?: string; + estimatedDistance?: number; + estimatedDuration?: number; + stellarTransactionId?: string; + notes?: string; +} + +export interface DeliveryFilter { + status?: DeliveryStatus; + driver?: string; + search?: string; + page?: number; + limit?: number; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export class DeliveryService { + async create(input: CreateDeliveryInput): Promise { + const existing = await Delivery.findOne({ + trackingNumber: input.trackingNumber, + }).setOptions({ includeDeleted: true }); + + if (existing) { + throw new AppError('Delivery with this tracking number already exists', httpStatus.CONFLICT); + } + + const delivery = await Delivery.create(input); + logger.info(`Delivery created: ${delivery.trackingNumber}`); + return delivery; + } + + async getById(id: string): Promise { + if (!Types.ObjectId.isValid(id)) { + throw new AppError('Invalid delivery ID', httpStatus.BAD_REQUEST); + } + + const delivery = await Delivery.findById(id); + if (!delivery) { + throw new AppError('Delivery not found', httpStatus.NOT_FOUND); + } + return delivery; + } + + async list(filters: DeliveryFilter): Promise> { + const { status, driver, search, page = 1, limit = 10 } = filters; + + const query: Record = {}; + + if (status) { + query.status = status; + } + + if (driver) { + query.driver = new Types.ObjectId(driver); + } + + if (search) { + query.$or = [ + { trackingNumber: { $regex: search, $options: 'i' } }, + { 'customer.name': { $regex: search, $options: 'i' } }, + { 'customer.phone': { $regex: search, $options: 'i' } }, + ]; + } + + const skip = (page - 1) * limit; + const [data, total] = await Promise.all([ + Delivery.find(query).sort({ createdAt: -1 }).skip(skip).limit(limit).exec(), + Delivery.countDocuments(query).exec(), + ]); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async update(id: string, input: UpdateDeliveryInput): Promise { + if (!Types.ObjectId.isValid(id)) { + throw new AppError('Invalid delivery ID', httpStatus.BAD_REQUEST); + } + + const delivery = await Delivery.findByIdAndUpdate( + id, + { $set: input }, + { new: true, runValidators: true }, + ); + + if (!delivery) { + throw new AppError('Delivery not found', httpStatus.NOT_FOUND); + } + + logger.info(`Delivery updated: ${delivery.trackingNumber}`); + return delivery; + } + + async archive(id: string, userId?: string): Promise { + if (!Types.ObjectId.isValid(id)) { + throw new AppError('Invalid delivery ID', httpStatus.BAD_REQUEST); + } + + const delivery = await Delivery.findById(id).setOptions({ includeDeleted: true }); + if (!delivery) { + throw new AppError('Delivery not found', httpStatus.NOT_FOUND); + } + + if (delivery.isDeleted) { + throw new AppError('Delivery is already archived', httpStatus.CONFLICT); + } + + return delivery.softDelete(userId); + } + + async restore(id: string): Promise { + if (!Types.ObjectId.isValid(id)) { + throw new AppError('Invalid delivery ID', httpStatus.BAD_REQUEST); + } + + const delivery = await Delivery.findById(id).setOptions({ includeDeleted: true }); + if (!delivery) { + throw new AppError('Delivery not found', httpStatus.NOT_FOUND); + } + + if (!delivery.isDeleted) { + throw new AppError('Delivery is not archived', httpStatus.CONFLICT); + } + + return delivery.restore(); + } + + async listArchived(page = 1, limit = 10): Promise> { + const skip = (page - 1) * limit; + const [data, total] = await Promise.all([ + Delivery.find({ isDeleted: true }) + .setOptions({ includeDeleted: true }) + .sort({ deletedAt: -1 }) + .skip(skip) + .limit(limit) + .exec(), + Delivery.countDocuments({ isDeleted: true }).setOptions({ includeDeleted: true }).exec(), + ]); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } +} + +export const deliveryService = new DeliveryService(); diff --git a/src/utils/AppError.ts b/src/utils/AppError.ts index 93f2dd3..5190bab 100644 --- a/src/utils/AppError.ts +++ b/src/utils/AppError.ts @@ -1,27 +1,11 @@ -/** - * Custom application error class that extends the native Error. - * Carries an HTTP status code so the global error handler can - * respond with the correct status without any extra mapping. - */ -class AppError extends Error { +export class AppError extends Error { public readonly statusCode: number; - public readonly isOperational: boolean; constructor(message: string, statusCode: number) { super(message); - this.statusCode = statusCode; - // Operational errors are expected (bad input, not found, etc.). - // Programmer errors should NOT set this flag. - this.isOperational = true; - - // Restore the prototype chain so `instanceof AppError` works correctly - // after TypeScript compiles down to ES5. - Object.setPrototypeOf(this, new.target.prototype); + this.name = 'AppError'; - // Capture stack trace, excluding the constructor frame itself - Error.captureStackTrace(this, this.constructor); + Object.setPrototypeOf(this, AppError.prototype); } } - -export default AppError; diff --git a/tests/delivery.test.ts b/tests/delivery.test.ts index 3f949a3..bbe2865 100644 --- a/tests/delivery.test.ts +++ b/tests/delivery.test.ts @@ -2,10 +2,8 @@ import request from 'supertest'; import mongoose from 'mongoose'; import { MongoMemoryServer } from 'mongodb-memory-server'; import app from '../src/app'; +import Delivery from '../src/models/Delivery'; -// ─── Module mocks ────────────────────────────────────────────────────────────── - -// Prevent the real DB connection in app.ts from firing during tests jest.mock('../src/config/database', () => ({ connectDatabase: jest.fn(), })); @@ -17,309 +15,285 @@ jest.mock('../src/config/logger', () => ({ debug: jest.fn(), })); -// ─── In-memory MongoDB ───────────────────────────────────────────────────────── +const mockDeliveryInput = { + trackingNumber: 'SWIFT-001', + customer: { + name: 'John Doe', + phone: '+1234567890', + email: 'john@example.com', + }, + pickup: { + address: '123 Pickup St', + city: 'New York', + state: 'NY', + zipCode: '10001', + instructions: 'Ring bell', + }, + dropoff: { + address: '456 Dropoff Ave', + city: 'Brooklyn', + state: 'NY', + zipCode: '11201', + }, + package: { + description: 'Electronics', + weight: 2.5, + size: 'Medium', + isFragile: true, + requiresSignature: true, + }, + deliveryFee: 15.99, + escrowAmount: 150.0, +}; let mongoServer: MongoMemoryServer; -// MongoMemoryServer may need to download its binary on first run (can take >60s). -// Set an explicit timeout so Jest does not fail the hook prematurely. -const SETUP_TIMEOUT = 120_000; // 2 minutes - beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); - await mongoose.connect(mongoServer.getUri()); -}, SETUP_TIMEOUT); - -afterEach(async () => { - // Clean up all collections between tests to keep them independent - const collections = mongoose.connection.collections; - for (const key in collections) { - await collections[key].deleteMany({}); - } -}, 15_000); + const uri = mongoServer.getUri(); + await mongoose.connect(uri); +}); afterAll(async () => { await mongoose.disconnect(); await mongoServer.stop(); -}, 15_000); - -// ─── Fixtures ────────────────────────────────────────────────────────────────── - -const validAddress = { - street: '123 Main St', - city: 'Accra', - state: 'Greater Accra', - postalCode: '00233', - country: 'Ghana', -}; +}); -const validSender = { - name: 'Alice Mensah', - email: 'alice@example.com', - phone: '+233201234567', - stellarAddress: 'GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV3SFZS522K', - address: validAddress, -}; +beforeEach(async () => { + await Delivery.deleteMany({}); +}); -const validRecipient = { - name: 'Bob Asante', - email: 'bob@example.com', - phone: '+233207654321', - stellarAddress: 'GBVVNBZGZILHXKUQ7YSVUV7TVNXW3PFOSC7YWPXNZL7CZMHP5BSXQM', - address: { ...validAddress, street: '456 Harbor Rd', city: 'Tema' }, -}; +describe('Delivery API — POST /api/v1/deliveries', () => { + it('should create a new delivery', async () => { + const res = await request(app).post('/api/v1/deliveries').send(mockDeliveryInput); -const validPackageDetails = { - weight: 2.5, - dimensions: { length: 30, width: 20, height: 15 }, - description: 'Electronic components', - fragile: true, -}; + expect(res.status).toBe(201); + expect(res.body.status).toBe('success'); + expect(res.body.data.trackingNumber).toBe('SWIFT-001'); + expect(res.body.data.isDeleted).toBe(false); + expect(res.body.data).not.toHaveProperty('__v'); + }); -const validEscrow = { - amount: 100, - stellarAsset: 'XLM', -}; + it('should reject duplicate tracking numbers', async () => { + await request(app).post('/api/v1/deliveries').send(mockDeliveryInput); + const res = await request(app).post('/api/v1/deliveries').send(mockDeliveryInput); -const validPayload = { - sender: validSender, - recipient: validRecipient, - packageDetails: validPackageDetails, - escrow: validEscrow, - estimatedDeliveryDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), - notes: 'Handle with care.', -}; - -// ─── Tests ───────────────────────────────────────────────────────────────────── + expect(res.status).toBe(409); + expect(res.body.status).toBe('error'); + }); -describe('POST /api/v1/deliveries', () => { - // ── Happy path ─────────────────────────────────────────────────────────────── + it('should reject invalid input (missing required fields)', async () => { + const res = await request(app).post('/api/v1/deliveries').send({}); - describe('201 – successful creation', () => { - it('returns 201 with status "success" and the created delivery document', async () => { - const res = await request(app).post('/api/v1/deliveries').send(validPayload); + expect(res.status).toBe(500); + expect(res.body.status).toBe('error'); + }); +}); - expect(res.status).toBe(201); - expect(res.body.status).toBe('success'); - expect(res.body.message).toBe('Delivery created successfully'); - expect(res.body.data).toBeDefined(); - expect(res.body.data.delivery).toBeDefined(); +describe('Delivery API — GET /api/v1/deliveries', () => { + it('should list deliveries excluding soft-deleted', async () => { + await Delivery.create(mockDeliveryInput); + await Delivery.create({ + ...mockDeliveryInput, + trackingNumber: 'SWIFT-002', }); - it('returns the document with a generated _id and id string', async () => { - const res = await request(app).post('/api/v1/deliveries').send(validPayload); + const res = await request(app).get('/api/v1/deliveries'); - const { delivery } = res.body.data; - expect(delivery._id).toBeDefined(); - expect(delivery.id).toBeDefined(); - expect(typeof delivery.id).toBe('string'); - }); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + }); - it('sets status to "pending" by default', async () => { - const res = await request(app).post('/api/v1/deliveries').send(validPayload); + it('should paginate results', async () => { + for (let i = 0; i < 5; i++) { + await Delivery.create({ + ...mockDeliveryInput, + trackingNumber: `SWIFT-00${i + 1}`, + }); + } - expect(res.body.data.delivery.status).toBe('pending'); - }); + const res = await request(app).get('/api/v1/deliveries?page=1&limit=2'); - it('generates a tracking number prefixed with "SWC-"', async () => { - const res = await request(app).post('/api/v1/deliveries').send(validPayload); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + expect(res.body.meta.total).toBe(5); + expect(res.body.meta.totalPages).toBe(3); + }); - expect(res.body.data.delivery.trackingNumber).toMatch(/^SWC-/); + it('should filter by status', async () => { + await Delivery.create(mockDeliveryInput); + await Delivery.create({ + ...mockDeliveryInput, + trackingNumber: 'SWIFT-002', + status: 'Assigned', }); - it('persists sender and recipient details correctly', async () => { - const res = await request(app).post('/api/v1/deliveries').send(validPayload); + const res = await request(app).get('/api/v1/deliveries?status=Pending'); - const { delivery } = res.body.data; - expect(delivery.sender.name).toBe(validSender.name); - expect(delivery.sender.email).toBe(validSender.email.toLowerCase()); - expect(delivery.recipient.name).toBe(validRecipient.name); - }); - - it('persists packageDetails including dimensions', async () => { - const res = await request(app).post('/api/v1/deliveries').send(validPayload); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].status).toBe('Pending'); + }); - const { packageDetails } = res.body.data.delivery; - expect(packageDetails.weight).toBe(validPackageDetails.weight); - expect(packageDetails.fragile).toBe(true); - expect(packageDetails.dimensions.length).toBe(30); + it('should search by tracking number', async () => { + await Delivery.create(mockDeliveryInput); + await Delivery.create({ + ...mockDeliveryInput, + trackingNumber: 'OTHER-001', }); - it('persists escrow amount and defaults stellarAsset to XLM when omitted', async () => { - const payload = { - ...validPayload, - escrow: { amount: 50 }, // no stellarAsset - }; - const res = await request(app).post('/api/v1/deliveries').send(payload); + const res = await request(app).get('/api/v1/deliveries?search=SWIFT'); - expect(res.body.data.delivery.escrow.amount).toBe(50); - expect(res.body.data.delivery.escrow.stellarAsset).toBe('XLM'); - }); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + }); +}); - it('stores estimatedDeliveryDate when provided', async () => { - const res = await request(app).post('/api/v1/deliveries').send(validPayload); +describe('Delivery API — GET /api/v1/deliveries/:id', () => { + it('should retrieve a delivery by ID', async () => { + const created = await Delivery.create(mockDeliveryInput); + const res = await request(app).get(`/api/v1/deliveries/${created._id}`); - expect(res.body.data.delivery.estimatedDeliveryDate).toBeDefined(); - }); + expect(res.status).toBe(200); + expect(res.body.data.trackingNumber).toBe('SWIFT-001'); + }); - it('includes createdAt and updatedAt timestamps', async () => { - const res = await request(app).post('/api/v1/deliveries').send(validPayload); + it('should return 404 for non-existent delivery', async () => { + const fakeId = new mongoose.Types.ObjectId().toHexString(); + const res = await request(app).get(`/api/v1/deliveries/${fakeId}`); - const { delivery } = res.body.data; - expect(delivery.createdAt).toBeDefined(); - expect(delivery.updatedAt).toBeDefined(); - }); + expect(res.status).toBe(404); + }); - it('generates unique tracking numbers for two concurrent deliveries', async () => { - const [res1, res2] = await Promise.all([ - request(app).post('/api/v1/deliveries').send(validPayload), - request(app).post('/api/v1/deliveries').send(validPayload), - ]); - - expect(res1.status).toBe(201); - expect(res2.status).toBe(201); - expect(res1.body.data.delivery.trackingNumber).not.toBe( - res2.body.data.delivery.trackingNumber, - ); - }); + it('should return 400 for invalid ID format', async () => { + const res = await request(app).get('/api/v1/deliveries/invalid-id'); + + expect(res.status).toBe(400); }); - // ── Validation errors (400) ────────────────────────────────────────────────── + it('should not return soft-deleted deliveries by ID', async () => { + const created = await Delivery.create(mockDeliveryInput); + await created.softDelete(); - describe('400 – validation errors', () => { - it('returns 400 when body is empty', async () => { - const res = await request(app).post('/api/v1/deliveries').send({}); + const res = await request(app).get(`/api/v1/deliveries/${created._id}`); - expect(res.status).toBe(400); - expect(res.body.status).toBe('error'); - }); + expect(res.status).toBe(404); + }); +}); - it('returns 400 when sender is missing', async () => { - const { sender: _s, ...payload } = validPayload; - const res = await request(app).post('/api/v1/deliveries').send(payload); +describe('Delivery API — PATCH /api/v1/deliveries/:id', () => { + it('should update a delivery', async () => { + const created = await Delivery.create(mockDeliveryInput); + const res = await request(app) + .patch(`/api/v1/deliveries/${created._id}`) + .send({ notes: 'Updated notes', status: 'Assigned' }); - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/sender/i); - }); + expect(res.status).toBe(200); + expect(res.body.data.notes).toBe('Updated notes'); + expect(res.body.data.status).toBe('Assigned'); + }); +}); - it('returns 400 when recipient is missing', async () => { - const { recipient: _r, ...payload } = validPayload; - const res = await request(app).post('/api/v1/deliveries').send(payload); +describe('Delivery API — PATCH /api/v1/deliveries/:id/archive', () => { + it('should archive (soft-delete) a delivery', async () => { + const created = await Delivery.create(mockDeliveryInput); + const res = await request(app).patch(`/api/v1/deliveries/${created._id}/archive`); - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/recipient/i); - }); + expect(res.status).toBe(200); + expect(res.body.data.isDeleted).toBe(true); + expect(res.body.data.deletedAt).toBeTruthy(); + expect(res.body.message).toBe('Delivery archived successfully'); + }); - it('returns 400 when packageDetails is missing', async () => { - const { packageDetails: _p, ...payload } = validPayload; - const res = await request(app).post('/api/v1/deliveries').send(payload); + it('should return 409 if already archived', async () => { + const created = await Delivery.create(mockDeliveryInput); + await created.softDelete(); - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/packageDetails/i); - }); + const res = await request(app).patch(`/api/v1/deliveries/${created._id}/archive`); - it('returns 400 when escrow is missing', async () => { - const { escrow: _e, ...payload } = validPayload; - const res = await request(app).post('/api/v1/deliveries').send(payload); + expect(res.status).toBe(409); + }); - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/escrow/i); + it('should exclude archived deliveries from list', async () => { + await Delivery.create(mockDeliveryInput); + const d2 = await Delivery.create({ + ...mockDeliveryInput, + trackingNumber: 'SWIFT-002', }); + await d2.softDelete(); - it('returns 400 when sender.email is missing', async () => { - const payload = { - ...validPayload, - sender: { ...validSender, email: undefined }, - }; - const res = await request(app).post('/api/v1/deliveries').send(payload); + const res = await request(app).get('/api/v1/deliveries'); - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/sender\.email/i); - }); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].trackingNumber).toBe('SWIFT-001'); + }); +}); - it('returns 400 when sender.stellarAddress is missing', async () => { - const payload = { - ...validPayload, - sender: { ...validSender, stellarAddress: undefined }, - }; - const res = await request(app).post('/api/v1/deliveries').send(payload); +describe('Delivery API — PATCH /api/v1/deliveries/:id/restore', () => { + it('should restore an archived delivery', async () => { + const created = await Delivery.create(mockDeliveryInput); + await created.softDelete(); - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/sender\.stellarAddress/i); - }); + const res = await request(app).patch(`/api/v1/deliveries/${created._id}/restore`); - it('returns 400 when packageDetails.weight is a negative number', async () => { - const payload = { - ...validPayload, - packageDetails: { ...validPackageDetails, weight: -1 }, - }; - const res = await request(app).post('/api/v1/deliveries').send(payload); + expect(res.status).toBe(200); + expect(res.body.data.isDeleted).toBe(false); + expect(res.body.data.deletedAt).toBeNull(); + expect(res.body.message).toBe('Delivery restored successfully'); + }); - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/weight/i); - }); + it('should return 409 if delivery is not archived', async () => { + const created = await Delivery.create(mockDeliveryInput); + const res = await request(app).patch(`/api/v1/deliveries/${created._id}/restore`); - it('returns 400 when packageDetails.fragile is not a boolean', async () => { - const payload = { - ...validPayload, - packageDetails: { ...validPackageDetails, fragile: 'yes' }, - }; - const res = await request(app).post('/api/v1/deliveries').send(payload); + expect(res.status).toBe(409); + }); - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/fragile/i); - }); + it('restored delivery should appear in list', async () => { + const created = await Delivery.create(mockDeliveryInput); + await created.softDelete(); + await created.restore(); - it('returns 400 when escrow.amount is negative', async () => { - const payload = { ...validPayload, escrow: { amount: -10 } }; - const res = await request(app).post('/api/v1/deliveries').send(payload); + const res = await request(app).get('/api/v1/deliveries'); - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/escrow\.amount/i); - }); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); }); +}); - // ── Business rule errors (422) ─────────────────────────────────────────────── +describe('Delivery API — GET /api/v1/deliveries/archived', () => { + it('should list archived deliveries', async () => { + const created = await Delivery.create(mockDeliveryInput); + await created.softDelete(); - describe('422 – business rule violations', () => { - it('returns 422 when sender and recipient share the same Stellar address', async () => { - const sharedAddress = validSender.stellarAddress; - const payload = { - ...validPayload, - recipient: { ...validRecipient, stellarAddress: sharedAddress }, - }; - const res = await request(app).post('/api/v1/deliveries').send(payload); + const res = await request(app).get('/api/v1/deliveries/archived'); - expect(res.status).toBe(422); - expect(res.body.message).toMatch(/stellar address/i); - }); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].isDeleted).toBe(true); + }); - it('returns 422 when estimatedDeliveryDate is in the past', async () => { - const payload = { - ...validPayload, - estimatedDeliveryDate: new Date(Date.now() - 86400000).toISOString(), - }; - const res = await request(app).post('/api/v1/deliveries').send(payload); + it('should return empty list when no archived deliveries', async () => { + await Delivery.create(mockDeliveryInput); - expect(res.status).toBe(422); - expect(res.body.message).toMatch(/future date/i); - }); + const res = await request(app).get('/api/v1/deliveries/archived'); - it('returns 400 when estimatedDeliveryDate is not a valid date string', async () => { - const payload = { ...validPayload, estimatedDeliveryDate: 'not-a-date' }; - const res = await request(app).post('/api/v1/deliveries').send(payload); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(0); + }); - expect(res.status).toBe(400); - expect(res.body.message).toMatch(/ISO 8601/i); + it('should not include non-archived deliveries', async () => { + await Delivery.create(mockDeliveryInput); + const d2 = await Delivery.create({ + ...mockDeliveryInput, + trackingNumber: 'SWIFT-002', }); - }); + await d2.softDelete(); - // ── Wrong method / not found ───────────────────────────────────────────────── + const res = await request(app).get('/api/v1/deliveries/archived'); - describe('routing', () => { - it('returns 404 for GET /api/v1/deliveries (route not defined)', async () => { - const res = await request(app).get('/api/v1/deliveries'); - expect(res.status).toBe(404); - }); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); }); });