diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 526a9c5..84ed6f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -711,6 +711,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.12.2': resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} @@ -2644,6 +2645,9 @@ packages: resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==} engines: {node: '>= 0.8.0'} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3008,6 +3012,7 @@ packages: uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -3129,6 +3134,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@babel/code-frame@7.29.7': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bc4d724..2662fd4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,5 @@ allowBuilds: + mongodb-memory-server: true + unrs-resolver: true mongodb-memory-server: set this to true or false unrs-resolver: set this to true or false diff --git a/src/config/env.ts b/src/config/env.ts index f45e519..2765cf3 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -3,6 +3,35 @@ import dotenv from 'dotenv'; dotenv.config(); +interface EnvConfig { + NODE_ENV: string; + PORT: number; + MONGODB_URI: string; + JWT_SECRET: string; + JWT_EXPIRES_IN: string; + BCRYPT_ROUNDS: number; + LOG_LEVEL: string; + CORS_ORIGIN: string; +} + +const getEnvVar = (key: string, fallback?: string): string => { + const value = process.env[key] || fallback; + if (value === undefined) { + throw new Error(`Missing required environment variable: ${key}`); + } + return value; +}; + +const env: EnvConfig = { + NODE_ENV: getEnvVar('NODE_ENV', 'development'), + PORT: parseInt(getEnvVar('PORT', '3000'), 10), + MONGODB_URI: getEnvVar('MONGODB_URI', 'mongodb://localhost:27017/swiftchain'), + JWT_SECRET: getEnvVar('JWT_SECRET'), + JWT_EXPIRES_IN: getEnvVar('JWT_EXPIRES_IN', '7d'), + BCRYPT_ROUNDS: parseInt(getEnvVar('BCRYPT_ROUNDS', '10'), 10), + LOG_LEVEL: getEnvVar('LOG_LEVEL', 'debug'), + CORS_ORIGIN: getEnvVar('CORS_ORIGIN', '*'), +}; const envSchema = z.object({ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), PORT: z.coerce.number().int().min(1).max(65535).default(3000), diff --git a/src/controllers/authController.ts b/src/controllers/authController.ts index 8215b37..7f7644c 100644 --- a/src/controllers/authController.ts +++ b/src/controllers/authController.ts @@ -1,3 +1,33 @@ +import { Request, Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import authService from '../services/authService'; +import asyncHandler from '../utils/asyncHandler'; +import { ILoginPayload } from '../interfaces/IUser'; + +class AuthController { + /** + * POST /api/v1/auth/login + * + * Authenticate a user with email and password. + * Returns a JWT token and sanitized user data on success. + */ + login = asyncHandler(async (req: Request, res: Response): Promise => { + const loginPayload: ILoginPayload = { + email: req.body.email, + password: req.body.password, + }; + + const result = await authService.login(loginPayload); + + res.status(StatusCodes.OK).json({ + status: 'success', + message: 'Login successful', + data: result, + }); + }); +} + +export default new AuthController(); import type { Request, Response } from 'express'; import { StatusCodes } from 'http-status-codes'; import { registerUser } from '../services/authService'; diff --git a/src/interfaces/IUser.ts b/src/interfaces/IUser.ts new file mode 100644 index 0000000..071a0d3 --- /dev/null +++ b/src/interfaces/IUser.ts @@ -0,0 +1,35 @@ +import { Document } from 'mongoose'; + +export enum UserRole { + USER = 'user', + DRIVER = 'driver', + ADMIN = 'admin', +} + +export interface IUser extends Document { + email: string; + password: string; + firstName: string; + lastName: string; + role: UserRole; + isActive: boolean; + createdAt: Date; + updatedAt: Date; + comparePassword(candidatePassword: string): Promise; +} + +export interface ILoginPayload { + email: string; + password: string; +} + +export interface IAuthResponse { + user: { + id: string; + email: string; + firstName: string; + lastName: string; + role: UserRole; + }; + token: string; +} diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index b39387e..7eea2be 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -1,4 +1,65 @@ import { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import logger from '../config/logger'; +import AppError from '../utils/AppError'; + +interface MongooseValidationError extends Error { + errors: Record; +} + +interface MongooseDuplicateKeyError extends Error { + code: number; + keyValue: Record; +} + +const errorHandler = (err: Error, req: Request, res: Response, _next: NextFunction): void => { + let statusCode = 500; + let message = 'Internal Server Error'; + let errors: Array<{ field: string; message: string }> | undefined; + + // Custom application errors + if (err instanceof AppError) { + statusCode = err.statusCode; + message = err.message; + } + + // Zod validation errors + else if (err instanceof z.ZodError) { + statusCode = 400; + message = 'Validation failed'; + errors = err.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message, + })); + } + + // Mongoose validation errors + else if (err.name === 'ValidationError') { + statusCode = 400; + message = 'Validation failed'; + const mongooseErr = err as MongooseValidationError; + errors = Object.entries(mongooseErr.errors).map(([field, detail]) => ({ + field, + message: detail.message, + })); + } + + // MongoDB duplicate key error + else if ((err as MongooseDuplicateKeyError).code === 11000) { + statusCode = 409; + const duplicateErr = err as MongooseDuplicateKeyError; + const field = Object.keys(duplicateErr.keyValue)[0]; + message = `An account with this ${field} already exists`; + } + + // JWT errors + else if (err.name === 'JsonWebTokenError') { + statusCode = 401; + message = 'Invalid token'; + } else if (err.name === 'TokenExpiredError') { + statusCode = 401; + message = 'Token has expired'; + } import { Error as MongooseError } from 'mongoose'; import logger from '../config/logger'; import env from '../config/env'; @@ -21,6 +82,21 @@ const handleValidationErrorDB = (err: MongooseError.ValidationError): AppError = return new AppError(message, 400); }; + const response: Record = { + status: 'error', + statusCode, + message, + }; + + if (errors) { + response.errors = errors; + } + + if (process.env.NODE_ENV === 'development') { + response.stack = err.stack; + } + + res.status(statusCode).json(response); const sendErrorDev = (err: AppError, _req: Request, res: Response): void => { res.status(err.statusCode).json({ status: 'error', diff --git a/src/middleware/validate.ts b/src/middleware/validate.ts new file mode 100644 index 0000000..9adfacc --- /dev/null +++ b/src/middleware/validate.ts @@ -0,0 +1,33 @@ +import { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { StatusCodes } from 'http-status-codes'; + +/** + * Express middleware factory that validates req.body against a Zod schema. + * Returns structured validation errors on failure. + */ +const validate = + (schema: z.ZodType) => + (req: Request, res: Response, next: NextFunction): void => { + const result = schema.safeParse(req.body); + + if (!result.success) { + const errors = result.error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message, + })); + + res.status(StatusCodes.BAD_REQUEST).json({ + status: 'error', + statusCode: StatusCodes.BAD_REQUEST, + message: 'Validation failed', + errors, + }); + return; + } + + req.body = result.data; + next(); + }; + +export default validate; diff --git a/src/models/User.ts b/src/models/User.ts index ec7d09e..10fa938 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -1,3 +1,84 @@ +import mongoose, { Schema } from 'mongoose'; +import bcrypt from 'bcryptjs'; +import { IUser, UserRole } from '../interfaces/IUser'; + +const userSchema = new Schema( + { + email: { + type: String, + required: [true, 'Email is required'], + unique: true, + lowercase: true, + trim: true, + match: [/^\S+@\S+\.\S+$/, 'Please provide a valid email address'], + }, + password: { + type: String, + required: [true, 'Password is required'], + minlength: [8, 'Password must be at least 8 characters'], + select: false, + }, + firstName: { + type: String, + required: [true, 'First name is required'], + trim: true, + maxlength: [50, 'First name cannot exceed 50 characters'], + }, + lastName: { + type: String, + required: [true, 'Last name is required'], + trim: true, + maxlength: [50, 'Last name cannot exceed 50 characters'], + }, + role: { + type: String, + enum: Object.values(UserRole), + default: UserRole.USER, + }, + isActive: { + type: Boolean, + default: true, + }, + }, + { + timestamps: true, + toJSON: { + transform(_doc, ret): any { + ret.id = ret._id; + delete ret._id; + delete ret.__v; + delete ret.password; + return ret; + }, + }, + }, +); + +// Hash password before saving +userSchema.pre('save', async function (next) { + if (!this.isModified('password')) { + return next(); + } + + try { + const rounds = parseInt(process.env.BCRYPT_ROUNDS || '10', 10); + const salt = await bcrypt.genSalt(rounds); + this.password = await bcrypt.hash(this.password, salt); + next(); + } catch (error) { + next(error as Error); + } +}); + +// Instance method to compare passwords +userSchema.methods.comparePassword = async function (candidatePassword: string): Promise { + return bcrypt.compare(candidatePassword, this.password); +}; + +// Index for efficient email lookups +userSchema.index({ email: 1 }); + +const User = mongoose.model('User', userSchema); import mongoose, { Document, Model } from 'mongoose'; export interface IUser extends Document { diff --git a/src/routes/authRoutes.ts b/src/routes/authRoutes.ts index a8a5aae..a34e018 100644 --- a/src/routes/authRoutes.ts +++ b/src/routes/authRoutes.ts @@ -1,9 +1,17 @@ import { Router } from 'express'; +import authController from '../controllers/authController'; +import validate from '../middleware/validate'; +import { loginSchema } from '../validators/authValidator'; import { register } from '../controllers/authController'; const router = Router(); /** + * @route POST /api/v1/auth/login + * @desc Authenticate user and return JWT token + * @access Public + */ +router.post('/login', validate(loginSchema), authController.login); * @route POST /api/v1/auth/register * @desc Register a new user * @access Public diff --git a/src/routes/index.ts b/src/routes/index.ts index cff571a..c53a095 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,4 +1,10 @@ import { Router } from 'express'; +import authRoutes from './authRoutes'; + +const router = Router(); + +// Auth routes +router.use('/auth', authRoutes); import deliveryRoutes from './deliveryRoutes'; const router = Router(); diff --git a/src/services/authService.ts b/src/services/authService.ts index 1929900..64bfaf7 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -1,4 +1,80 @@ import jwt from 'jsonwebtoken'; +import { StatusCodes } from 'http-status-codes'; +import User from '../models/User'; +import { IAuthResponse, ILoginPayload } from '../interfaces/IUser'; +import AppError from '../utils/AppError'; +import logger from '../config/logger'; + +class AuthService { + /** + * Authenticate a user with email and password, returning a JWT token. + * + * Flow: + * 1. Look up user by email (explicitly selecting the password field). + * 2. Verify the account is active. + * 3. Compare the provided password against the stored hash. + * 4. Generate and return a signed JWT along with sanitized user data. + */ + async login(payload: ILoginPayload): Promise { + const { email, password } = payload; + + // Find user by email — must explicitly select password since it's excluded by default + const user = await User.findOne({ email }).select('+password'); + + if (!user) { + logger.warn(`Login attempt failed: no account found for email ${email}`); + throw new AppError('Invalid email or password', StatusCodes.UNAUTHORIZED); + } + + // Check if the account is active + if (!user.isActive) { + logger.warn(`Login attempt failed: deactivated account for email ${email}`); + throw new AppError( + 'Your account has been deactivated. Please contact support.', + StatusCodes.UNAUTHORIZED, + ); + } + + // Verify password + const isPasswordValid = await user.comparePassword(password); + + if (!isPasswordValid) { + logger.warn(`Login attempt failed: invalid password for email ${email}`); + throw new AppError('Invalid email or password', StatusCodes.UNAUTHORIZED); + } + + // Generate JWT + const token = this.generateToken(user.id as string, user.role); + + logger.info(`User ${email} logged in successfully`); + + return { + user: { + id: user.id as string, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + }, + token, + }; + } + + /** + * Generate a signed JWT token containing the user's ID and role. + */ + private generateToken(userId: string, role: string): string { + const secret = process.env.JWT_SECRET; + + if (!secret) { + throw new AppError('JWT secret is not configured', StatusCodes.INTERNAL_SERVER_ERROR, false); + } + + const expiresIn = process.env.JWT_EXPIRES_IN || '7d'; + + return jwt.sign({ userId, role }, secret, { + expiresIn, + }); import logger from '../config/logger'; import User, { IUser } from '../models/User'; diff --git a/src/utils/AppError.ts b/src/utils/AppError.ts index 5190bab..12b7949 100644 --- a/src/utils/AppError.ts +++ b/src/utils/AppError.ts @@ -1,5 +1,14 @@ export class AppError extends Error { public readonly statusCode: number; + public readonly isOperational: boolean; + + constructor(message: string, statusCode: number, isOperational = true) { + super(message); + this.statusCode = statusCode; + this.isOperational = isOperational; + + // Maintains proper stack trace for where the error was thrown + Error.captureStackTrace(this, this.constructor); constructor(message: string, statusCode: number) { super(message); @@ -9,3 +18,5 @@ export class AppError extends Error { Object.setPrototypeOf(this, AppError.prototype); } } + +export default AppError; diff --git a/src/utils/asyncHandler.ts b/src/utils/asyncHandler.ts index c9a0515..f653006 100644 --- a/src/utils/asyncHandler.ts +++ b/src/utils/asyncHandler.ts @@ -1,3 +1,13 @@ +import { Request, Response, NextFunction, RequestHandler } from 'express'; + +/** + * Wraps an async Express route handler to automatically catch errors + * and forward them to the Express error handler via next(). + */ +const asyncHandler = + (fn: (req: Request, res: Response, next: NextFunction) => Promise): RequestHandler => + (req: Request, res: Response, next: NextFunction) => { + Promise.resolve(fn(req, res, next)).catch(next); import type { Request, Response, NextFunction, RequestHandler } from 'express'; /** diff --git a/src/validators/authValidator.ts b/src/validators/authValidator.ts index df6f80a..12e06ba 100644 --- a/src/validators/authValidator.ts +++ b/src/validators/authValidator.ts @@ -1,3 +1,11 @@ +import { z } from 'zod'; + +export const loginSchema = z.object({ + email: z.email('Please provide a valid email address').toLowerCase().trim(), + password: z.string({ error: 'Password is required' }).min(1, 'Password is required'), +}); + +export type LoginInput = z.infer; import ApiError from '../utils/ApiError'; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 661f6fa..615d742 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -1,6 +1,10 @@ import request from 'supertest'; import mongoose from 'mongoose'; import { MongoMemoryServer } from 'mongodb-memory-server'; +import app from '../src/app'; +import User from '../src/models/User'; + +// Mock the database connection to prevent the app from connecting to the real DB import type { Express } from 'express'; // Mock the database connection so app.ts does not open its own connection; @@ -17,6 +21,17 @@ jest.mock('../src/config/logger', () => ({ debug: jest.fn(), })); +let mongoServer: MongoMemoryServer; + +beforeAll(async () => { + // Set JWT_SECRET for tests + process.env.JWT_SECRET = 'test-jwt-secret-key-for-testing'; + process.env.JWT_EXPIRES_IN = '1h'; + process.env.BCRYPT_ROUNDS = '4'; // Lower rounds for faster tests + + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + await mongoose.connect(mongoUri); let app: Express; let mongoServer: MongoMemoryServer; @@ -36,6 +51,209 @@ afterAll(async () => { await mongoServer.stop(); }); +afterEach(async () => { + // Clean up the users collection between tests + await User.deleteMany({}); +}); + +/** + * Helper: Create a user directly in the database for login tests. + * This goes through the Mongoose model (including the pre-save hash hook) + * so the password is properly hashed. + */ +const createTestUser = async (overrides = {}): Promise => { + const defaultUser = { + email: 'testuser@swiftchain.com', + password: 'SecurePass123!', + firstName: 'Test', + lastName: 'User', + role: 'user', + ...overrides, + }; + + return User.create(defaultUser); +}; + +describe('POST /api/v1/auth/login', () => { + describe('Successful Login', () => { + it('should return 200 and a JWT token for valid credentials', async () => { + await createTestUser(); + + const res = await request(app).post('/api/v1/auth/login').send({ + email: 'testuser@swiftchain.com', + password: 'SecurePass123!', + }); + + expect(res.status).toBe(200); + expect(res.body.status).toBe('success'); + expect(res.body.message).toBe('Login successful'); + expect(res.body.data).toHaveProperty('token'); + expect(res.body.data).toHaveProperty('user'); + expect(res.body.data.user).toHaveProperty('id'); + expect(res.body.data.user.email).toBe('testuser@swiftchain.com'); + expect(res.body.data.user.firstName).toBe('Test'); + expect(res.body.data.user.lastName).toBe('User'); + expect(res.body.data.user.role).toBe('user'); + // Password should never be returned + expect(res.body.data.user).not.toHaveProperty('password'); + }); + + it('should return a valid JWT token structure', async () => { + await createTestUser(); + + const res = await request(app).post('/api/v1/auth/login').send({ + email: 'testuser@swiftchain.com', + password: 'SecurePass123!', + }); + + expect(res.status).toBe(200); + const token = res.body.data.token; + // JWT has three parts separated by dots + expect(token.split('.')).toHaveLength(3); + }); + + it('should handle case-insensitive email login', async () => { + await createTestUser({ email: 'user@swiftchain.com' }); + + const res = await request(app).post('/api/v1/auth/login').send({ + email: 'USER@SWIFTCHAIN.COM', + password: 'SecurePass123!', + }); + + expect(res.status).toBe(200); + expect(res.body.data.user.email).toBe('user@swiftchain.com'); + }); + + it('should login a driver user', async () => { + await createTestUser({ role: 'driver', email: 'driver@swiftchain.com' }); + + const res = await request(app).post('/api/v1/auth/login').send({ + email: 'driver@swiftchain.com', + password: 'SecurePass123!', + }); + + expect(res.status).toBe(200); + expect(res.body.data.user.role).toBe('driver'); + }); + }); + + describe('Authentication Failures', () => { + it('should return 401 for non-existent email', async () => { + const res = await request(app).post('/api/v1/auth/login').send({ + email: 'nonexistent@swiftchain.com', + password: 'SecurePass123!', + }); + + expect(res.status).toBe(401); + expect(res.body.status).toBe('error'); + expect(res.body.message).toBe('Invalid email or password'); + }); + + it('should return 401 for wrong password', async () => { + await createTestUser(); + + const res = await request(app).post('/api/v1/auth/login').send({ + email: 'testuser@swiftchain.com', + password: 'WrongPassword123!', + }); + + expect(res.status).toBe(401); + expect(res.body.status).toBe('error'); + expect(res.body.message).toBe('Invalid email or password'); + }); + + it('should return 401 for deactivated account', async () => { + await createTestUser({ isActive: false }); + + const res = await request(app).post('/api/v1/auth/login').send({ + email: 'testuser@swiftchain.com', + password: 'SecurePass123!', + }); + + expect(res.status).toBe(401); + expect(res.body.status).toBe('error'); + expect(res.body.message).toContain('deactivated'); + }); + + it('should use the same error message for invalid email and password to prevent enumeration', async () => { + await createTestUser(); + + const wrongEmailRes = await request(app).post('/api/v1/auth/login').send({ + email: 'wrong@swiftchain.com', + password: 'SecurePass123!', + }); + + const wrongPasswordRes = await request(app).post('/api/v1/auth/login').send({ + email: 'testuser@swiftchain.com', + password: 'WrongPassword!', + }); + + // Same generic message to prevent email enumeration + expect(wrongEmailRes.body.message).toBe(wrongPasswordRes.body.message); + }); + }); + + describe('Validation Errors', () => { + it('should return 400 for empty body', async () => { + const res = await request(app).post('/api/v1/auth/login').send({}); + + expect(res.status).toBe(400); + expect(res.body.status).toBe('error'); + expect(res.body.message).toBe('Validation failed'); + expect(res.body.errors).toBeDefined(); + expect(res.body.errors.length).toBeGreaterThan(0); + }); + + it('should return 400 for invalid email format', async () => { + const res = await request(app).post('/api/v1/auth/login').send({ + email: 'not-an-email', + password: 'SecurePass123!', + }); + + expect(res.status).toBe(400); + expect(res.body.status).toBe('error'); + expect(res.body.errors).toBeDefined(); + }); + + it('should return 400 for missing password', async () => { + const res = await request(app).post('/api/v1/auth/login').send({ + email: 'testuser@swiftchain.com', + }); + + expect(res.status).toBe(400); + expect(res.body.status).toBe('error'); + }); + + it('should return 400 for missing email', async () => { + const res = await request(app).post('/api/v1/auth/login').send({ + password: 'SecurePass123!', + }); + + expect(res.status).toBe(400); + expect(res.body.status).toBe('error'); + }); + }); + + describe('API Versioning', () => { + it('should be accessible at /api/v1/auth/login', async () => { + await createTestUser(); + + const res = await request(app).post('/api/v1/auth/login').send({ + email: 'testuser@swiftchain.com', + password: 'SecurePass123!', + }); + + expect(res.status).toBe(200); + }); + + it('should return 404 for unversioned auth endpoint', async () => { + const res = await request(app).post('/auth/login').send({ + email: 'testuser@swiftchain.com', + password: 'SecurePass123!', + }); + + expect(res.status).toBe(404); + }); const validUser = { name: 'Ada Lovelace', email: 'ada@example.com', diff --git a/tsconfig.json b/tsconfig.json index 4ce0786..5616f51 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,14 +2,14 @@ "compilerOptions": { "target": "ES2020", "module": "commonjs", - "rootDir": "./src", "outDir": "./dist", "strict": true, "esModuleInterop": true, "sourceMap": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "types": ["jest", "node"] }, - "include": ["src/**/*"], + "include": ["src/**/*", "tests/**/*"], "exclude": ["node_modules", "dist"] }