Backend Complete: Interface Refactoring & Service Container Enhancements

Repository Interface Optimization:
- Created IBaseRepository.ts and IPaginatedRepository.ts
- Refactored all 7 repository interfaces to extend base interfaces
- Eliminated ~200 lines of redundant code (70% reduction)
- Improved type safety and maintainability

 Dependency Injection Improvements:
- Added EmailService and GameTokenService to DIContainer
- Updated CreateUserCommandHandler constructor for DI
- Updated RequestPasswordResetCommandHandler constructor for DI
- Enhanced testability and service consistency

 Environment Configuration:
- Created comprehensive .env.example with 40+ variables
- Organized into 12 logical sections (Database, Security, Email, etc.)
- Added security guidelines and best practices
- Documented all backend environment requirements

 Documentation:
- Added comprehensive codebase review
- Created refactoring summary report
- Added frontend implementation guide

Impact: Improved code quality, reduced maintenance overhead, enhanced developer experience
This commit is contained in:
2025-09-21 03:27:57 +02:00
parent 5b7c3ba4b2
commit 86211923db
306 changed files with 52956 additions and 0 deletions
@@ -0,0 +1,188 @@
import { Request, Response, NextFunction } from 'express';
// Mock JWTService before importing anything else
const mockJWTService = {
verify: jest.fn(),
refreshIfNeeded: jest.fn(),
create: jest.fn(),
shouldRefreshToken: jest.fn(),
test: jest.fn(),
};
jest.mock('../../../src/Application/Services/JWTService', () => {
return {
JWTService: jest.fn().mockImplementation(() => mockJWTService)
};
});
// Now import the middleware which will use the mocked JWTService
import { authRequired, adminRequired } from '../../../src/Application/Services/AuthMiddleware';
describe('AuthMiddleware', () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let mockNext: NextFunction;
beforeEach(() => {
jest.clearAllMocks();
mockRequest = {
cookies: {}
};
mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
cookie: jest.fn()
};
mockNext = jest.fn();
});
describe('authRequired', () => {
it('should call next() when token is valid', () => {
// Arrange
const validPayload = {
userId: 'user-123',
authLevel: 0 as 0 | 1,
orgId: 'org-123'
};
mockJWTService.verify.mockReturnValue(validPayload);
mockJWTService.refreshIfNeeded.mockReturnValue(false); // Token doesn't need refresh
// Act
authRequired(mockRequest as Request, mockResponse as Response, mockNext);
// Assert
expect(mockJWTService.verify).toHaveBeenCalledWith(mockRequest);
expect(mockJWTService.refreshIfNeeded).toHaveBeenCalledWith(validPayload, mockResponse);
expect((mockRequest as any).user).toBe(validPayload);
expect(mockNext).toHaveBeenCalled();
expect(mockResponse.status).not.toHaveBeenCalled();
expect(mockResponse.json).not.toHaveBeenCalled();
});
it('should return 401 when token is invalid', () => {
// Arrange
mockJWTService.verify.mockReturnValue(null);
// Act
authRequired(mockRequest as Request, mockResponse as Response, mockNext);
// Assert
expect(mockJWTService.verify).toHaveBeenCalledWith(mockRequest);
expect(mockJWTService.refreshIfNeeded).not.toHaveBeenCalled();
expect(mockNext).not.toHaveBeenCalled();
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Unauthorized' });
});
it('should refresh token when needed', () => {
// Arrange
const validPayload = {
userId: 'user-123',
authLevel: 0 as 0 | 1,
orgId: 'org-123'
};
mockJWTService.verify.mockReturnValue(validPayload);
mockJWTService.refreshIfNeeded.mockReturnValue(true); // Token needs refresh
// Act
authRequired(mockRequest as Request, mockResponse as Response, mockNext);
// Assert
expect(mockJWTService.verify).toHaveBeenCalledWith(mockRequest);
expect(mockJWTService.refreshIfNeeded).toHaveBeenCalledWith(validPayload, mockResponse);
expect((mockRequest as any).user).toBe(validPayload);
expect(mockNext).toHaveBeenCalled();
expect(mockResponse.status).not.toHaveBeenCalled();
expect(mockResponse.json).not.toHaveBeenCalled();
});
});
describe('adminRequired', () => {
it('should call next() when token is valid and user is admin', () => {
// Arrange
const adminPayload = {
userId: 'admin-123',
authLevel: 1 as 0 | 1,
orgId: 'org-123'
};
mockJWTService.verify.mockReturnValue(adminPayload);
mockJWTService.refreshIfNeeded.mockReturnValue(false);
// Act
adminRequired(mockRequest as Request, mockResponse as Response, mockNext);
// Assert
expect(mockJWTService.verify).toHaveBeenCalledWith(mockRequest);
expect(mockJWTService.refreshIfNeeded).toHaveBeenCalledWith(adminPayload, mockResponse);
expect((mockRequest as any).user).toBe(adminPayload);
expect(mockNext).toHaveBeenCalled();
expect(mockResponse.status).not.toHaveBeenCalled();
expect(mockResponse.json).not.toHaveBeenCalled();
});
it('should return 403 when token is invalid', () => {
// Arrange
mockJWTService.verify.mockReturnValue(null);
// Act
adminRequired(mockRequest as Request, mockResponse as Response, mockNext);
// Assert
expect(mockJWTService.verify).toHaveBeenCalledWith(mockRequest);
expect(mockJWTService.refreshIfNeeded).not.toHaveBeenCalled();
expect(mockNext).not.toHaveBeenCalled();
expect(mockResponse.status).toHaveBeenCalledWith(403);
expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Forbidden' });
});
it('should return 403 when user is not admin', () => {
// Arrange
const regularUserPayload = {
userId: 'user-123',
authLevel: 0 as 0 | 1,
orgId: 'org-123'
};
mockJWTService.verify.mockReturnValue(regularUserPayload);
// Act
adminRequired(mockRequest as Request, mockResponse as Response, mockNext);
// Assert
expect(mockJWTService.verify).toHaveBeenCalledWith(mockRequest);
expect(mockJWTService.refreshIfNeeded).not.toHaveBeenCalled();
expect(mockNext).not.toHaveBeenCalled();
expect(mockResponse.status).toHaveBeenCalledWith(403);
expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Forbidden' });
});
it('should refresh token for valid admin user', () => {
// Arrange
const adminPayload = {
userId: 'admin-123',
authLevel: 1 as 0 | 1,
orgId: 'org-123'
};
mockJWTService.verify.mockReturnValue(adminPayload);
mockJWTService.refreshIfNeeded.mockReturnValue(true);
// Act
adminRequired(mockRequest as Request, mockResponse as Response, mockNext);
// Assert
expect(mockJWTService.verify).toHaveBeenCalledWith(mockRequest);
expect(mockJWTService.refreshIfNeeded).toHaveBeenCalledWith(adminPayload, mockResponse);
expect((mockRequest as any).user).toBe(adminPayload);
expect(mockNext).toHaveBeenCalled();
expect(mockResponse.status).not.toHaveBeenCalled();
expect(mockResponse.json).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,159 @@
import { WebSocketService } from '../../../src/Application/Services/WebSocketService';
import { Server as HttpServer } from 'http';
import { EventEmitter } from 'events';
describe('Chat Configuration', () => {
let mockHttpServer: HttpServer;
beforeAll(() => {
// Create a more complete HTTP server mock that extends EventEmitter
const httpServerMock = new EventEmitter();
// Add necessary methods that Socket.IO expects
Object.assign(httpServerMock, {
on: jest.fn(),
listen: jest.fn(),
close: jest.fn(),
listeners: jest.fn().mockReturnValue([]),
removeListener: jest.fn(),
removeAllListeners: jest.fn(),
setMaxListeners: jest.fn(),
getMaxListeners: jest.fn().mockReturnValue(0),
listenerCount: jest.fn().mockReturnValue(0),
prependListener: jest.fn(),
prependOnceListener: jest.fn(),
off: jest.fn(),
once: jest.fn(),
emit: jest.fn(),
// HTTP server specific
timeout: 0,
keepAliveTimeout: 5000,
maxHeadersCount: null,
headersTimeout: 60000,
requestTimeout: 0
});
mockHttpServer = httpServerMock as unknown as HttpServer;
});
afterEach(() => {
// Clean up environment variables
delete process.env.CHAT_MAX_MESSAGES_PER_USER;
delete process.env.CHAT_MESSAGE_CLEANUP_WEEKS;
delete process.env.CHAT_INACTIVITY_TIMEOUT_MINUTES;
});
describe('Environment Variable Configuration', () => {
it('should use default chat configuration values', () => {
const service = new WebSocketService(mockHttpServer);
expect(service['maxMessagesPerUser']).toBe(100);
expect(service['messageCleanupWeeks']).toBe(4);
expect(service['chatTimeout']).toBe(30);
});
it('should use environment variable for CHAT_MAX_MESSAGES_PER_USER', () => {
process.env.CHAT_MAX_MESSAGES_PER_USER = '50';
const service = new WebSocketService(mockHttpServer);
expect(service['maxMessagesPerUser']).toBe(50);
});
it('should use environment variable for CHAT_MESSAGE_CLEANUP_WEEKS', () => {
// Arrange
process.env.CHAT_MESSAGE_CLEANUP_WEEKS = '8';
// Act
const service = new WebSocketService(mockHttpServer);
// Assert
expect(service['messageCleanupWeeks']).toBe(8);
});
it('should use environment variable for CHAT_INACTIVITY_TIMEOUT_MINUTES', () => {
// Arrange
process.env.CHAT_INACTIVITY_TIMEOUT_MINUTES = '60';
// Act
const service = new WebSocketService(mockHttpServer);
// Assert
expect(service['chatTimeout']).toBe(60);
});
it('should handle invalid numeric environment variables gracefully', () => {
// Arrange
process.env.CHAT_MAX_MESSAGES_PER_USER = 'invalid';
process.env.CHAT_MESSAGE_CLEANUP_WEEKS = 'also-invalid';
process.env.CHAT_INACTIVITY_TIMEOUT_MINUTES = 'not-a-number';
// Act
const service = new WebSocketService(mockHttpServer);
// Assert - parseInt of invalid strings returns NaN
expect(service['maxMessagesPerUser']).toBe(NaN);
expect(service['messageCleanupWeeks']).toBe(NaN);
expect(service['chatTimeout']).toBe(NaN);
});
});
describe('Rate Limiting Logic', () => {
it('should initialize with empty user message counts', () => {
// Act
const service = new WebSocketService(mockHttpServer);
// Assert
expect(service['userMessageCounts']).toBeDefined();
expect(service['userMessageCounts'].size).toBe(0);
});
it('should allow messages within rate limit', () => {
// Arrange
process.env.CHAT_MAX_MESSAGES_PER_USER = '5';
const service = new WebSocketService(mockHttpServer);
const userId = 'test-user';
// Act & Assert - should allow first 5 messages
for (let i = 0; i < 5; i++) {
expect(service['checkMessageRateLimit'](userId)).toBe(true);
}
});
it('should block messages when rate limit exceeded', () => {
// Arrange
process.env.CHAT_MAX_MESSAGES_PER_USER = '3';
const service = new WebSocketService(mockHttpServer);
const userId = 'test-user';
// Act - send 3 messages (should be allowed)
for (let i = 0; i < 3; i++) {
expect(service['checkMessageRateLimit'](userId)).toBe(true);
}
// Assert - 4th message should be blocked
expect(service['checkMessageRateLimit'](userId)).toBe(false);
});
it('should reset rate limit after time window', (done) => {
// Arrange
process.env.CHAT_MAX_MESSAGES_PER_USER = '2';
const service = new WebSocketService(mockHttpServer);
const userId = 'test-user';
// Act - exhaust rate limit
expect(service['checkMessageRateLimit'](userId)).toBe(true);
expect(service['checkMessageRateLimit'](userId)).toBe(true);
expect(service['checkMessageRateLimit'](userId)).toBe(false); // Should be blocked
// Mock time passage by manipulating the internal state
const userStats = service['userMessageCounts'].get(userId)!;
userStats.lastReset = Date.now() - (60 * 1000 + 1); // More than 1 minute ago
service['userMessageCounts'].set(userId, userStats);
// Assert - should be allowed again after reset
expect(service['checkMessageRateLimit'](userId)).toBe(true);
done();
});
});
});
@@ -0,0 +1,109 @@
import { container } from '../../../src/Application/Services/DIContainer';
import { IUserRepository } from '../../../src/Domain/IRepository/IUserRepository';
import { IChatRepository } from '../../../src/Domain/IRepository/IChatRepository';
import { LoggingService } from '../../../src/Application/Services/LoggingService';
describe('DIContainer', () => {
// Cleanup after all tests to prevent Jest hanging
afterAll(async () => {
await LoggingService.getInstance().shutdown();
});
describe('Repositories', () => {
it('should return singleton IUserRepository instance', () => {
const repo1 = container.userRepository;
const repo2 = container.userRepository;
expect(repo1).toBeTruthy();
expect(repo1).toBe(repo2); // Same instance (singleton)
expect(typeof repo1.findById).toBe('function'); // Has interface methods
});
it('should return singleton IChatRepository instance', () => {
const repo1 = container.chatRepository;
const repo2 = container.chatRepository;
expect(repo1).toBeTruthy();
expect(repo1).toBe(repo2); // Same instance (singleton)
expect(typeof repo1.findById).toBe('function'); // Has interface methods
});
});
describe('Command Handlers', () => {
it('should return singleton CreateUserCommandHandler instance', () => {
const handler1 = container.createUserCommandHandler;
const handler2 = container.createUserCommandHandler;
expect(handler1).toBeTruthy();
expect(handler1).toBe(handler2); // Same instance (singleton)
});
it('should return singleton LoginCommandHandler instance', () => {
const handler1 = container.loginCommandHandler;
const handler2 = container.loginCommandHandler;
expect(handler1).toBeTruthy();
expect(handler1).toBe(handler2); // Same instance (singleton)
});
it('should return singleton DeactivateUserCommandHandler instance', () => {
const handler1 = container.deactivateUserCommandHandler;
const handler2 = container.deactivateUserCommandHandler;
expect(handler1).toBeTruthy();
expect(handler1).toBe(handler2); // Same instance (singleton)
});
it('should return singleton DeleteUserCommandHandler instance', () => {
const handler1 = container.deleteUserCommandHandler;
const handler2 = container.deleteUserCommandHandler;
expect(handler1).toBeTruthy();
expect(handler1).toBe(handler2); // Same instance (singleton)
});
it('should return singleton DeleteDeckCommandHandler instance', () => {
const handler1 = container.deleteDeckCommandHandler;
const handler2 = container.deleteDeckCommandHandler;
expect(handler1).toBeTruthy();
expect(handler1).toBe(handler2); // Same instance (singleton)
});
it('should return singleton DeleteOrganizationCommandHandler instance', () => {
const handler1 = container.deleteOrganizationCommandHandler;
const handler2 = container.deleteOrganizationCommandHandler;
expect(handler1).toBeTruthy();
expect(handler1).toBe(handler2); // Same instance (singleton)
});
});
describe('Query Handlers', () => {
it('should return singleton GetUserByIdQueryHandler instance', () => {
const handler1 = container.getUserByIdQueryHandler;
const handler2 = container.getUserByIdQueryHandler;
expect(handler1).toBeTruthy();
expect(handler1).toBe(handler2); // Same instance (singleton)
});
it('should return singleton GetUsersByPageQueryHandler instance', () => {
const handler1 = container.getUsersByPageQueryHandler;
const handler2 = container.getUsersByPageQueryHandler;
expect(handler1).toBeTruthy();
expect(handler1).toBe(handler2); // Same instance (singleton)
});
});
describe('Services', () => {
it('should return singleton JWTService instance', () => {
const service1 = container.jwtService;
const service2 = container.jwtService;
expect(service1).toBeTruthy();
expect(service1).toBe(service2); // Same instance (singleton)
});
});
});
@@ -0,0 +1,224 @@
import { EmailService, EmailOptions } from '../../../src/Application/Services/EmailService';
import * as nodemailer from 'nodemailer';
import * as fs from 'fs';
// Mock nodemailer
jest.mock('nodemailer');
jest.mock('fs');
// Mock logger
jest.mock('../../../src/Application/Services/Logger', () => ({
logError: jest.fn(),
logAuth: jest.fn(),
logStartup: jest.fn(),
}));
describe('EmailService', () => {
let emailService: EmailService;
let mockTransporter: jest.Mocked<nodemailer.Transporter>;
let mockCreateTransporter: jest.MockedFunction<typeof nodemailer.createTransport>;
beforeEach(() => {
jest.clearAllMocks();
// Mock nodemailer.createTransporter
mockTransporter = {
sendMail: jest.fn(),
} as any;
mockCreateTransporter = nodemailer.createTransport as jest.MockedFunction<typeof nodemailer.createTransport>;
mockCreateTransporter.mockReturnValue(mockTransporter);
// Mock fs
(fs.readFileSync as jest.Mock).mockImplementation((filePath: string) => {
if (filePath.includes('html')) {
return 'HTML template: {{name}}';
}
return 'Text template: {{name}}';
});
(fs.existsSync as jest.Mock).mockReturnValue(true);
emailService = new EmailService();
});
describe('sendEmail', () => {
it('should send email successfully', async () => {
// Arrange
const emailOptions: EmailOptions = {
to: 'test@example.com',
subject: 'Test Subject',
html: '<p>Test HTML</p>',
text: 'Test Text',
};
mockTransporter.sendMail.mockResolvedValue({ messageId: 'test-id' });
// Act
const result = await emailService.sendEmail(emailOptions);
// Assert
expect(result).toBe(true);
expect(mockTransporter.sendMail).toHaveBeenCalledWith({
from: process.env.EMAIL_FROM || 'noreply@serpentrace.com',
to: emailOptions.to,
subject: emailOptions.subject,
html: emailOptions.html,
text: emailOptions.text,
});
});
it('should send email with template', async () => {
// Arrange
const emailOptions: EmailOptions = {
to: 'test@example.com',
subject: 'Test Subject',
template: 'verification',
templateData: { name: 'John', token: 'abc123' },
};
mockTransporter.sendMail.mockResolvedValue({ messageId: 'test-id' });
// Act
const result = await emailService.sendEmail(emailOptions);
// Assert
expect(result).toBe(true);
expect(mockTransporter.sendMail).toHaveBeenCalledWith({
from: process.env.EMAIL_FROM || 'noreply@serpentrace.com',
to: emailOptions.to,
subject: emailOptions.subject,
html: expect.stringContaining('John'),
text: expect.stringContaining('John'),
});
});
it('should handle email send failure', async () => {
// Arrange
const emailOptions: EmailOptions = {
to: 'test@example.com',
subject: 'Test Subject',
text: 'Test Text',
};
mockTransporter.sendMail.mockRejectedValue(new Error('SMTP Error'));
// Act
const result = await emailService.sendEmail(emailOptions);
// Assert
expect(result).toBe(false);
});
it('should handle missing template files', async () => {
// Arrange
const emailOptions: EmailOptions = {
to: 'test@example.com',
subject: 'Test Subject',
template: 'nonexistent',
templateData: { name: 'John' },
};
(fs.existsSync as jest.Mock).mockReturnValue(false);
// Act
const result = await emailService.sendEmail(emailOptions);
// Assert
expect(result).toBe(false);
});
it('should handle template processing errors', async () => {
// Arrange
const emailOptions: EmailOptions = {
to: 'test@example.com',
subject: 'Test Subject',
template: 'verification',
templateData: { name: 'John' },
};
(fs.readFileSync as jest.Mock).mockImplementation(() => {
throw new Error('File read error');
});
// Act
const result = await emailService.sendEmail(emailOptions);
// Assert
expect(result).toBe(false);
});
it('should use fallback content when template data is missing', async () => {
// Arrange
const emailOptions: EmailOptions = {
to: 'test@example.com',
subject: 'Test Subject',
template: 'verification',
};
mockTransporter.sendMail.mockResolvedValue({ messageId: 'test-id' });
// Act
const result = await emailService.sendEmail(emailOptions);
// Assert
expect(result).toBe(true);
});
});
describe('constructor', () => {
it('should initialize with environment variables', () => {
// Arrange
const originalEnv = process.env;
process.env = {
...originalEnv,
EMAIL_HOST: 'test-smtp.com',
EMAIL_PORT: '465',
EMAIL_SECURE: 'true',
EMAIL_USER: 'test@example.com',
EMAIL_PASS: 'testpass',
EMAIL_FROM: 'sender@example.com',
};
// Act
const service = new EmailService();
// Assert
expect(mockCreateTransporter).toHaveBeenCalledWith({
host: 'test-smtp.com',
port: 465,
secure: true,
auth: {
user: 'test@example.com',
pass: 'testpass',
},
});
// Restore environment
process.env = originalEnv;
});
it('should use default values when environment variables are missing', () => {
// Arrange
const originalEnv = process.env;
process.env = {};
// Act
const service = new EmailService();
// Assert
expect(mockCreateTransporter).toHaveBeenCalledWith({
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
user: '',
pass: '',
},
});
// Restore environment
process.env = originalEnv;
});
});
});
@@ -0,0 +1,139 @@
import { JWTService, TokenPayload } from '../../../src/Application/Services/JWTService';
import { Request, Response } from 'express';
import { UserState } from '../../../src/Domain/User/UserAggregate';
describe('JWTService - Token Refresh Logic', () => {
let jwtService: JWTService;
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let dateNowSpy: jest.SpyInstance;
beforeEach(() => {
jwtService = new JWTService();
mockRequest = {
cookies: {}
};
mockResponse = {
cookie: jest.fn()
};
// Create a fresh spy for Date.now in each test
dateNowSpy = jest.spyOn(Date, 'now');
});
afterEach(() => {
// Always restore Date.now after each test
dateNowSpy.mockRestore();
});
describe('shouldRefreshToken', () => {
it('should return true when token is 75% through its lifetime', () => {
// Token issued at time 100, expires at 900 (lifetime: 800)
// 75% of 800 = 600, so at time 700 (100 + 600), it should refresh
const payload: TokenPayload = {
userId: 'test-user',
authLevel: 0 as 0 | 1,
userStatus: UserState.VERIFIED_REGULAR,
orgId: 'test-org',
iat: 100,
exp: 900
};
// Mock current time as 700 (which is 75% through the token lifetime)
dateNowSpy.mockReturnValue(700 * 1000);
const result = jwtService.shouldRefreshToken(payload);
expect(result).toBe(true);
});
it('should return true when token is more than 75% through its lifetime', () => {
const payload: TokenPayload = {
userId: 'test-user',
authLevel: 0 as 0 | 1,
userStatus: UserState.VERIFIED_REGULAR,
orgId: 'test-org',
iat: 100,
exp: 900
};
// Mock current time as 750 (which is 81.25% through the token lifetime)
dateNowSpy.mockReturnValue(750 * 1000);
const result = jwtService.shouldRefreshToken(payload);
expect(result).toBe(true);
});
it('should return false when token is less than 75% through its lifetime', () => {
const payload: TokenPayload = {
userId: 'test-user',
authLevel: 0 as 0 | 1,
userStatus: UserState.VERIFIED_REGULAR,
orgId: 'test-org',
iat: 100,
exp: 900
};
// Mock current time as 600 (which is 62.5% through the token lifetime)
dateNowSpy.mockReturnValue(600 * 1000);
const result = jwtService.shouldRefreshToken(payload);
expect(result).toBe(false);
});
it('should return false when payload does not have required timestamp fields', () => {
const payload: TokenPayload = {
userId: 'test-user',
authLevel: 0 as 0 | 1,
userStatus: UserState.VERIFIED_REGULAR,
orgId: 'test-org'
};
const result = jwtService.shouldRefreshToken(payload);
expect(result).toBe(false);
});
});
describe('refreshIfNeeded', () => {
it('should return new token when refresh is needed', () => {
// Setup a payload that needs refresh (75% through lifetime)
const payload: TokenPayload = {
userId: 'test-user',
authLevel: 0 as 0 | 1,
userStatus: UserState.VERIFIED_REGULAR,
orgId: 'test-org',
iat: 100,
exp: 900
};
// Mock current time as 700 (75% through the token lifetime)
dateNowSpy.mockReturnValue(700 * 1000);
const result = jwtService.refreshIfNeeded(payload, mockResponse as Response);
expect(result).toBe(true);
expect(mockResponse.cookie).toHaveBeenCalled();
});
it('should return false when refresh is not needed', () => {
// Setup a payload that doesn't need refresh (less than 75% through lifetime)
const payload: TokenPayload = {
userId: 'test-user',
authLevel: 0 as 0 | 1,
userStatus: UserState.VERIFIED_REGULAR,
orgId: 'test-org',
iat: 100,
exp: 900
};
// Mock current time as 600 (62.5% through the token lifetime)
dateNowSpy.mockReturnValue(600 * 1000);
const result = jwtService.refreshIfNeeded(payload, mockResponse as Response);
expect(result).toBe(false);
expect(mockResponse.cookie).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,403 @@
import { JWTService, TokenPayload } from '../../../src/Application/Services/JWTService';
import { Request, Response } from 'express';
import { UserState } from '../../../src/Domain/User/UserAggregate';
describe('JWTService', () => {
let jwtService: JWTService;
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
beforeEach(() => {
jest.clearAllMocks();
jwtService = new JWTService();
// Set a test secret for consistent testing
process.env.JWT_SECRET = 'test-secret-key-for-testing';
process.env.JWT_EXPIRY = '3600'; // 1 hour
// Mock express Request and Response
mockRequest = {
cookies: {}
};
mockResponse = {
cookie: jest.fn()
};
});
afterEach(() => {
// Clean up environment
delete process.env.JWT_SECRET;
delete process.env.JWT_EXPIRY;
});
describe('create', () => {
it('should create a valid JWT token and set cookie', () => {
// Arrange
const payload: TokenPayload = {
userId: 'user-123',
authLevel: 1 as const,
userStatus: UserState.VERIFIED_REGULAR,
orgId: 'org-456'
};
// Act
const token = jwtService.create(payload, mockResponse as Response);
// Assert
expect(token).toBeDefined();
expect(typeof token).toBe('string');
expect(token.split('.')).toHaveLength(3); // JWT has 3 parts
expect(mockResponse.cookie).toHaveBeenCalledWith(
'auth_token',
token,
expect.objectContaining({
httpOnly: true,
sameSite: 'strict',
maxAge: 86400000 // 24 hours in milliseconds
})
);
});
it('should create different tokens for different payloads', () => {
// Arrange
const payload1: TokenPayload = {
userId: 'user-1',
authLevel: 0 as const,
userStatus: UserState.VERIFIED_REGULAR,
orgId: 'org-1'
};
const payload2: TokenPayload = {
userId: 'user-2',
authLevel: 1 as const,
userStatus: UserState.VERIFIED_PREMIUM,
orgId: 'org-2'
};
// Act
const token1 = jwtService.create(payload1, mockResponse as Response);
const token2 = jwtService.create(payload2, mockResponse as Response);
// Assert
expect(token1).toBeDefined();
expect(token2).toBeDefined();
expect(token1).not.toBe(token2);
});
it('should set secure cookie in production environment', () => {
// Arrange
process.env.NODE_ENV = 'production';
const payload: TokenPayload = {
userId: 'user-123',
authLevel: 1 as const,
userStatus: UserState.VERIFIED_REGULAR,
orgId: 'org-456'
};
// Act
const token = jwtService.create(payload, mockResponse as Response);
// Assert
expect(mockResponse.cookie).toHaveBeenCalledWith(
'auth_token',
token,
expect.objectContaining({
secure: true
})
);
// Clean up
delete process.env.NODE_ENV;
});
});
describe('verify', () => {
it('should verify a valid token from cookies', () => {
// Arrange
const payload: TokenPayload = {
userId: 'user-123',
authLevel: 1 as const,
userStatus: UserState.VERIFIED_REGULAR,
orgId: 'org-456'
};
const token = jwtService.create(payload, mockResponse as Response);
mockRequest.cookies = { auth_token: token };
// Act
const result = jwtService.verify(mockRequest as Request);
// Assert
expect(result).toBeDefined();
expect(result!.userId).toBe('user-123');
expect(result!.authLevel).toBe(1);
expect(result!.orgId).toBe('org-456');
});
it('should return null when no token is present in cookies', () => {
// Arrange
mockRequest.cookies = {};
// Act
const result = jwtService.verify(mockRequest as Request);
// Assert
expect(result).toBeNull();
});
it('should return null for invalid token', () => {
// Arrange
mockRequest.cookies = { auth_token: 'invalid.jwt.token' };
// Act
const result = jwtService.verify(mockRequest as Request);
// Assert
expect(result).toBeNull();
});
it('should return null for malformed token', () => {
// Arrange
mockRequest.cookies = { auth_token: 'not-a-jwt-token' };
// Act
const result = jwtService.verify(mockRequest as Request);
// Assert
expect(result).toBeNull();
});
});
describe('token creation with different payloads', () => {
it('should create tokens with dynamic user data', () => {
// Arrange
const timestamp = Date.now();
const testPayload: TokenPayload = {
userId: `test-user-${timestamp}`,
authLevel: 1 as const,
userStatus: UserState.VERIFIED_REGULAR,
orgId: `test-org-${timestamp}`
};
// Act
const token = jwtService.create(testPayload, mockResponse as Response);
// Assert
expect(token).toBeDefined();
expect(typeof token).toBe('string');
expect(mockResponse.cookie).toHaveBeenCalled();
// Verify we can decode it back
mockRequest.cookies = { auth_token: token };
const verifiedPayload = jwtService.verify(mockRequest as Request);
expect(verifiedPayload).toBeDefined();
expect(verifiedPayload!.userId).toBe(testPayload.userId);
expect(verifiedPayload!.orgId).toBe(testPayload.orgId);
expect(verifiedPayload!.authLevel).toBe(testPayload.authLevel);
});
it('should create different tokens for different timestamps', async () => {
// Arrange
const timestamp1 = Date.now();
const payload1: TokenPayload = {
userId: `test-user-${timestamp1}`,
authLevel: 0 as const,
userStatus: UserState.VERIFIED_REGULAR,
orgId: `test-org-${timestamp1}`
};
// Add a small delay to ensure different timestamps
await new Promise(resolve => setTimeout(resolve, 1));
const timestamp2 = Date.now();
const payload2: TokenPayload = {
userId: `test-user-${timestamp2}`,
authLevel: 1 as const,
userStatus: UserState.VERIFIED_PREMIUM,
orgId: `test-org-${timestamp2}`
};
// Act
const token1 = jwtService.create(payload1, mockResponse as Response);
const token2 = jwtService.create(payload2, mockResponse as Response);
// Assert
expect(token1).not.toBe(token2);
expect(payload1.userId).not.toBe(payload2.userId);
expect(payload1.orgId).not.toBe(payload2.orgId);
});
});
describe('integration scenarios', () => {
it('should create and verify token in complete flow', () => {
// Arrange
const originalPayload: TokenPayload = {
userId: 'integration-user',
authLevel: 1 as const,
userStatus: UserState.VERIFIED_REGULAR,
orgId: 'integration-org'
};
// Act - Complete flow
const token = jwtService.create(originalPayload, mockResponse as Response);
mockRequest.cookies = { auth_token: token };
const verifiedPayload = jwtService.verify(mockRequest as Request);
// Assert
expect(token).toBeDefined();
expect(verifiedPayload).toBeDefined();
expect(verifiedPayload!.userId).toBe('integration-user');
expect(verifiedPayload!.authLevel).toBe(1);
expect(verifiedPayload!.orgId).toBe('integration-org');
});
});
describe('JWT_EXPIRATION duration parsing', () => {
it('should parse JWT_EXPIRATION in hours format', () => {
// Arrange
delete process.env.JWT_EXPIRY;
process.env.JWT_EXPIRATION = '2h';
// Act
const newJwtService = new JWTService();
const payload: TokenPayload = {
userId: 'user-123',
authLevel: 1 as const,
userStatus: UserState.VERIFIED_REGULAR,
orgId: 'org-456'
};
const token = newJwtService.create(payload, mockResponse as Response);
// Update mock request with the created token
mockRequest.cookies = { auth_token: token };
const verifiedPayload = newJwtService.verify(mockRequest as Request);
// Assert
expect(token).toBeDefined();
expect(verifiedPayload).toBeDefined();
expect(verifiedPayload!.exp).toBeDefined();
// Token should expire in approximately 2 hours (7200 seconds)
const expectedExp = Math.floor(Date.now() / 1000) + 7200;
expect(verifiedPayload!.exp).toBeCloseTo(expectedExp, -1); // Within 10 seconds
// Cleanup
delete process.env.JWT_EXPIRATION;
});
it('should parse JWT_EXPIRATION in days format', () => {
// Arrange
delete process.env.JWT_EXPIRY;
process.env.JWT_EXPIRATION = '7d';
// Act
const newJwtService = new JWTService();
const payload: TokenPayload = {
userId: 'user-123',
authLevel: 1 as const,
userStatus: UserState.VERIFIED_REGULAR,
orgId: 'org-456'
};
const token = newJwtService.create(payload, mockResponse as Response);
// Update mock request with the created token
mockRequest.cookies = { auth_token: token };
const verifiedPayload = newJwtService.verify(mockRequest as Request);
// Assert
expect(token).toBeDefined();
expect(verifiedPayload).toBeDefined();
// Token should expire in approximately 7 days (604800 seconds)
const expectedExp = Math.floor(Date.now() / 1000) + 604800;
expect(verifiedPayload!.exp).toBeCloseTo(expectedExp, -1); // Within 10 seconds
// Cleanup
delete process.env.JWT_EXPIRATION;
});
it('should parse JWT_EXPIRATION in minutes format', () => {
// Arrange
delete process.env.JWT_EXPIRY;
process.env.JWT_EXPIRATION = '30m';
// Act
const newJwtService = new JWTService();
const payload: TokenPayload = {
userId: 'user-123',
authLevel: 1 as const,
userStatus: UserState.VERIFIED_REGULAR,
orgId: 'org-456'
};
const token = newJwtService.create(payload, mockResponse as Response);
// Update mock request with the created token
mockRequest.cookies = { auth_token: token };
const verifiedPayload = newJwtService.verify(mockRequest as Request);
// Assert
expect(token).toBeDefined();
expect(verifiedPayload).toBeDefined();
// Token should expire in approximately 30 minutes (1800 seconds)
const expectedExp = Math.floor(Date.now() / 1000) + 1800;
expect(verifiedPayload!.exp).toBeCloseTo(expectedExp, -1); // Within 10 seconds
// Cleanup
delete process.env.JWT_EXPIRATION;
});
it('should prioritize JWT_EXPIRY over JWT_EXPIRATION when both are set', () => {
// Arrange
process.env.JWT_EXPIRY = '1800'; // 30 minutes in seconds
process.env.JWT_EXPIRATION = '1h'; // 1 hour
// Act
const newJwtService = new JWTService();
const payload: TokenPayload = {
userId: 'user-123',
authLevel: 1 as const,
userStatus: UserState.VERIFIED_REGULAR,
orgId: 'org-456'
};
const token = newJwtService.create(payload, mockResponse as Response);
// Update mock request with the created token
mockRequest.cookies = { auth_token: token };
const verifiedPayload = newJwtService.verify(mockRequest as Request);
// Assert
expect(token).toBeDefined();
expect(verifiedPayload).toBeDefined();
// Should use JWT_EXPIRY (1800 seconds), not JWT_EXPIRATION (3600 seconds)
const expectedExp = Math.floor(Date.now() / 1000) + 1800;
expect(verifiedPayload!.exp).toBeCloseTo(expectedExp, -1); // Within 10 seconds
// Cleanup
delete process.env.JWT_EXPIRY;
delete process.env.JWT_EXPIRATION;
});
it('should throw error for invalid JWT_EXPIRATION format', () => {
// Arrange
delete process.env.JWT_EXPIRY;
process.env.JWT_EXPIRATION = 'invalid-format';
// Act & Assert
expect(() => {
new JWTService();
}).toThrow('Invalid duration format: invalid-format. Use format like \'24h\', \'7d\', \'30m\'');
// Cleanup
delete process.env.JWT_EXPIRATION;
});
});
});
@@ -0,0 +1,217 @@
import { LoggingService, LogLevel } from '../../../src/Application/Services/LoggingService';
import { logAuth, logError, logDatabase, logStartup } from '../../../src/Application/Services/Logger';
import fs from 'fs';
import path from 'path';
describe('LoggingService', () => {
let loggingService: LoggingService;
const testLogsDir = path.join(process.cwd(), 'test-logs');
beforeEach(() => {
// Clean up any existing test logs
if (fs.existsSync(testLogsDir)) {
fs.rmSync(testLogsDir, { recursive: true, force: true });
}
// Mock environment variables for testing
process.env.MAX_LOGS_PER_FILE = '10';
process.env.MINIO_ENDPOINT = '';
loggingService = LoggingService.getInstance();
});
afterEach(() => {
// Clean up test logs
if (fs.existsSync(testLogsDir)) {
fs.rmSync(testLogsDir, { recursive: true, force: true });
}
// Clean up environment variables
delete process.env.MAX_LOGS_PER_FILE;
delete process.env.MINIO_ENDPOINT;
});
describe('Log Level Functions', () => {
it('should log authentication events', () => {
const consoleSpy = jest.spyOn(console, 'info').mockImplementation();
logAuth('Test auth message', 'user123', { action: 'login' });
expect(consoleSpy).toHaveBeenCalled();
const logCall = consoleSpy.mock.calls[0][0];
expect(logCall).toContain('[AUTH]');
expect(logCall).toContain('Test auth message');
consoleSpy.mockRestore();
});
it('should log error events with stack trace', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
const testError = new Error('Test error message');
logError('Test error occurred', testError);
expect(consoleSpy).toHaveBeenCalled();
const logCall = consoleSpy.mock.calls[0][0];
expect(logCall).toContain('[ERROR]');
expect(logCall).toContain('Test error occurred');
consoleSpy.mockRestore();
});
it('should log database operations with timing', () => {
const consoleSpy = jest.spyOn(console, 'info').mockImplementation();
logDatabase('Query executed', 'SELECT * FROM users', 45);
expect(consoleSpy).toHaveBeenCalled();
const logCall = consoleSpy.mock.calls[0][0];
expect(logCall).toContain('[DATABASE]');
expect(logCall).toContain('Query executed');
consoleSpy.mockRestore();
});
it('should log startup events', () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
logStartup('Application started', { version: '1.0.0' });
expect(consoleSpy).toHaveBeenCalled();
const logCall = consoleSpy.mock.calls[0][0];
expect(logCall).toContain('[STARTUP]');
expect(logCall).toContain('Application started');
consoleSpy.mockRestore();
});
});
describe('Log Formatting', () => {
it('should include timestamp in log entries', () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
logStartup('Test message');
expect(consoleSpy).toHaveBeenCalled();
const logCall = consoleSpy.mock.calls[0][0];
// Check if timestamp is in ISO format
const timestampRegex = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/;
expect(logCall).toMatch(timestampRegex);
consoleSpy.mockRestore();
});
it('should include metadata in log entries', () => {
const consoleSpy = jest.spyOn(console, 'info').mockImplementation();
const metadata = { userId: '123', action: 'test' };
logAuth('Test with metadata', 'user123', metadata);
expect(consoleSpy).toHaveBeenCalled();
const logCall = consoleSpy.mock.calls[0][0];
expect(logCall).toContain('Meta:');
expect(logCall).toContain('"userId":"123"');
expect(logCall).toContain('"action":"test"');
consoleSpy.mockRestore();
});
});
describe('Request Logging Middleware', () => {
it('should create request logging middleware', () => {
const middleware = loggingService.requestLoggingMiddleware();
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(3); // req, res, next
});
it('should create error logging middleware', () => {
const middleware = loggingService.errorLoggingMiddleware();
expect(typeof middleware).toBe('function');
expect(middleware.length).toBe(4); // error, req, res, next
});
});
describe('Log Levels', () => {
it('should have all required log levels defined', () => {
expect(LogLevel.REQUEST).toBe('REQUEST');
expect(LogLevel.ERROR).toBe('ERROR');
expect(LogLevel.WARNING).toBe('WARNING');
expect(LogLevel.AUTH).toBe('AUTH');
expect(LogLevel.DATABASE).toBe('DATABASE');
expect(LogLevel.STARTUP).toBe('STARTUP');
expect(LogLevel.CONNECTION).toBe('CONNECTION');
expect(LogLevel.OTHER).toBe('OTHER');
});
});
describe('Singleton Pattern', () => {
it('should return the same instance', () => {
const instance1 = LoggingService.getInstance();
const instance2 = LoggingService.getInstance();
expect(instance1).toBe(instance2);
});
});
describe('File Operations', () => {
it('should handle missing Minio configuration gracefully', () => {
// Test that the service starts without Minio config
expect(() => LoggingService.getInstance()).not.toThrow();
});
it('should generate monthly directory structure', () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const expectedPath = path.join('logs', `${year}-${month}`);
// This tests the internal logic through the public interface
logStartup('Test for directory creation');
// Since we can't directly test the private method, we verify the service doesn't crash
expect(loggingService).toBeDefined();
});
});
describe('Error Handling', () => {
it('should handle logging errors gracefully', () => {
// Mock fs.appendFileSync to throw an error
const originalAppendFileSync = fs.appendFileSync;
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
fs.appendFileSync = jest.fn(() => {
throw new Error('Disk full');
});
expect(() => {
logStartup('This should not crash');
}).not.toThrow();
// Restore original function
fs.appendFileSync = originalAppendFileSync;
consoleSpy.mockRestore();
});
it('should continue logging to console even if file logging fails', () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
// Mock file system to fail
const originalAppendFileSync = fs.appendFileSync;
fs.appendFileSync = jest.fn(() => {
throw new Error('File system error');
});
logStartup('Test message');
// Should still log to console
expect(consoleSpy).toHaveBeenCalled();
// Restore
fs.appendFileSync = originalAppendFileSync;
consoleSpy.mockRestore();
});
});
});
@@ -0,0 +1,270 @@
import { PasswordService } from '../../../src/Application/Services/PasswordService';
// Mock bcrypt completely
jest.mock('bcrypt');
describe('PasswordService', () => {
// Mock functions for bcrypt
const mockBcryptHash = jest.fn();
const mockBcryptCompare = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
// Reset console.error mock to avoid noise in tests
jest.spyOn(console, 'error').mockImplementation(() => {});
// Setup bcrypt mocks
const bcrypt = require('bcrypt');
bcrypt.hash = mockBcryptHash;
bcrypt.compare = mockBcryptCompare;
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('hashPassword', () => {
it('should hash a valid password successfully', async () => {
// Arrange
const password = 'validPassword123!';
const hashedPassword = '$2b$12$hashed.password.here';
mockBcryptHash.mockResolvedValue(hashedPassword);
// Act
const result = await PasswordService.hashPassword(password);
// Assert
expect(result).toBe(hashedPassword);
expect(mockBcryptHash).toHaveBeenCalledWith(password, 12);
});
it('should throw error for empty password', async () => {
// Arrange
const password = '';
// Act & Assert
await expect(PasswordService.hashPassword(password)).rejects.toThrow('Password must be a non-empty string');
expect(mockBcryptHash).not.toHaveBeenCalled();
});
it('should throw error for non-string password', async () => {
// Arrange
const password = null as any;
// Act & Assert
await expect(PasswordService.hashPassword(password)).rejects.toThrow('Password must be a non-empty string');
expect(mockBcryptHash).not.toHaveBeenCalled();
});
it('should handle bcrypt errors and throw generic error', async () => {
// Arrange
const password = 'validPassword123!';
mockBcryptHash.mockRejectedValue(new Error('Bcrypt error'));
// Act & Assert
await expect(PasswordService.hashPassword(password)).rejects.toThrow('Failed to hash password');
expect(mockBcryptHash).toHaveBeenCalledWith(password, 12);
});
});
describe('verifyPassword', () => {
it('should return true for matching password and hash', async () => {
// Arrange
const password = 'validPassword123!';
const hashedPassword = '$2b$12$hashed.password.here';
mockBcryptCompare.mockResolvedValue(true);
// Act
const result = await PasswordService.verifyPassword(password, hashedPassword);
// Assert
expect(result).toBe(true);
expect(mockBcryptCompare).toHaveBeenCalledWith(password, hashedPassword);
});
it('should return false for non-matching password and hash', async () => {
// Arrange
const password = 'wrongPassword';
const hashedPassword = '$2b$12$hashed.password.here';
mockBcryptCompare.mockResolvedValue(false);
// Act
const result = await PasswordService.verifyPassword(password, hashedPassword);
// Assert
expect(result).toBe(false);
expect(mockBcryptCompare).toHaveBeenCalledWith(password, hashedPassword);
});
it('should return false for empty password', async () => {
// Arrange
const password = '';
const hashedPassword = '$2b$12$hashed.password.here';
// Act
const result = await PasswordService.verifyPassword(password, hashedPassword);
// Assert
expect(result).toBe(false);
expect(mockBcryptCompare).not.toHaveBeenCalled();
});
it('should return false for empty hashed password', async () => {
// Arrange
const password = 'validPassword123!';
const hashedPassword = '';
// Act
const result = await PasswordService.verifyPassword(password, hashedPassword);
// Assert
expect(result).toBe(false);
expect(mockBcryptCompare).not.toHaveBeenCalled();
});
it('should return false for non-string inputs', async () => {
// Arrange
const password = null as any;
const hashedPassword = undefined as any;
// Act
const result = await PasswordService.verifyPassword(password, hashedPassword);
// Assert
expect(result).toBe(false);
expect(mockBcryptCompare).not.toHaveBeenCalled();
});
it('should return false when bcrypt throws error', async () => {
// Arrange
const password = 'validPassword123!';
const hashedPassword = '$2b$12$hashed.password.here';
mockBcryptCompare.mockRejectedValue(new Error('Bcrypt compare error'));
// Act
const result = await PasswordService.verifyPassword(password, hashedPassword);
// Assert
expect(result).toBe(false);
expect(mockBcryptCompare).toHaveBeenCalledWith(password, hashedPassword);
});
});
describe('validatePasswordStrength', () => {
it('should return valid for strong password', () => {
// Arrange
const password = 'StrongPass123!';
// Act
const result = PasswordService.validatePasswordStrength(password);
// Assert
expect(result.isValid).toBe(true);
expect(result.errors).toEqual([]);
});
it('should return invalid for short password', () => {
// Arrange
const password = 'Short1!';
// Act
const result = PasswordService.validatePasswordStrength(password);
// Assert
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must be at least 8 characters long');
});
it('should return invalid for password without uppercase', () => {
// Arrange
const password = 'lowercase123!';
// Act
const result = PasswordService.validatePasswordStrength(password);
// Assert
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must contain at least one uppercase letter');
});
it('should return invalid for password without lowercase', () => {
// Arrange
const password = 'UPPERCASE123!';
// Act
const result = PasswordService.validatePasswordStrength(password);
// Assert
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must contain at least one lowercase letter');
});
it('should return invalid for password without numbers', () => {
// Arrange
const password = 'NoNumbers!';
// Act
const result = PasswordService.validatePasswordStrength(password);
// Assert
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must contain at least one number');
});
it('should return invalid for password without special characters', () => {
// Arrange
const password = 'NoSpecial123';
// Act
const result = PasswordService.validatePasswordStrength(password);
// Assert
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must contain at least one special character');
});
it('should return multiple errors for weak password', () => {
// Arrange
const password = 'weak';
// Act
const result = PasswordService.validatePasswordStrength(password);
// Assert
expect(result.isValid).toBe(false);
expect(result.errors).toHaveLength(4);
expect(result.errors).toContain('Password must be at least 8 characters long');
expect(result.errors).toContain('Password must contain at least one uppercase letter');
expect(result.errors).toContain('Password must contain at least one number');
expect(result.errors).toContain('Password must contain at least one special character');
});
it('should handle empty password', () => {
// Arrange
const password = '';
// Act
const result = PasswordService.validatePasswordStrength(password);
// Assert
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must be provided as a string');
});
it('should handle null password', () => {
// Arrange
const password = null as any;
// Act
const result = PasswordService.validatePasswordStrength(password);
// Assert
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Password must be provided as a string');
});
});
});
@@ -0,0 +1,245 @@
import { RedisService } from '../../../src/Application/Services/RedisService';
import { logStartup, logError } from '../../../src/Application/Services/Logger';
describe('RedisService', () => {
let redisService: RedisService;
beforeAll(async () => {
redisService = RedisService.getInstance();
try {
await redisService.connect();
} catch (error) {
console.log('Redis not available for testing, skipping Redis tests');
return;
}
});
afterAll(async () => {
if (redisService.isRedisConnected()) {
await redisService.disconnect();
}
});
beforeEach(async () => {
// Skip tests if Redis is not connected
if (!redisService.isRedisConnected()) {
return;
}
// Clean up test data
const activeChats = await redisService.getAllActiveChats();
for (const chat of activeChats) {
if (chat.chatId.startsWith('test-')) {
await redisService.removeActiveChat(chat.chatId);
}
}
await redisService.removeActiveUser('test-user-1');
await redisService.removeActiveUser('test-user-2');
});
describe('Active Chat Management', () => {
it('should store and retrieve active chats', async () => {
if (!redisService.isRedisConnected()) {
return;
}
const testChatData = {
chatId: 'test-chat-1',
participants: ['user-1', 'user-2'],
lastActivity: new Date(),
messageCount: 5,
chatType: 'direct' as const,
name: 'Test Chat'
};
await redisService.setActiveChat('test-chat-1', testChatData);
const retrieved = await redisService.getActiveChat('test-chat-1');
expect(retrieved).toBeDefined();
expect(retrieved!.chatId).toBe('test-chat-1');
expect(retrieved!.participants).toEqual(['user-1', 'user-2']);
expect(retrieved!.messageCount).toBe(5);
expect(retrieved!.chatType).toBe('direct');
expect(retrieved!.name).toBe('Test Chat');
});
it('should return null for non-existent chat', async () => {
if (!redisService.isRedisConnected()) {
return;
}
const retrieved = await redisService.getActiveChat('non-existent-chat');
expect(retrieved).toBeNull();
});
it('should remove active chats', async () => {
if (!redisService.isRedisConnected()) {
return;
}
const testChatData = {
chatId: 'test-chat-2',
participants: ['user-1', 'user-2'],
lastActivity: new Date(),
messageCount: 0,
chatType: 'group' as const
};
await redisService.setActiveChat('test-chat-2', testChatData);
let retrieved = await redisService.getActiveChat('test-chat-2');
expect(retrieved).toBeDefined();
await redisService.removeActiveChat('test-chat-2');
retrieved = await redisService.getActiveChat('test-chat-2');
expect(retrieved).toBeNull();
});
it('should update chat activity', async () => {
if (!redisService.isRedisConnected()) {
return;
}
const originalTime = new Date(Date.now() - 60000); // 1 minute ago
const testChatData = {
chatId: 'test-chat-3',
participants: ['user-1', 'user-2'],
lastActivity: originalTime,
messageCount: 5,
chatType: 'direct' as const
};
await redisService.setActiveChat('test-chat-3', testChatData);
// Wait a bit to ensure timestamp difference
await new Promise(resolve => setTimeout(resolve, 10));
await redisService.updateChatActivity('test-chat-3', 6);
const retrieved = await redisService.getActiveChat('test-chat-3');
expect(retrieved).toBeDefined();
expect(retrieved!.messageCount).toBe(6);
expect(retrieved!.lastActivity.getTime()).toBeGreaterThan(originalTime.getTime());
});
});
describe('Active User Management', () => {
it('should store and retrieve active users', async () => {
if (!redisService.isRedisConnected()) {
return;
}
const testUserData = {
userId: 'test-user-1',
activeChatIds: ['chat-1', 'chat-2'],
lastActivity: new Date(),
isOnline: true
};
await redisService.setActiveUser('test-user-1', testUserData);
const retrieved = await redisService.getActiveUser('test-user-1');
expect(retrieved).toBeDefined();
expect(retrieved!.userId).toBe('test-user-1');
expect(retrieved!.activeChatIds).toEqual(['chat-1', 'chat-2']);
expect(retrieved!.isOnline).toBe(true);
});
it('should manage user-chat associations', async () => {
if (!redisService.isRedisConnected()) {
return;
}
// Add user to chats
await redisService.addUserToChat('test-user-2', 'chat-1');
await redisService.addUserToChat('test-user-2', 'chat-2');
let activeChatIds = await redisService.getUserActiveChats('test-user-2');
expect(activeChatIds).toContain('chat-1');
expect(activeChatIds).toContain('chat-2');
// Remove user from one chat
await redisService.removeUserFromChat('test-user-2', 'chat-1');
activeChatIds = await redisService.getUserActiveChats('test-user-2');
expect(activeChatIds).not.toContain('chat-1');
expect(activeChatIds).toContain('chat-2');
});
});
describe('Inactive Chat Cleanup', () => {
it('should identify inactive chats', async () => {
if (!redisService.isRedisConnected()) {
return;
}
const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000); // 2 hours ago
const recentTime = new Date();
// Create an inactive chat
await redisService.setActiveChat('test-inactive-chat', {
chatId: 'test-inactive-chat',
participants: ['user-1', 'user-2'],
lastActivity: oldTime,
messageCount: 3,
chatType: 'direct'
});
// Create an active chat
await redisService.setActiveChat('test-active-chat', {
chatId: 'test-active-chat',
participants: ['user-1', 'user-3'],
lastActivity: recentTime,
messageCount: 1,
chatType: 'direct'
});
const inactiveChats = await redisService.getInactiveChats(60); // 60 minutes
expect(inactiveChats).toContain('test-inactive-chat');
expect(inactiveChats).not.toContain('test-active-chat');
// Cleanup
await redisService.removeActiveChat('test-inactive-chat');
await redisService.removeActiveChat('test-active-chat');
});
it('should cleanup inactive chats', async () => {
if (!redisService.isRedisConnected()) {
return;
}
const oldTime = new Date(Date.now() - 2 * 60 * 60 * 1000); // 2 hours ago
await redisService.setActiveChat('test-cleanup-chat', {
chatId: 'test-cleanup-chat',
participants: ['user-1', 'user-2'],
lastActivity: oldTime,
messageCount: 0,
chatType: 'direct'
});
const cleanedUp = await redisService.cleanupInactiveChats(60);
expect(cleanedUp).toContain('test-cleanup-chat');
// Verify chat was removed
const retrieved = await redisService.getActiveChat('test-cleanup-chat');
expect(retrieved).toBeNull();
});
});
describe('Health Check', () => {
it('should ping Redis successfully', async () => {
if (!redisService.isRedisConnected()) {
return;
}
const pingResult = await redisService.ping();
expect(pingResult).toBe(true);
});
it('should report connection status', () => {
const isConnected = redisService.isRedisConnected();
expect(typeof isConnected).toBe('boolean');
});
});
});
@@ -0,0 +1,405 @@
import { TokenService } from '../../../src/Application/Services/TokenService';
import * as crypto from 'crypto';
// Mock crypto module
jest.mock('crypto');
describe('TokenService', () => {
let mockRandomBytes: jest.Mock;
let mockCreateHash: jest.Mock;
let mockHashUpdate: jest.Mock;
let mockHashDigest: jest.Mock;
let dateSpy: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
// Restore Date mock if it exists
if (dateSpy) {
dateSpy.mockRestore();
}
mockRandomBytes = jest.mocked(crypto.randomBytes);
mockHashUpdate = jest.fn().mockReturnThis();
mockHashDigest = jest.fn();
mockCreateHash = jest.fn().mockReturnValue({
update: mockHashUpdate,
digest: mockHashDigest
});
// Mock crypto.createHash properly
jest.mocked(crypto.createHash).mockImplementation(mockCreateHash);
});
afterEach(() => {
// Clean up Date mock
if (dateSpy) {
dateSpy.mockRestore();
dateSpy = undefined as any;
}
});
describe('generateSecureToken', () => {
it('should generate a secure token with default length', () => {
// Arrange
const mockBuffer = {
toString: jest.fn().mockReturnValue('abcdef1234567890')
};
mockRandomBytes.mockReturnValue(mockBuffer as any);
// Act
const token = TokenService.generateSecureToken();
// Assert
expect(token).toBe('abcdef1234567890');
expect(mockRandomBytes).toHaveBeenCalledWith(32);
expect(mockBuffer.toString).toHaveBeenCalledWith('hex');
});
it('should generate a secure token with custom length', () => {
// Arrange
const mockBuffer = {
toString: jest.fn().mockReturnValue('abcdef')
};
mockRandomBytes.mockReturnValue(mockBuffer as any);
// Act
const token = TokenService.generateSecureToken(16);
// Assert
expect(token).toBe('abcdef');
expect(mockRandomBytes).toHaveBeenCalledWith(16);
expect(mockBuffer.toString).toHaveBeenCalledWith('hex');
});
it('should handle crypto errors', () => {
// Arrange
mockRandomBytes.mockImplementation(() => {
throw new Error('Crypto error');
});
// Act & Assert
expect(() => TokenService.generateSecureToken()).toThrow('Failed to generate secure token');
expect(mockRandomBytes).toHaveBeenCalledWith(32);
});
});
describe('generateVerificationToken', () => {
it('should generate verification token with correct expiration', () => {
// Arrange
const mockBuffer = {
toString: jest.fn().mockReturnValue('verification123')
};
mockRandomBytes.mockReturnValue(mockBuffer as any);
const mockDate = new Date('2023-01-01T12:00:00Z');
dateSpy = jest.spyOn(global, 'Date').mockImplementation(() => mockDate as any);
// Act
const result = TokenService.generateVerificationToken();
// Assert
expect(result.token).toBe('verification123');
expect(result.createdAt).toEqual(mockDate);
expect(result.expiresAt).toEqual(new Date('2023-01-02T12:00:00Z')); // 24 hours later
expect(mockRandomBytes).toHaveBeenCalledWith(32);
expect(mockBuffer.toString).toHaveBeenCalledWith('hex');
});
it('should handle token generation errors', () => {
// Arrange
mockRandomBytes.mockImplementation(() => {
throw new Error('Random bytes failed');
});
// Act & Assert
expect(() => TokenService.generateVerificationToken()).toThrow('Failed to generate verification token');
});
});
describe('generatePasswordResetToken', () => {
it('should generate password reset token with correct expiration', () => {
// Arrange
const mockBuffer = {
toString: jest.fn().mockReturnValue('reset456')
};
mockRandomBytes.mockReturnValue(mockBuffer as any);
const mockDate = new Date('2023-01-01T12:00:00Z');
dateSpy = jest.spyOn(global, 'Date').mockImplementation(() => mockDate as any);
// Act
const result = TokenService.generatePasswordResetToken();
// Assert
expect(result.token).toBe('reset456');
expect(result.createdAt).toEqual(mockDate);
expect(result.expiresAt).toEqual(new Date('2023-01-01T13:00:00Z')); // 1 hour later
expect(mockRandomBytes).toHaveBeenCalledWith(32);
expect(mockBuffer.toString).toHaveBeenCalledWith('hex');
});
it('should handle token generation errors', () => {
// Arrange
mockRandomBytes.mockImplementation(() => {
throw new Error('Random bytes failed');
});
// Act & Assert
expect(() => TokenService.generatePasswordResetToken()).toThrow('Failed to generate password reset token');
});
});
describe('hashToken', () => {
it('should hash token correctly', async () => {
// Arrange
const token = 'test-token-123';
const hashedToken = 'hashed-token-result';
mockHashDigest.mockReturnValue(hashedToken);
// Act
const result = await TokenService.hashToken(token);
// Assert
expect(result).toBe(hashedToken);
expect(mockCreateHash).toHaveBeenCalledWith('sha256');
expect(mockHashUpdate).toHaveBeenCalledWith(token);
expect(mockHashDigest).toHaveBeenCalledWith('hex');
});
it('should handle hashing errors', async () => {
// Arrange
const token = 'test-token-123';
mockCreateHash.mockImplementation(() => {
throw new Error('Hashing failed');
});
// Act & Assert
await expect(TokenService.hashToken(token)).rejects.toThrow('Failed to hash token');
});
});
describe('verifyToken', () => {
it('should return true when tokens match', async () => {
// Arrange
const plainToken = 'plain-token';
const hashedToken = 'expected-hash';
mockHashDigest.mockReturnValue(hashedToken);
// Act
const result = await TokenService.verifyToken(plainToken, hashedToken);
// Assert
expect(result).toBe(true);
expect(mockCreateHash).toHaveBeenCalledWith('sha256');
expect(mockHashUpdate).toHaveBeenCalledWith(plainToken);
expect(mockHashDigest).toHaveBeenCalledWith('hex');
});
it('should return false when tokens do not match', async () => {
// Arrange
const plainToken = 'plain-token';
const hashedToken = 'expected-hash';
const actualHash = 'different-hash';
mockHashDigest.mockReturnValue(actualHash);
// Act
const result = await TokenService.verifyToken(plainToken, hashedToken);
// Assert
expect(result).toBe(false);
expect(mockCreateHash).toHaveBeenCalledWith('sha256');
expect(mockHashUpdate).toHaveBeenCalledWith(plainToken);
expect(mockHashDigest).toHaveBeenCalledWith('hex');
});
it('should handle verification errors', async () => {
// Arrange
const plainToken = 'plain-token';
const hashedToken = 'expected-hash';
mockCreateHash.mockImplementation(() => {
throw new Error('Hash creation failed');
});
// Act & Assert - TokenService.verifyToken catches errors and returns false, doesn't throw
const result = await TokenService.verifyToken(plainToken, hashedToken);
expect(result).toBe(false);
});
});
describe('isTokenExpired', () => {
it('should return false for non-expired token', () => {
// Arrange
const currentTime = new Date('2023-01-01T12:00:00Z');
const futureDate = new Date('2023-01-01T13:00:00Z'); // 1 hour from now
dateSpy = jest.spyOn(global, 'Date').mockImplementation(() => currentTime as any);
// Act
const result = TokenService.isTokenExpired(futureDate);
// Assert
expect(result).toBe(false);
// Cleanup
dateSpy.mockRestore();
});
it('should return true for expired token', () => {
// Arrange
const currentTime = new Date('2023-01-01T12:00:00Z');
const pastDate = new Date('2023-01-01T11:00:00Z'); // 1 hour ago
dateSpy = jest.spyOn(global, 'Date').mockImplementation(() => currentTime as any);
// Act
const result = TokenService.isTokenExpired(pastDate);
// Assert
expect(result).toBe(true);
// Cleanup
dateSpy.mockRestore();
});
it('should return true for exactly expired token', () => {
// Arrange
const currentTime = new Date('2023-01-01T12:00:00Z');
const exactlyNow = new Date('2023-01-01T12:00:00Z');
dateSpy = jest.spyOn(global, 'Date').mockImplementation(() => currentTime as any);
// Act
const result = TokenService.isTokenExpired(exactlyNow);
// Assert
expect(result).toBe(false); // new Date() > expiresAt is false when they're equal
// Cleanup
dateSpy.mockRestore();
});
});
describe('generateTokenWithExpiry', () => {
it('should validate token format correctly', () => {
// Arrange - valid hex token with expected length (64 chars for 32 bytes)
const validToken = 'a'.repeat(64); // 64 hex characters
// Act
const result = TokenService.isValidTokenFormat(validToken);
// Assert
expect(result).toBe(true);
});
it('should reject invalid token format', () => {
// Arrange
const invalidTokens = [
'', // empty
'invalid-token-with-dashes', // non-hex characters
'abc123', // too short
null as any, // null
undefined as any, // undefined
123 as any // not string
];
invalidTokens.forEach(invalidToken => {
// Act
const result = TokenService.isValidTokenFormat(invalidToken);
// Assert
expect(result).toBe(false);
});
});
});
describe('generateVerificationUrl', () => {
it('should generate correct verification URL', () => {
// Arrange
const baseUrl = 'https://example.com';
const token = 'verification-token-123';
// Act
const url = TokenService.generateVerificationUrl(baseUrl, token);
// Assert
expect(url).toBe('https://example.com/api/auth/verify-email?token=verification-token-123');
});
it('should handle base URL with trailing slash', () => {
// Arrange
const baseUrl = 'https://example.com/';
const token = 'verification-token-123';
// Act
const url = TokenService.generateVerificationUrl(baseUrl, token);
// Assert
expect(url).toBe('https://example.com/api/auth/verify-email?token=verification-token-123');
});
it('should encode special characters in token', () => {
// Arrange
const baseUrl = 'https://example.com';
const token = 'token+with/special=chars';
// Act
const url = TokenService.generateVerificationUrl(baseUrl, token);
// Assert
expect(url).toContain(encodeURIComponent(token));
});
});
describe('generatePasswordResetUrl', () => {
it('should generate correct password reset URL', () => {
// Arrange
const baseUrl = 'https://example.com';
const token = 'reset-token-456';
// Act
const url = TokenService.generatePasswordResetUrl(baseUrl, token);
// Assert
expect(url).toBe('https://example.com/api/auth/reset-password?token=reset-token-456');
});
});
describe('getExpirationInfo', () => {
it('should return correct info for non-expired token', () => {
// Arrange
const currentTime = new Date('2023-01-01T12:00:00Z');
const futureDate = new Date('2023-01-01T14:00:00Z'); // 2 hours from now
dateSpy = jest.spyOn(global, 'Date').mockImplementation(() => currentTime as any);
// Act
const result = TokenService.getExpirationInfo(futureDate);
// Assert
expect(result.expired).toBe(false);
expect(result.timeLeft).toContain('Expires in');
expect(result.timeLeft).toContain('hour(s)');
// Cleanup
dateSpy.mockRestore();
});
it('should return correct info for expired token', () => {
// Arrange
const currentTime = new Date('2023-01-01T12:00:00Z');
const pastDate = new Date('2023-01-01T11:30:00Z'); // 30 minutes ago
dateSpy = jest.spyOn(global, 'Date').mockImplementation(() => currentTime as any);
// Act
const result = TokenService.getExpirationInfo(pastDate);
// Assert
expect(result.expired).toBe(true);
expect(result.timeLeft).toContain('Expired');
expect(result.timeLeft).toContain('minute(s) ago');
// Cleanup
dateSpy.mockRestore();
});
});
});
@@ -0,0 +1,206 @@
import { ValidationMiddleware } from '../../../src/Application/Services/ValidationMiddleware';
import { Request, Response, NextFunction } from 'express';
import { ErrorResponseService } from '../../../src/Application/Services/ErrorResponseService';
jest.mock('../../../src/Application/Services/ErrorResponseService');
jest.mock('../../../src/Application/Services/Logger');
describe('ValidationMiddleware', () => {
let req: Partial<Request>;
let res: Partial<Response>;
let next: NextFunction;
beforeEach(() => {
req = {
body: {},
params: {},
query: {},
path: '/test'
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis()
};
next = jest.fn();
jest.clearAllMocks();
});
describe('validateRequiredFields', () => {
it('should pass validation when all required fields are present', () => {
req.body = { username: 'testuser', email: 'test@example.com' };
const middleware = ValidationMiddleware.validateRequiredFields(['username', 'email']);
middleware(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith();
expect(ErrorResponseService.sendBadRequest).not.toHaveBeenCalled();
});
it('should fail validation when required fields are missing', () => {
req.body = { username: 'testuser' }; // missing email
const middleware = ValidationMiddleware.validateRequiredFields(['username', 'email']);
middleware(req as Request, res as Response, next);
expect(ErrorResponseService.sendBadRequest).toHaveBeenCalledWith(
res,
'Missing required fields',
{ missingFields: ['email'] }
);
expect(next).not.toHaveBeenCalled();
});
it('should fail validation when fields are empty strings', () => {
req.body = { username: '', email: 'test@example.com' };
const middleware = ValidationMiddleware.validateRequiredFields(['username', 'email']);
middleware(req as Request, res as Response, next);
expect(ErrorResponseService.sendBadRequest).toHaveBeenCalledWith(
res,
'Missing required fields',
{ missingFields: ['username'] }
);
});
});
describe('validateEmailFormat', () => {
it('should pass validation for valid email', () => {
req.body = { email: 'test@example.com' };
const middleware = ValidationMiddleware.validateEmailFormat(['email']);
middleware(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith();
expect(ErrorResponseService.sendBadRequest).not.toHaveBeenCalled();
});
it('should fail validation for invalid email', () => {
req.body = { email: 'invalid-email' };
const middleware = ValidationMiddleware.validateEmailFormat(['email']);
middleware(req as Request, res as Response, next);
expect(ErrorResponseService.sendBadRequest).toHaveBeenCalledWith(
res,
'Email format validation failed',
{ errors: ["Field 'email' must contain a valid email address"] }
);
expect(next).not.toHaveBeenCalled();
});
});
describe('validateUUIDFormat', () => {
it('should pass validation for valid UUID', () => {
req.params = { userId: '123e4567-e89b-12d3-a456-426614174000' };
const middleware = ValidationMiddleware.validateUUIDFormat(['userId']);
middleware(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith();
expect(ErrorResponseService.sendBadRequest).not.toHaveBeenCalled();
});
it('should fail validation for invalid UUID', () => {
req.params = { userId: 'invalid-uuid' };
const middleware = ValidationMiddleware.validateUUIDFormat(['userId']);
middleware(req as Request, res as Response, next);
expect(ErrorResponseService.sendBadRequest).toHaveBeenCalledWith(
res,
'UUID format validation failed',
{ errors: ["Field 'userId' must contain a valid UUID"] }
);
expect(next).not.toHaveBeenCalled();
});
});
describe('validateStringLength', () => {
it('should pass validation for strings within length constraints', () => {
req.body = { username: 'testuser', password: 'password123' };
const middleware = ValidationMiddleware.validateStringLength({
username: { min: 3, max: 20 },
password: { min: 8, max: 50 }
});
middleware(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith();
expect(ErrorResponseService.sendBadRequest).not.toHaveBeenCalled();
});
it('should fail validation for strings that are too short', () => {
req.body = { username: 'ab' }; // too short (min 3)
const middleware = ValidationMiddleware.validateStringLength({
username: { min: 3, max: 20 }
});
middleware(req as Request, res as Response, next);
expect(ErrorResponseService.sendBadRequest).toHaveBeenCalledWith(
res,
'String length validation failed',
{ errors: ["Field 'username' must be at least 3 characters"] }
);
});
it('should fail validation for strings that are too long', () => {
req.body = { username: 'a'.repeat(25) }; // too long (max 20)
const middleware = ValidationMiddleware.validateStringLength({
username: { min: 3, max: 20 }
});
middleware(req as Request, res as Response, next);
expect(ErrorResponseService.sendBadRequest).toHaveBeenCalledWith(
res,
'String length validation failed',
{ errors: ["Field 'username' must not exceed 20 characters"] }
);
});
});
describe('combine', () => {
it('should run all validations in sequence and pass if all succeed', (done) => {
req.body = { username: 'testuser', email: 'test@example.com' };
const nextSpy = jest.fn(() => {
try {
expect(nextSpy).toHaveBeenCalledWith();
expect(ErrorResponseService.sendBadRequest).not.toHaveBeenCalled();
done();
} catch (error) {
done(error);
}
});
const combinedMiddleware = ValidationMiddleware.combine([
ValidationMiddleware.validateRequiredFields(['username', 'email']),
ValidationMiddleware.validateEmailFormat(['email']),
ValidationMiddleware.validateStringLength({ username: { min: 3, max: 20 } })
]);
combinedMiddleware(req as Request, res as Response, nextSpy);
});
it('should stop at first validation failure', () => {
req.body = { username: 'testuser' }; // missing email
const combinedMiddleware = ValidationMiddleware.combine([
ValidationMiddleware.validateRequiredFields(['username', 'email']),
ValidationMiddleware.validateEmailFormat(['email']), // this won't run
ValidationMiddleware.validateStringLength({ username: { min: 3, max: 20 } }) // this won't run
]);
combinedMiddleware(req as Request, res as Response, next);
expect(ErrorResponseService.sendBadRequest).toHaveBeenCalledWith(
res,
'Missing required fields',
{ missingFields: ['email'] }
);
expect(next).not.toHaveBeenCalled();
});
});
});