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 new file mode 100644 index 0000000..c432a32 --- /dev/null +++ b/src/models/Delivery.ts @@ -0,0 +1,210 @@ +import mongoose, { Document, Schema, Model, Query } from 'mongoose'; +import logger from '../config/logger'; + +export enum DeliveryStatus { + PENDING = 'Pending', + ASSIGNED = 'Assigned', + PICKED_UP = 'Picked Up', + IN_TRANSIT = 'In Transit', + DELIVERED = 'Delivered', + CANCELLED = 'Cancelled', +} + +export enum DeliverySize { + SMALL = 'Small', + MEDIUM = 'Medium', + LARGE = 'Large', + EXTRA_LARGE = 'Extra Large', +} + +export interface ILocation { + address: string; + city: string; + state: string; + zipCode: string; + lat?: number; + lng?: number; + instructions?: string; +} + +export interface IPackage { + description: string; + weight: number; + size: DeliverySize; + itemValue?: number; + isFragile: boolean; + requiresSignature: boolean; +} + +export interface IDelivery extends Document { + trackingNumber: string; + customer: { + name: string; + phone: string; + email?: string; + }; + 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; +} + +export interface IDeliveryModel extends Model { + findAvailable(): Promise; +} + +const locationSchema = new Schema( + { + address: { type: String, required: true, trim: true }, + city: { type: String, required: true, trim: true }, + state: { 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 packageSchema = new Schema( + { + description: { type: String, required: true, trim: true }, + weight: { type: Number, required: true, min: 0 }, + 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 }, +); + +const deliverySchema = new Schema( + { + trackingNumber: { + type: String, + required: true, + unique: 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), + default: DeliveryStatus.PENDING, + index: true, + }, + driver: { + type: Schema.Types.ObjectId, + ref: 'User', + index: 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', + }, + }, + { + timestamps: true, + toJSON: { + transform(_doc: unknown, ret: Record): void { + delete ret.__v; + }, + }, + }, +); + +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 68e0f11..08d6870 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,9 +1,8 @@ import { Router } from 'express'; +import deliveryRoutes from './delivery.routes'; 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/server.ts b/src/server.ts index 4f34316..6e3796d 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'; 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 new file mode 100644 index 0000000..5190bab --- /dev/null +++ b/src/utils/AppError.ts @@ -0,0 +1,11 @@ +export class AppError extends Error { + public readonly statusCode: number; + + constructor(message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + this.name = 'AppError'; + + Object.setPrototypeOf(this, AppError.prototype); + } +} diff --git a/tests/delivery.test.ts b/tests/delivery.test.ts new file mode 100644 index 0000000..bbe2865 --- /dev/null +++ b/tests/delivery.test.ts @@ -0,0 +1,299 @@ +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'; + +jest.mock('../src/config/database', () => ({ + connectDatabase: jest.fn(), +})); + +jest.mock('../src/config/logger', () => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), +})); + +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; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const uri = mongoServer.getUri(); + await mongoose.connect(uri); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await Delivery.deleteMany({}); +}); + +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); + + 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'); + }); + + 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); + + expect(res.status).toBe(409); + expect(res.body.status).toBe('error'); + }); + + it('should reject invalid input (missing required fields)', async () => { + const res = await request(app).post('/api/v1/deliveries').send({}); + + expect(res.status).toBe(500); + expect(res.body.status).toBe('error'); + }); +}); + +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', + }); + + const res = await request(app).get('/api/v1/deliveries'); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + }); + + it('should paginate results', async () => { + for (let i = 0; i < 5; i++) { + await Delivery.create({ + ...mockDeliveryInput, + trackingNumber: `SWIFT-00${i + 1}`, + }); + } + + const res = await request(app).get('/api/v1/deliveries?page=1&limit=2'); + + 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); + }); + + it('should filter by status', async () => { + await Delivery.create(mockDeliveryInput); + await Delivery.create({ + ...mockDeliveryInput, + trackingNumber: 'SWIFT-002', + status: 'Assigned', + }); + + const res = await request(app).get('/api/v1/deliveries?status=Pending'); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].status).toBe('Pending'); + }); + + it('should search by tracking number', async () => { + await Delivery.create(mockDeliveryInput); + await Delivery.create({ + ...mockDeliveryInput, + trackingNumber: 'OTHER-001', + }); + + const res = await request(app).get('/api/v1/deliveries?search=SWIFT'); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + }); +}); + +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.status).toBe(200); + expect(res.body.data.trackingNumber).toBe('SWIFT-001'); + }); + + 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}`); + + expect(res.status).toBe(404); + }); + + 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); + }); + + it('should not return soft-deleted deliveries by ID', async () => { + const created = await Delivery.create(mockDeliveryInput); + await created.softDelete(); + + const res = await request(app).get(`/api/v1/deliveries/${created._id}`); + + expect(res.status).toBe(404); + }); +}); + +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(200); + expect(res.body.data.notes).toBe('Updated notes'); + expect(res.body.data.status).toBe('Assigned'); + }); +}); + +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(200); + expect(res.body.data.isDeleted).toBe(true); + expect(res.body.data.deletedAt).toBeTruthy(); + expect(res.body.message).toBe('Delivery archived successfully'); + }); + + it('should return 409 if already archived', async () => { + const created = await Delivery.create(mockDeliveryInput); + await created.softDelete(); + + const res = await request(app).patch(`/api/v1/deliveries/${created._id}/archive`); + + expect(res.status).toBe(409); + }); + + it('should exclude archived deliveries from list', async () => { + await Delivery.create(mockDeliveryInput); + const d2 = await Delivery.create({ + ...mockDeliveryInput, + trackingNumber: 'SWIFT-002', + }); + await d2.softDelete(); + + const res = await request(app).get('/api/v1/deliveries'); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].trackingNumber).toBe('SWIFT-001'); + }); +}); + +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(); + + const res = await request(app).patch(`/api/v1/deliveries/${created._id}/restore`); + + 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'); + }); + + 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`); + + expect(res.status).toBe(409); + }); + + it('restored delivery should appear in list', async () => { + const created = await Delivery.create(mockDeliveryInput); + await created.softDelete(); + await created.restore(); + + const res = await request(app).get('/api/v1/deliveries'); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + }); +}); + +describe('Delivery API — GET /api/v1/deliveries/archived', () => { + it('should list archived deliveries', async () => { + const created = await Delivery.create(mockDeliveryInput); + await created.softDelete(); + + const res = await request(app).get('/api/v1/deliveries/archived'); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].isDeleted).toBe(true); + }); + + it('should return empty list when no archived deliveries', async () => { + await Delivery.create(mockDeliveryInput); + + const res = await request(app).get('/api/v1/deliveries/archived'); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(0); + }); + + it('should not include non-archived deliveries', async () => { + await Delivery.create(mockDeliveryInput); + const d2 = await Delivery.create({ + ...mockDeliveryInput, + trackingNumber: 'SWIFT-002', + }); + await d2.softDelete(); + + const res = await request(app).get('/api/v1/deliveries/archived'); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + }); +});