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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user