diff --git a/jest.config.js b/jest.config.js index 7ea56d0..a5a30af 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,4 +8,12 @@ module.exports = { transform: { ...tsJestTransformCfg, }, + // Set mongodb-memory-server env vars before any test file is loaded. + // setupFiles runs inside each worker process, so env vars are visible to MMS. + // This pins the binary to MongoDB 7.0 / ubuntu2204 to avoid glibc + // compatibility issues with the default 6.0.9 build on this machine. + setupFiles: ['./jest.setup.js'], + // Individual test timeout — generous enough for the in-memory MongoDB to + // start on first run (binary download already done after that). + testTimeout: 30_000, }; diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..4baedc9 --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,4 @@ +// Tell mongodb-memory-server to use MongoDB 7.0.14. +// The 6.0.9 binary downloaded previously was corrupt (truncated download), +// causing a SIGSEGV on startup. 7.0.14 is verified to work on this machine. +process.env.MONGOMS_VERSION = '7.0.14'; diff --git a/package.json b/package.json index eeeb8c4..889c4c8 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "format": "prettier --write .", "test": "jest" }, + "mongodbMemoryServer": { + "version": "7.0.14" + }, "dependencies": { "bcryptjs": "2.4.3", "compression": "1.7.4", diff --git a/src/controllers/adminController.ts b/src/controllers/adminController.ts new file mode 100644 index 0000000..bb67c05 --- /dev/null +++ b/src/controllers/adminController.ts @@ -0,0 +1,75 @@ +import { Request, Response, NextFunction } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { suspendUser as suspendUserService } from '../services/adminService'; +import AppError from '../utils/AppError'; + +// ─── Request body type ───────────────────────────────────────────────────────── + +interface SuspendUserBody { + reason?: unknown; + ban?: unknown; +} + +// ─── Controller ──────────────────────────────────────────────────────────────── + +/** + * PUT /api/v1/admin/users/:id/suspend + * + * Suspends (or permanently bans) a user or driver account. + * The route is protected by `authenticate` + `requireRole(UserRole.ADMIN)`. + * + * Body: + * - reason {string} Required — audit trail description. + * - ban {boolean} Optional — true applies a permanent ban instead of suspension. + * + * Responds: + * 200 — success, returns the updated user document. + */ +export const suspendUser = async ( + req: Request<{ id: string }, unknown, SuspendUserBody>, + res: Response, + next: NextFunction, +): Promise => { + try { + // req.user is guaranteed by the authenticate middleware + if (!req.user) { + throw new AppError( + 'Authentication required.', + StatusCodes.UNAUTHORIZED, + ); + } + + const { id: targetUserId } = req.params; + const { reason, ban } = req.body; + + // Input validation + if (!reason || typeof reason !== 'string' || reason.trim().length === 0) { + throw new AppError( + 'A reason is required to suspend or ban a user.', + StatusCodes.BAD_REQUEST, + ); + } + + if (ban !== undefined && typeof ban !== 'boolean') { + throw new AppError( + '"ban" must be a boolean value.', + StatusCodes.BAD_REQUEST, + ); + } + + const { user, action } = await suspendUserService({ + targetUserId, + adminId: req.user._id.toString(), + reason: reason.trim(), + ban: ban === true, + }); + + res.status(StatusCodes.OK).json({ + status: 'success', + message: `User has been ${action} successfully.`, + data: { user }, + }); + } catch (error) { + next(error); + } +}; diff --git a/src/controllers/deliveryController.ts b/src/controllers/deliveryController.ts new file mode 100644 index 0000000..fcd3ef1 --- /dev/null +++ b/src/controllers/deliveryController.ts @@ -0,0 +1,144 @@ +import { Request, Response, NextFunction } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import * as deliveryService from '../services/deliveryService'; +import { CreateDeliveryInput } from '../services/deliveryService'; +import AppError from '../utils/AppError'; + +// ─── Request body type ───────────────────────────────────────────────────────── + +type CreateDeliveryBody = CreateDeliveryInput; + +// ─── Validation helpers ──────────────────────────────────────────────────────── + +/** + * Returns a list of missing required field paths for an address object. + */ +const validateAddress = (address: unknown, prefix: string): string[] => { + const errors: string[] = []; + const required = ['street', 'city', 'state', 'postalCode', 'country'] as const; + + if (typeof address !== 'object' || address === null) { + return [`${prefix} is required and must be an object`]; + } + + const addr = address as Record; + for (const field of required) { + if (!addr[field] || typeof addr[field] !== 'string') { + errors.push(`${prefix}.${field} is required`); + } + } + return errors; +}; + +/** + * Returns a list of missing / invalid fields for a sender or recipient object. + */ +const validateParty = (party: unknown, role: 'sender' | 'recipient'): string[] => { + const errors: string[] = []; + + if (typeof party !== 'object' || party === null) { + return [`${role} is required and must be an object`]; + } + + const p = party as Record; + + if (!p.name || typeof p.name !== 'string') errors.push(`${role}.name is required`); + if (!p.email || typeof p.email !== 'string') errors.push(`${role}.email is required`); + if (!p.phone || typeof p.phone !== 'string') errors.push(`${role}.phone is required`); + if (!p.stellarAddress || typeof p.stellarAddress !== 'string') { + errors.push(`${role}.stellarAddress is required`); + } + + errors.push(...validateAddress(p.address, `${role}.address`)); + + return errors; +}; + +/** + * Validates the top-level request body and returns an array of error messages. + * An empty array means the body is valid. + */ +const validateCreateDeliveryBody = (body: Partial): string[] => { + const errors: string[] = []; + + errors.push(...validateParty(body.sender, 'sender')); + errors.push(...validateParty(body.recipient, 'recipient')); + + // packageDetails + if (typeof body.packageDetails !== 'object' || body.packageDetails === null) { + errors.push('packageDetails is required and must be an object'); + } else { + // Cast through unknown to allow dynamic key access on the typed sub-object + const pkg = body.packageDetails as unknown as Record; + if (typeof pkg['weight'] !== 'number' || (pkg['weight'] as number) < 0) { + errors.push('packageDetails.weight must be a non-negative number'); + } + if (!pkg['description'] || typeof pkg['description'] !== 'string') { + errors.push('packageDetails.description is required'); + } + if (typeof pkg['fragile'] !== 'boolean') { + errors.push('packageDetails.fragile must be a boolean'); + } + if (pkg['dimensions'] !== undefined) { + const dims = pkg['dimensions'] as Record; + for (const dim of ['length', 'width', 'height']) { + if (typeof dims[dim] !== 'number' || (dims[dim] as number) < 0) { + errors.push(`packageDetails.dimensions.${dim} must be a non-negative number`); + } + } + } + } + + // escrow + if (typeof body.escrow !== 'object' || body.escrow === null) { + errors.push('escrow is required and must be an object'); + } else { + // Cast through unknown to allow dynamic key access on the typed sub-object + const escrow = body.escrow as unknown as Record; + if (typeof escrow['amount'] !== 'number' || (escrow['amount'] as number) < 0) { + errors.push('escrow.amount must be a non-negative number'); + } + if (escrow['stellarAsset'] !== undefined && typeof escrow['stellarAsset'] !== 'string') { + errors.push('escrow.stellarAsset must be a string'); + } + } + + return errors; +}; + +// ─── Controller ──────────────────────────────────────────────────────────────── + +/** + * POST /api/v1/deliveries + * + * Creates a new delivery record (off-chain metadata only). + * The on-chain Soroban contract interaction happens in a subsequent step. + */ +export const createDelivery = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const validationErrors = validateCreateDeliveryBody(req.body); + + if (validationErrors.length > 0) { + throw new AppError( + `Validation failed: ${validationErrors.join('; ')}`, + StatusCodes.BAD_REQUEST, + ); + } + + const delivery = await deliveryService.createDelivery(req.body); + + res.status(StatusCodes.CREATED).json({ + status: 'success', + message: 'Delivery created successfully', + data: { + delivery, + }, + }); + } catch (error) { + next(error); + } +}; diff --git a/src/middleware/authenticate.ts b/src/middleware/authenticate.ts new file mode 100644 index 0000000..89f174c --- /dev/null +++ b/src/middleware/authenticate.ts @@ -0,0 +1,95 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { StatusCodes } from 'http-status-codes'; +import User, { IUser } from '../models/User'; +import AppError from '../utils/AppError'; + +// ─── Augment Express Request ─────────────────────────────────────────────────── + +declare global { + namespace Express { + interface Request { + /** Populated by the `authenticate` middleware after token verification. */ + user?: IUser; + } + } +} + +// ─── JWT payload shape ──────────────────────────────────────────────────────── + +interface JwtPayload { + id: string; + iat?: number; + exp?: number; +} + +// ─── Middleware ─────────────────────────────────────────────────────────────── + +/** + * Verifies the `Authorization: Bearer ` header, loads the matching + * User document from the database, and attaches it to `req.user`. + * + * Throws 401 if the token is missing, malformed, expired, or references a + * user that no longer exists. + * Throws 403 if the user account has been suspended or banned. + */ +const authenticate = async ( + req: Request, + _res: Response, + next: NextFunction, +): Promise => { + try { + // 1. Extract token from Authorization header + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new AppError( + 'Authentication required. Please provide a valid Bearer token.', + StatusCodes.UNAUTHORIZED, + ); + } + + const token = authHeader.split(' ')[1]; + + // 2. Verify and decode the JWT + const secret = process.env.JWT_SECRET; + if (!secret) { + throw new AppError('Server misconfiguration: JWT secret not set.', StatusCodes.INTERNAL_SERVER_ERROR); + } + + let decoded: JwtPayload; + try { + decoded = jwt.verify(token, secret) as JwtPayload; + } catch { + throw new AppError( + 'Invalid or expired token. Please log in again.', + StatusCodes.UNAUTHORIZED, + ); + } + + // 3. Load the user from DB (re-validates they still exist) + const user = await User.findById(decoded.id).select('+password'); + if (!user) { + throw new AppError( + 'The user associated with this token no longer exists.', + StatusCodes.UNAUTHORIZED, + ); + } + + // 4. Block suspended or banned accounts + if (user.status === 'suspended' || user.status === 'banned') { + throw new AppError( + `Your account has been ${user.status}. Please contact support.`, + StatusCodes.FORBIDDEN, + ); + } + + // 5. Attach user to request for downstream middleware/controllers + req.user = user; + next(); + } catch (error) { + next(error); + } +}; + +export default authenticate; diff --git a/src/middleware/requireRole.ts b/src/middleware/requireRole.ts new file mode 100644 index 0000000..4aa1f8e --- /dev/null +++ b/src/middleware/requireRole.ts @@ -0,0 +1,42 @@ +import { Request, Response, NextFunction } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { UserRole } from '../models/User'; +import AppError from '../utils/AppError'; + +/** + * Role-based access control middleware factory. + * + * Returns a middleware that allows the request to proceed only when + * `req.user.role` is included in the provided `allowedRoles` list. + * + * Must be used **after** the `authenticate` middleware so `req.user` is set. + * + * @example + * router.put('/users/:id/suspend', authenticate, requireRole(UserRole.ADMIN), suspendUser); + */ +const requireRole = (...allowedRoles: UserRole[]) => { + return (req: Request, _res: Response, next: NextFunction): void => { + if (!req.user) { + // Defensive: authenticate should always run first + return next( + new AppError( + 'Authentication required. Please provide a valid Bearer token.', + StatusCodes.UNAUTHORIZED, + ), + ); + } + + if (!allowedRoles.includes(req.user.role as UserRole)) { + return next( + new AppError( + 'Access denied. You do not have permission to perform this action.', + StatusCodes.FORBIDDEN, + ), + ); + } + + next(); + }; +}; + +export default requireRole; diff --git a/src/models/Delivery.ts b/src/models/Delivery.ts new file mode 100644 index 0000000..e7d6b4d --- /dev/null +++ b/src/models/Delivery.ts @@ -0,0 +1,187 @@ +import { Schema, model, Document, Types } from 'mongoose'; + +// ─── Enums ──────────────────────────────────────────────────────────────────── + +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 +} + +// ─── Sub-document interfaces ─────────────────────────────────────────────────── + +export interface IAddress { + street: string; + city: string; + state: string; + postalCode: string; + country: string; +} + +export interface IPackageDetails { + weight: number; // kg + dimensions?: { + length: number; // cm + width: number; // cm + height: number; // cm + }; + description: string; + fragile: 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: { + name: string; + email: string; + phone: string; + stellarAddress: string; + address: IAddress; + }; + + recipient: { + name: string; + email: string; + phone: string; + stellarAddress: string; + address: IAddress; + }; + + packageDetails: IPackageDetails; + escrow: IEscrow; + + estimatedDeliveryDate?: Date; + actualDeliveryDate?: Date; + notes?: string; + + createdAt: Date; + updatedAt: Date; +} + +// ─── Sub-document schemas ────────────────────────────────────────────────────── + +const AddressSchema = new Schema( + { + street: { 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 }, + }, + { _id: false }, +); + +const PackageDetailsSchema = new Schema( + { + 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 }, + }, + { _id: false }, +); + +// ─── Main schema ─────────────────────────────────────────────────────────────── + +const DeliverySchema = new Schema( + { + trackingNumber: { + type: String, + required: true, + unique: true, + uppercase: true, + trim: true, + index: 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 }, + }, + + 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 }, + }, + + 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 + toJSON: { + virtuals: true, + transform: (_doc, ret) => { + // Expose id as a plain string instead of keeping only _id + ret.id = ret._id.toString(); + delete ret.__v; + return ret; + }, + }, + }, +); + +// ─── Model ───────────────────────────────────────────────────────────────────── + +const Delivery = model('Delivery', DeliverySchema); + +export default Delivery; diff --git a/src/models/User.ts b/src/models/User.ts new file mode 100644 index 0000000..7a75936 --- /dev/null +++ b/src/models/User.ts @@ -0,0 +1,129 @@ +import { Schema, model, Document, Types } from 'mongoose'; +import bcrypt from 'bcryptjs'; + +// ─── Enums ──────────────────────────────────────────────────────────────────── + +export enum UserRole { + ADMIN = 'admin', + USER = 'user', + DRIVER = 'driver', +} + +export enum UserStatus { + ACTIVE = 'active', + SUSPENDED = 'suspended', + BANNED = 'banned', +} + +// ─── Document interface ──────────────────────────────────────────────────────── + +export interface IUser extends Document { + _id: Types.ObjectId; + name: string; + email: string; + password: string; + role: UserRole; + status: UserStatus; + stellarAddress?: string; + suspendedAt?: Date; + suspendedReason?: string; + createdAt: Date; + updatedAt: Date; + + /** Returns true when the plain-text password matches the stored hash. */ + comparePassword(candidate: string): Promise; +} + +// ─── Schema ─────────────────────────────────────────────────────────────────── + +const UserSchema = new Schema( + { + name: { + type: String, + required: true, + trim: true, + maxlength: 100, + }, + + email: { + type: String, + required: true, + unique: true, + trim: true, + lowercase: true, + match: [/^\S+@\S+\.\S+$/, 'Please provide a valid email address'], + index: true, + }, + + password: { + type: String, + required: true, + minlength: 8, + // Never return the password hash in query results by default + select: false, + }, + + role: { + type: String, + enum: Object.values(UserRole), + required: true, + default: UserRole.USER, + index: true, + }, + + status: { + type: String, + enum: Object.values(UserStatus), + required: true, + default: UserStatus.ACTIVE, + index: true, + }, + + stellarAddress: { + type: String, + trim: true, + }, + + // Audit fields set when an admin suspends/bans the account + suspendedAt: { type: Date }, + suspendedReason: { type: String, trim: true, maxlength: 500 }, + }, + { + timestamps: true, + toJSON: { + virtuals: true, + transform: (_doc, ret) => { + ret.id = ret._id.toString(); + // Never leak the password hash over the wire + delete ret.password; + delete ret.__v; + return ret; + }, + }, + }, +); + +// ─── Hooks ──────────────────────────────────────────────────────────────────── + +/** Hash password before save when it has been modified. */ +UserSchema.pre('save', async function (next) { + if (!this.isModified('password')) return next(); + + const rounds = parseInt(process.env.BCRYPT_ROUNDS ?? '10', 10); + this.password = await bcrypt.hash(this.password, rounds); + next(); +}); + +// ─── Instance methods ────────────────────────────────────────────────────────── + +UserSchema.methods.comparePassword = async function ( + candidate: string, +): Promise { + return bcrypt.compare(candidate, this.password as string); +}; + +// ─── Model ──────────────────────────────────────────────────────────────────── + +const User = model('User', UserSchema); + +export default User; diff --git a/src/routes/adminRoutes.ts b/src/routes/adminRoutes.ts new file mode 100644 index 0000000..1b0956d --- /dev/null +++ b/src/routes/adminRoutes.ts @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import authenticate from '../middleware/authenticate'; +import requireRole from '../middleware/requireRole'; +import { suspendUser } from '../controllers/adminController'; +import { UserRole } from '../models/User'; + +const router = Router(); + +// All admin routes require a valid JWT AND the admin role +router.use(authenticate); +router.use(requireRole(UserRole.ADMIN)); + +/** + * @route PUT /api/v1/admin/users/:id/suspend + * @desc Suspend or ban a user / driver account + * @access Admin only + */ +router.put('/users/:id/suspend', suspendUser); + +export default router; diff --git a/src/routes/deliveryRoutes.ts b/src/routes/deliveryRoutes.ts new file mode 100644 index 0000000..b2d8199 --- /dev/null +++ b/src/routes/deliveryRoutes.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { createDelivery } from '../controllers/deliveryController'; + +const router = Router(); + +/** + * @route POST /api/v1/deliveries + * @desc Create a new delivery and store its off-chain metadata + * @access Public (authentication to be layered on in a future issue) + */ +router.post('/', createDelivery); + +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index 68e0f11..c812e87 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,9 +1,13 @@ import { Router } from 'express'; +import deliveryRoutes from './deliveryRoutes'; +import adminRoutes from './adminRoutes'; const router = Router(); // Define your routes here // router.use('/auth', authRoutes); // router.use('/users', userRoutes); +router.use('/deliveries', deliveryRoutes); +router.use('/admin', adminRoutes); export default router; diff --git a/src/services/adminService.ts b/src/services/adminService.ts new file mode 100644 index 0000000..275f901 --- /dev/null +++ b/src/services/adminService.ts @@ -0,0 +1,92 @@ +import { StatusCodes } from 'http-status-codes'; +import mongoose from 'mongoose'; +import User, { IUser, UserRole, UserStatus } from '../models/User'; +import AppError from '../utils/AppError'; +import logger from '../config/logger'; + +// ─── DTOs ────────────────────────────────────────────────────────────────────── + +export interface SuspendUserInput { + /** MongoDB ObjectId string of the target user. */ + targetUserId: string; + /** MongoDB ObjectId string of the admin performing the action. */ + adminId: string; + /** Human-readable reason required for the audit trail. */ + reason: string; + /** + * Whether to apply a full ban instead of a temporary suspension. + * Defaults to false (suspension). + */ + ban?: boolean; +} + +export interface SuspendUserResult { + user: IUser; + action: 'suspended' | 'banned'; +} + +// ─── Service ─────────────────────────────────────────────────────────────────── + +/** + * Suspends or bans a user account. + * + * Business rules enforced here: + * - Target user must exist. + * - An admin cannot suspend or ban themselves. + * - An admin cannot suspend or ban another admin (privilege escalation guard). + * - A user already in the desired status is a no-op that returns 409. + * - `reason` is mandatory for audit purposes. + */ +export const suspendUser = async (input: SuspendUserInput): Promise => { + const { targetUserId, adminId, reason, ban = false } = input; + + // 1. Validate the target ID is a valid ObjectId before hitting the DB + if (!mongoose.Types.ObjectId.isValid(targetUserId)) { + throw new AppError('Invalid user ID format.', StatusCodes.BAD_REQUEST); + } + + // 2. Self-action guard + if (targetUserId === adminId) { + throw new AppError( + 'Admins cannot suspend or ban their own account.', + StatusCodes.UNPROCESSABLE_ENTITY, + ); + } + + // 3. Load the target user + const targetUser = await User.findById(targetUserId); + if (!targetUser) { + throw new AppError('User not found.', StatusCodes.NOT_FOUND); + } + + // 4. Privilege escalation guard — admins cannot action other admins + if (targetUser.role === UserRole.ADMIN) { + throw new AppError( + 'Admin accounts cannot be suspended or banned by another admin.', + StatusCodes.FORBIDDEN, + ); + } + + const desiredStatus: UserStatus = ban ? UserStatus.BANNED : UserStatus.SUSPENDED; + + // 5. Idempotency — already in the desired state + if (targetUser.status === desiredStatus) { + throw new AppError( + `User is already ${desiredStatus}.`, + StatusCodes.CONFLICT, + ); + } + + // 6. Apply the status change with audit metadata + targetUser.status = desiredStatus; + targetUser.suspendedAt = new Date(); + targetUser.suspendedReason = reason; + + await targetUser.save(); + + logger.info( + `Admin ${adminId} ${desiredStatus} user ${targetUserId}. Reason: "${reason}"`, + ); + + return { user: targetUser, action: desiredStatus }; +}; diff --git a/src/services/deliveryService.ts b/src/services/deliveryService.ts new file mode 100644 index 0000000..bd08742 --- /dev/null +++ b/src/services/deliveryService.ts @@ -0,0 +1,129 @@ +import { StatusCodes } from 'http-status-codes'; +import Delivery, { IDelivery, DeliveryStatus } from '../models/Delivery'; +import AppError from '../utils/AppError'; +import logger from '../config/logger'; + +// ─── DTOs ────────────────────────────────────────────────────────────────────── + +export interface AddressInput { + street: string; + city: string; + state: string; + postalCode: string; + country: string; +} + +export interface PartyInput { + name: string; + email: string; + phone: string; + stellarAddress: string; + address: AddressInput; +} + +export interface PackageDetailsInput { + weight: number; + dimensions?: { + length: number; + width: number; + height: number; + }; + description: string; + fragile: boolean; +} + +export interface EscrowInput { + amount: number; + stellarAsset?: string; // defaults to 'XLM' +} + +export interface CreateDeliveryInput { + sender: PartyInput; + recipient: PartyInput; + packageDetails: PackageDetailsInput; + escrow: EscrowInput; + estimatedDeliveryDate?: string; // ISO 8601 string from the client + notes?: string; +} + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Generates a unique tracking number in the format SWC--. + * Collision probability is negligible for reasonable traffic volumes. + */ +const generateTrackingNumber = (): string => { + const ts = Date.now().toString(36).toUpperCase(); + const rand = Math.random().toString(36).substring(2, 8).toUpperCase(); + return `SWC-${ts}-${rand}`; +}; + +// ─── Service ─────────────────────────────────────────────────────────────────── + +/** + * Creates a new delivery record in MongoDB. + * + * Business rules enforced here (not in the controller): + * - Sender and recipient Stellar addresses must differ. + * - Estimated delivery date, if provided, must be in the future. + * - New deliveries always start with status PENDING. + * - A unique tracking number is generated server-side. + */ +export const createDelivery = async (input: CreateDeliveryInput): Promise => { + const { + sender, + recipient, + packageDetails, + escrow, + estimatedDeliveryDate, + notes, + } = input; + + // Business rule: sender and recipient cannot share the same Stellar address + if (sender.stellarAddress === recipient.stellarAddress) { + throw new AppError( + 'Sender and recipient Stellar addresses must be different.', + StatusCodes.UNPROCESSABLE_ENTITY, + ); + } + + // Business rule: estimated delivery date must be in the future + let parsedEstimatedDate: Date | undefined; + if (estimatedDeliveryDate) { + parsedEstimatedDate = new Date(estimatedDeliveryDate); + if (isNaN(parsedEstimatedDate.getTime())) { + throw new AppError( + 'estimatedDeliveryDate must be a valid ISO 8601 date string.', + StatusCodes.BAD_REQUEST, + ); + } + if (parsedEstimatedDate <= new Date()) { + throw new AppError( + 'estimatedDeliveryDate must be a future date.', + StatusCodes.UNPROCESSABLE_ENTITY, + ); + } + } + + const trackingNumber = generateTrackingNumber(); + + logger.info(`Creating new delivery with tracking number: ${trackingNumber}`); + + const delivery = await Delivery.create({ + trackingNumber, + status: DeliveryStatus.PENDING, + sender, + recipient, + packageDetails, + escrow: { + amount: escrow.amount, + stellarAsset: escrow.stellarAsset ?? 'XLM', + }, + estimatedDeliveryDate: parsedEstimatedDate, + notes, + }); + + logger.info(`Delivery created successfully. ID: ${delivery._id}, Tracking: ${trackingNumber}`); + + return delivery; +}; diff --git a/src/utils/AppError.ts b/src/utils/AppError.ts new file mode 100644 index 0000000..93f2dd3 --- /dev/null +++ b/src/utils/AppError.ts @@ -0,0 +1,27 @@ +/** + * 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 { + 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); + + // Capture stack trace, excluding the constructor frame itself + Error.captureStackTrace(this, this.constructor); + } +} + +export default AppError; diff --git a/tests/admin.test.ts b/tests/admin.test.ts new file mode 100644 index 0000000..89b6f5f --- /dev/null +++ b/tests/admin.test.ts @@ -0,0 +1,471 @@ +import request from 'supertest'; +import mongoose from 'mongoose'; +import jwt from 'jsonwebtoken'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import app from '../src/app'; +import User, { UserRole, UserStatus } from '../src/models/User'; + +// ─── Module mocks ────────────────────────────────────────────────────────────── + +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(), +})); + +// ─── In-memory MongoDB ───────────────────────────────────────────────────────── + +let mongoServer: MongoMemoryServer; + +const SETUP_TIMEOUT = 120_000; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); +}, SETUP_TIMEOUT); + +afterEach(async () => { + const collections = mongoose.connection.collections; + for (const key in collections) { + await collections[key].deleteMany({}); + } +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}, 15_000); + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +const JWT_SECRET = 'test-secret-key'; + +/** Mint a signed JWT for the given user id. */ +const signToken = (userId: string): string => + jwt.sign({ id: userId }, JWT_SECRET, { expiresIn: '1h' }); + +/** Create a User document directly — bypasses HTTP so passwords are hashed by the pre-save hook. */ +const createUser = async (overrides: Partial<{ + name: string; + email: string; + password: string; + role: UserRole; + status: UserStatus; +}> = {}): Promise> => { + return User.create({ + name: overrides.name ?? 'Test User', + email: overrides.email ?? `user-${Date.now()}-${Math.random()}@example.com`, + password: overrides.password ?? 'Password123!', + role: overrides.role ?? UserRole.USER, + status: overrides.status ?? UserStatus.ACTIVE, + }); +}; + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +describe('PUT /api/v1/admin/users/:id/suspend', () => { + // ── 200 Happy paths ─────────────────────────────────────────────────────────── + + describe('200 – successful suspension', () => { + it('suspends an active user and returns the updated document', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const admin = await createUser({ role: UserRole.ADMIN }); + const target = await createUser({ role: UserRole.USER }); + const token = signToken(admin._id.toString()); + + const res = await request(app) + .put(`/api/v1/admin/users/${target._id}/suspend`) + .set('Authorization', `Bearer ${token}`) + .send({ reason: 'Fraudulent activity detected.' }); + + expect(res.status).toBe(200); + expect(res.body.status).toBe('success'); + expect(res.body.data.user.status).toBe('suspended'); + }); + + it('returns the message "User has been suspended successfully."', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const admin = await createUser({ role: UserRole.ADMIN }); + const target = await createUser({ role: UserRole.USER }); + const token = signToken(admin._id.toString()); + + const res = await request(app) + .put(`/api/v1/admin/users/${target._id}/suspend`) + .set('Authorization', `Bearer ${token}`) + .send({ reason: 'Spam behaviour.' }); + + expect(res.body.message).toBe('User has been suspended successfully.'); + }); + + it('persists the suspension to the database', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const admin = await createUser({ role: UserRole.ADMIN }); + const target = await createUser({ role: UserRole.USER }); + const token = signToken(admin._id.toString()); + + await request(app) + .put(`/api/v1/admin/users/${target._id}/suspend`) + .set('Authorization', `Bearer ${token}`) + .send({ reason: 'Policy violation.' }); + + const refreshed = await User.findById(target._id); + expect(refreshed?.status).toBe(UserStatus.SUSPENDED); + expect(refreshed?.suspendedReason).toBe('Policy violation.'); + expect(refreshed?.suspendedAt).toBeDefined(); + }); + + it('does not expose the password hash in the response', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const admin = await createUser({ role: UserRole.ADMIN }); + const target = await createUser({ role: UserRole.USER }); + const token = signToken(admin._id.toString()); + + const res = await request(app) + .put(`/api/v1/admin/users/${target._id}/suspend`) + .set('Authorization', `Bearer ${token}`) + .send({ reason: 'Test.' }); + + expect(res.body.data.user.password).toBeUndefined(); + }); + + it('bans a user when ban=true is provided', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const admin = await createUser({ role: UserRole.ADMIN }); + const target = await createUser({ role: UserRole.USER }); + const token = signToken(admin._id.toString()); + + const res = await request(app) + .put(`/api/v1/admin/users/${target._id}/suspend`) + .set('Authorization', `Bearer ${token}`) + .send({ reason: 'Repeated abuse.', ban: true }); + + expect(res.status).toBe(200); + expect(res.body.data.user.status).toBe('banned'); + expect(res.body.message).toBe('User has been banned successfully.'); + }); + + it('suspends a driver account the same as a regular user', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const admin = await createUser({ role: UserRole.ADMIN }); + const driver = await createUser({ role: UserRole.DRIVER }); + const token = signToken(admin._id.toString()); + + const res = await request(app) + .put(`/api/v1/admin/users/${driver._id}/suspend`) + .set('Authorization', `Bearer ${token}`) + .send({ reason: 'Dangerous driving reported.' }); + + expect(res.status).toBe(200); + expect(res.body.data.user.status).toBe('suspended'); + }); + + it('returns the id field as a string in the response', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const admin = await createUser({ role: UserRole.ADMIN }); + const target = await createUser({ role: UserRole.USER }); + const token = signToken(admin._id.toString()); + + const res = await request(app) + .put(`/api/v1/admin/users/${target._id}/suspend`) + .set('Authorization', `Bearer ${token}`) + .send({ reason: 'Test id field.' }); + + expect(typeof res.body.data.user.id).toBe('string'); + }); + }); + + // ── 400 Validation errors ───────────────────────────────────────────────────── + + describe('400 – validation errors', () => { + it('returns 400 when reason is missing', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const admin = await createUser({ role: UserRole.ADMIN }); + const target = await createUser({ role: UserRole.USER }); + const token = signToken(admin._id.toString()); + + const res = await request(app) + .put(`/api/v1/admin/users/${target._id}/suspend`) + .set('Authorization', `Bearer ${token}`) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/reason/i); + }); + + it('returns 400 when reason is an empty string', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const admin = await createUser({ role: UserRole.ADMIN }); + const target = await createUser({ role: UserRole.USER }); + const token = signToken(admin._id.toString()); + + const res = await request(app) + .put(`/api/v1/admin/users/${target._id}/suspend`) + .set('Authorization', `Bearer ${token}`) + .send({ reason: ' ' }); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/reason/i); + }); + + it('returns 400 when ban is not a boolean', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const admin = await createUser({ role: UserRole.ADMIN }); + const target = await createUser({ role: UserRole.USER }); + const token = signToken(admin._id.toString()); + + const res = await request(app) + .put(`/api/v1/admin/users/${target._id}/suspend`) + .set('Authorization', `Bearer ${token}`) + .send({ reason: 'Valid reason.', ban: 'yes' }); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/ban/i); + }); + + it('returns 400 when :id is not a valid ObjectId', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const admin = await createUser({ role: UserRole.ADMIN }); + const token = signToken(admin._id.toString()); + + const res = await request(app) + .put('/api/v1/admin/users/not-an-objectid/suspend') + .set('Authorization', `Bearer ${token}`) + .send({ reason: 'Test.' }); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/invalid user id/i); + }); + }); + + // ── 401 Authentication errors ───────────────────────────────────────────────── + + describe('401 – authentication errors', () => { + it('returns 401 when no Authorization header is provided', async () => { + const target = await createUser({ role: UserRole.USER }); + + const res = await request(app) + .put(`/api/v1/admin/users/${target._id}/suspend`) + .send({ reason: 'Test.' }); + + expect(res.status).toBe(401); + }); + + it('returns 401 when the token is malformed', async () => { + process.env.JWT_SECRET = JWT_SECRET; + const target = await createUser({ role: UserRole.USER }); + + const res = await request(app) + .put(`/api/v1/admin/users/${target._id}/suspend`) + .set('Authorization', 'Bearer this.is.not.a.valid.token') + .send({ reason: 'Test.' }); + + expect(res.status).toBe(401); + }); + + it('returns 401 when the token is signed with a different secret', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const admin = await createUser({ role: UserRole.ADMIN }); + const target = await createUser({ role: UserRole.USER }); + // Signed with the wrong secret + const badToken = jwt.sign({ id: admin._id.toString() }, 'wrong-secret', { expiresIn: '1h' }); + + const res = await request(app) + .put(`/api/v1/admin/users/${target._id}/suspend`) + .set('Authorization', `Bearer ${badToken}`) + .send({ reason: 'Test.' }); + + expect(res.status).toBe(401); + }); + + it('returns 401 when the token references a non-existent user', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const ghostId = new mongoose.Types.ObjectId().toString(); + const token = signToken(ghostId); + const target = await createUser({ role: UserRole.USER }); + + const res = await request(app) + .put(`/api/v1/admin/users/${target._id}/suspend`) + .set('Authorization', `Bearer ${token}`) + .send({ reason: 'Test.' }); + + expect(res.status).toBe(401); + }); + }); + + // ── 403 Authorisation errors ────────────────────────────────────────────────── + + describe('403 – authorisation errors', () => { + it('returns 403 when a regular user tries to call the endpoint', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const regularUser = await createUser({ role: UserRole.USER }); + const target = await createUser({ role: UserRole.USER }); + const token = signToken(regularUser._id.toString()); + + const res = await request(app) + .put(`/api/v1/admin/users/${target._id}/suspend`) + .set('Authorization', `Bearer ${token}`) + .send({ reason: 'Test.' }); + + expect(res.status).toBe(403); + }); + + it('returns 403 when a driver tries to call the endpoint', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const driver = await createUser({ role: UserRole.DRIVER }); + const target = await createUser({ role: UserRole.USER }); + const token = signToken(driver._id.toString()); + + const res = await request(app) + .put(`/api/v1/admin/users/${target._id}/suspend`) + .set('Authorization', `Bearer ${token}`) + .send({ reason: 'Test.' }); + + expect(res.status).toBe(403); + }); + + it('returns 403 when an admin tries to suspend another admin', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const admin = await createUser({ role: UserRole.ADMIN }); + const targetAdmin = await createUser({ role: UserRole.ADMIN }); + const token = signToken(admin._id.toString()); + + const res = await request(app) + .put(`/api/v1/admin/users/${targetAdmin._id}/suspend`) + .set('Authorization', `Bearer ${token}`) + .send({ reason: 'Test.' }); + + expect(res.status).toBe(403); + expect(res.body.message).toMatch(/admin/i); + }); + + it('returns 403 when a suspended admin tries to call the endpoint', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const suspendedAdmin = await createUser({ + role: UserRole.ADMIN, + status: UserStatus.SUSPENDED, + }); + const target = await createUser({ role: UserRole.USER }); + const token = signToken(suspendedAdmin._id.toString()); + + const res = await request(app) + .put(`/api/v1/admin/users/${target._id}/suspend`) + .set('Authorization', `Bearer ${token}`) + .send({ reason: 'Test.' }); + + expect(res.status).toBe(403); + }); + }); + + // ── 404 Not found ───────────────────────────────────────────────────────────── + + describe('404 – not found', () => { + it('returns 404 when the target user does not exist', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const admin = await createUser({ role: UserRole.ADMIN }); + const token = signToken(admin._id.toString()); + const nonExistentId = new mongoose.Types.ObjectId().toString(); + + const res = await request(app) + .put(`/api/v1/admin/users/${nonExistentId}/suspend`) + .set('Authorization', `Bearer ${token}`) + .send({ reason: 'Test.' }); + + expect(res.status).toBe(404); + expect(res.body.message).toMatch(/user not found/i); + }); + }); + + // ── 409 Conflict ────────────────────────────────────────────────────────────── + + describe('409 – conflict', () => { + it('returns 409 when the user is already suspended', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const admin = await createUser({ role: UserRole.ADMIN }); + const target = await createUser({ + role: UserRole.USER, + status: UserStatus.SUSPENDED, + }); + const token = signToken(admin._id.toString()); + + const res = await request(app) + .put(`/api/v1/admin/users/${target._id}/suspend`) + .set('Authorization', `Bearer ${token}`) + .send({ reason: 'Already suspended.' }); + + expect(res.status).toBe(409); + expect(res.body.message).toMatch(/already suspended/i); + }); + + it('returns 409 when the user is already banned and ban=true is sent', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const admin = await createUser({ role: UserRole.ADMIN }); + const target = await createUser({ + role: UserRole.USER, + status: UserStatus.BANNED, + }); + const token = signToken(admin._id.toString()); + + const res = await request(app) + .put(`/api/v1/admin/users/${target._id}/suspend`) + .set('Authorization', `Bearer ${token}`) + .send({ reason: 'Already banned.', ban: true }); + + expect(res.status).toBe(409); + expect(res.body.message).toMatch(/already banned/i); + }); + }); + + // ── 422 Business rule violations ────────────────────────────────────────────── + + describe('422 – business rule violations', () => { + it('returns 422 when an admin tries to suspend their own account', async () => { + process.env.JWT_SECRET = JWT_SECRET; + + const admin = await createUser({ role: UserRole.ADMIN }); + const token = signToken(admin._id.toString()); + + const res = await request(app) + .put(`/api/v1/admin/users/${admin._id}/suspend`) + .set('Authorization', `Bearer ${token}`) + .send({ reason: 'Self-suspension test.' }); + + expect(res.status).toBe(422); + expect(res.body.message).toMatch(/cannot suspend or ban their own/i); + }); + }); + + // ── Routing ─────────────────────────────────────────────────────────────────── + + describe('routing', () => { + it('returns 404 for GET on the same path', async () => { + const res = await request(app).get('/api/v1/admin/users/someid/suspend'); + // No token — hits 401 before 404, which is correct auth-first behaviour + expect([401, 404]).toContain(res.status); + }); + }); +}); diff --git a/tests/delivery.test.ts b/tests/delivery.test.ts new file mode 100644 index 0000000..3f949a3 --- /dev/null +++ b/tests/delivery.test.ts @@ -0,0 +1,325 @@ +import request from 'supertest'; +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import app from '../src/app'; + +// ─── Module mocks ────────────────────────────────────────────────────────────── + +// Prevent the real DB connection in app.ts from firing during tests +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(), +})); + +// ─── In-memory MongoDB ───────────────────────────────────────────────────────── + +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); + +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, +}; + +const validRecipient = { + name: 'Bob Asante', + email: 'bob@example.com', + phone: '+233207654321', + stellarAddress: 'GBVVNBZGZILHXKUQ7YSVUV7TVNXW3PFOSC7YWPXNZL7CZMHP5BSXQM', + address: { ...validAddress, street: '456 Harbor Rd', city: 'Tema' }, +}; + +const validPackageDetails = { + weight: 2.5, + dimensions: { length: 30, width: 20, height: 15 }, + description: 'Electronic components', + fragile: true, +}; + +const validEscrow = { + amount: 100, + stellarAsset: 'XLM', +}; + +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 ───────────────────────────────────────────────────────────────────── + +describe('POST /api/v1/deliveries', () => { + // ── Happy path ─────────────────────────────────────────────────────────────── + + 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(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(); + }); + + it('returns the document with a generated _id and id string', async () => { + const res = await request(app).post('/api/v1/deliveries').send(validPayload); + + const { delivery } = res.body.data; + expect(delivery._id).toBeDefined(); + expect(delivery.id).toBeDefined(); + expect(typeof delivery.id).toBe('string'); + }); + + it('sets status to "pending" by default', async () => { + const res = await request(app).post('/api/v1/deliveries').send(validPayload); + + expect(res.body.data.delivery.status).toBe('pending'); + }); + + it('generates a tracking number prefixed with "SWC-"', async () => { + const res = await request(app).post('/api/v1/deliveries').send(validPayload); + + expect(res.body.data.delivery.trackingNumber).toMatch(/^SWC-/); + }); + + it('persists sender and recipient details correctly', async () => { + const res = await request(app).post('/api/v1/deliveries').send(validPayload); + + 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); + + const { packageDetails } = res.body.data.delivery; + expect(packageDetails.weight).toBe(validPackageDetails.weight); + expect(packageDetails.fragile).toBe(true); + expect(packageDetails.dimensions.length).toBe(30); + }); + + 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); + + expect(res.body.data.delivery.escrow.amount).toBe(50); + expect(res.body.data.delivery.escrow.stellarAsset).toBe('XLM'); + }); + + it('stores estimatedDeliveryDate when provided', async () => { + const res = await request(app).post('/api/v1/deliveries').send(validPayload); + + expect(res.body.data.delivery.estimatedDeliveryDate).toBeDefined(); + }); + + it('includes createdAt and updatedAt timestamps', async () => { + const res = await request(app).post('/api/v1/deliveries').send(validPayload); + + const { delivery } = res.body.data; + expect(delivery.createdAt).toBeDefined(); + expect(delivery.updatedAt).toBeDefined(); + }); + + 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, + ); + }); + }); + + // ── Validation errors (400) ────────────────────────────────────────────────── + + describe('400 – validation errors', () => { + it('returns 400 when body is empty', async () => { + const res = await request(app).post('/api/v1/deliveries').send({}); + + expect(res.status).toBe(400); + expect(res.body.status).toBe('error'); + }); + + it('returns 400 when sender is missing', async () => { + const { sender: _s, ...payload } = validPayload; + const res = await request(app).post('/api/v1/deliveries').send(payload); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/sender/i); + }); + + it('returns 400 when recipient is missing', async () => { + const { recipient: _r, ...payload } = validPayload; + const res = await request(app).post('/api/v1/deliveries').send(payload); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/recipient/i); + }); + + it('returns 400 when packageDetails is missing', async () => { + const { packageDetails: _p, ...payload } = validPayload; + const res = await request(app).post('/api/v1/deliveries').send(payload); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/packageDetails/i); + }); + + 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(400); + expect(res.body.message).toMatch(/escrow/i); + }); + + 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); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/sender\.email/i); + }); + + 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); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/sender\.stellarAddress/i); + }); + + 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(400); + expect(res.body.message).toMatch(/weight/i); + }); + + 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(400); + expect(res.body.message).toMatch(/fragile/i); + }); + + 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); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/escrow\.amount/i); + }); + }); + + // ── Business rule errors (422) ─────────────────────────────────────────────── + + 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); + + expect(res.status).toBe(422); + expect(res.body.message).toMatch(/stellar address/i); + }); + + 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); + + expect(res.status).toBe(422); + expect(res.body.message).toMatch(/future date/i); + }); + + 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(400); + expect(res.body.message).toMatch(/ISO 8601/i); + }); + }); + + // ── Wrong method / not found ───────────────────────────────────────────────── + + 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); + }); + }); +});