feat: add Jest testing framework and implement unit tests for configuration and service logic

This commit is contained in:
Shrev Dev
2025-10-21 11:08:26 -05:00
parent 526298bea4
commit 4ee1442589
12 changed files with 4517 additions and 3 deletions
+140
View File
@@ -0,0 +1,140 @@
import { ConfigManager } from '../src/config/config';
describe('ConfigManager', () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
// Save original environment
originalEnv = { ...process.env };
// Clear environment variables
delete process.env.DISCORD_BOT_TOKEN;
delete process.env.UPTIME_KUMA_URL;
delete process.env.UPTIME_KUMA_USERNAME;
delete process.env.UPTIME_KUMA_PASSWORD;
delete process.env.ADMIN_USER_IDS;
delete process.env.UPDATE_INTERVAL;
delete process.env.EMBED_COLOR;
});
afterEach(() => {
// Restore original environment
process.env = originalEnv;
});
test('should load valid configuration', () => {
process.env.DISCORD_BOT_TOKEN = 'test-token';
process.env.UPTIME_KUMA_URL = 'http://localhost:3001';
process.env.UPTIME_KUMA_USERNAME = 'test-user';
process.env.UPTIME_KUMA_PASSWORD = 'test-password';
process.env.ADMIN_USER_IDS = '123456789,987654321';
process.env.UPDATE_INTERVAL = '30';
process.env.EMBED_COLOR = '16711680';
const configManager = new ConfigManager();
const config = configManager.getConfig();
expect(config.discord.token).toBe('test-token');
expect(config.discord.adminUserIds).toEqual(['123456789', '987654321']);
expect(config.uptimeKuma.url).toBe('http://localhost:3001');
expect(config.uptimeKuma.username).toBe('test-user');
expect(config.uptimeKuma.password).toBe('test-password');
expect(config.bot.updateInterval).toBe(30000); // 30 * 1000
expect(config.bot.embedColor).toBe(16711680);
});
test('should use default values when environment variables are not set', () => {
process.env.DISCORD_BOT_TOKEN = 'test-token';
process.env.UPTIME_KUMA_URL = 'http://localhost:3001';
process.env.UPTIME_KUMA_USERNAME = 'test-user';
process.env.UPTIME_KUMA_PASSWORD = 'test-password';
const configManager = new ConfigManager();
const config = configManager.getConfig();
expect(config.discord.adminUserIds).toEqual([]);
expect(config.bot.updateInterval).toBe(60000); // 60 * 1000 (default)
expect(config.bot.embedColor).toBe(5814783); // default color
});
test('should parse admin user IDs correctly', () => {
process.env.DISCORD_BOT_TOKEN = 'test-token';
process.env.UPTIME_KUMA_URL = 'http://localhost:3001';
process.env.UPTIME_KUMA_USERNAME = 'test-user';
process.env.UPTIME_KUMA_PASSWORD = 'test-password';
process.env.ADMIN_USER_IDS = '123456789, 987654321 , 555666777 ';
const configManager = new ConfigManager();
const config = configManager.getConfig();
expect(config.discord.adminUserIds).toEqual(['123456789', '987654321', '555666777']);
});
test('should handle empty admin user IDs', () => {
process.env.DISCORD_BOT_TOKEN = 'test-token';
process.env.UPTIME_KUMA_URL = 'http://localhost:3001';
process.env.UPTIME_KUMA_USERNAME = 'test-user';
process.env.UPTIME_KUMA_PASSWORD = 'test-password';
process.env.ADMIN_USER_IDS = '';
const configManager = new ConfigManager();
const config = configManager.getConfig();
expect(config.discord.adminUserIds).toEqual([]);
});
test('should throw error when DISCORD_BOT_TOKEN is missing', () => {
process.env.UPTIME_KUMA_URL = 'http://localhost:3001';
process.env.UPTIME_KUMA_USERNAME = 'test-user';
process.env.UPTIME_KUMA_PASSWORD = 'test-password';
expect(() => new ConfigManager()).toThrow('DISCORD_BOT_TOKEN is required');
});
test('should throw error when UPTIME_KUMA_URL is empty', () => {
process.env.DISCORD_BOT_TOKEN = 'test-token';
process.env.UPTIME_KUMA_URL = '';
process.env.UPTIME_KUMA_USERNAME = 'test-user';
process.env.UPTIME_KUMA_PASSWORD = 'test-password';
expect(() => new ConfigManager()).toThrow('UPTIME_KUMA_URL is required');
});
test('should throw error when UPTIME_KUMA_USERNAME is missing', () => {
process.env.DISCORD_BOT_TOKEN = 'test-token';
process.env.UPTIME_KUMA_URL = 'http://localhost:3001';
process.env.UPTIME_KUMA_PASSWORD = 'test-password';
expect(() => new ConfigManager()).toThrow('UPTIME_KUMA_USERNAME is required');
});
test('should throw error when UPTIME_KUMA_PASSWORD is missing', () => {
process.env.DISCORD_BOT_TOKEN = 'test-token';
process.env.UPTIME_KUMA_URL = 'http://localhost:3001';
process.env.UPTIME_KUMA_USERNAME = 'test-user';
expect(() => new ConfigManager()).toThrow('UPTIME_KUMA_PASSWORD is required');
});
test('should throw error when UPDATE_INTERVAL is too low', () => {
process.env.DISCORD_BOT_TOKEN = 'test-token';
process.env.UPTIME_KUMA_URL = 'http://localhost:3001';
process.env.UPTIME_KUMA_USERNAME = 'test-user';
process.env.UPTIME_KUMA_PASSWORD = 'test-password';
process.env.UPDATE_INTERVAL = '5'; // Less than 10 seconds
expect(() => new ConfigManager()).toThrow('UPDATE_INTERVAL must be at least 10 seconds');
});
test('should throw error with multiple validation failures', () => {
// Missing multiple required fields
process.env.DISCORD_BOT_TOKEN = 'test-token';
process.env.UPTIME_KUMA_URL = '';
// Missing UPTIME_KUMA_USERNAME, UPTIME_KUMA_PASSWORD
expect(() => new ConfigManager()).toThrow('Configuration validation failed:');
expect(() => new ConfigManager()).toThrow('UPTIME_KUMA_URL is required');
expect(() => new ConfigManager()).toThrow('UPTIME_KUMA_USERNAME is required');
expect(() => new ConfigManager()).toThrow('UPTIME_KUMA_PASSWORD is required');
});
});
+68
View File
@@ -0,0 +1,68 @@
import { DiscordService } from '../src/services/discord.service';
import { Client } from 'discord.js';
// Mock discord.js
jest.mock('discord.js', () => ({
Client: jest.fn().mockImplementation(() => ({
isReady: jest.fn(),
login: jest.fn(),
on: jest.fn(),
once: jest.fn(),
destroy: jest.fn(),
})),
GatewayIntentBits: {
Guilds: 'GUILDS',
},
}));
// Mock other dependencies
jest.mock('../src/config/config');
jest.mock('../src/config/storage');
jest.mock('../src/services/commands.service');
jest.mock('../src/services/uptime-kuma.service');
describe('DiscordService', () => {
let discordService: DiscordService;
let mockClient: jest.Mocked<Client>;
beforeEach(() => {
jest.clearAllMocks();
// Get the mocked client instance
mockClient = new Client({ intents: [] }) as jest.Mocked<Client>;
discordService = new DiscordService();
});
test('should create Discord client with correct intents', () => {
expect(Client).toHaveBeenCalledWith({
intents: ['GUILDS'],
});
});
test('should return client instance', () => {
const client = discordService.getClient();
expect(client).toBeDefined();
});
test('should return connection status', () => {
// Get the actual client from the service
const client = discordService.getClient();
// Test when client is ready
(client as any).isReady = jest.fn().mockReturnValue(true);
expect(discordService.isConnected()).toBe(true);
// Test when client is not ready
(client as any).isReady = jest.fn().mockReturnValue(false);
expect(discordService.isConnected()).toBe(false);
});
test('should set uptime kuma service', () => {
const mockUptimeKumaService = {} as any;
expect(() => {
discordService.setUptimeKumaService(mockUptimeKumaService);
}).not.toThrow();
});
});
+144
View File
@@ -0,0 +1,144 @@
import * as http from 'http';
// Mock the services
jest.mock('../src/services/discord.service');
jest.mock('../src/services/uptime-kuma.service');
jest.mock('../src/config/storage');
describe('Health Check Logic', () => {
let mockDiscordService: any;
let mockUptimeKumaService: any;
let healthServer: http.Server;
const testPort = 3001;
beforeAll(async () => {
// Create mock services
mockDiscordService = {
isConnected: jest.fn()
};
mockUptimeKumaService = {
isConnected: jest.fn()
};
// Start health server with the same logic as the bot
healthServer = http.createServer((req, res) => {
if (req.url === '/health' && req.method === 'GET') {
const isHealthy = mockDiscordService.isConnected() && mockUptimeKumaService.isConnected();
const status = isHealthy ? 'healthy' : 'unhealthy';
const statusCode = isHealthy ? 200 : 503;
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status,
discord: mockDiscordService.isConnected() ? 'connected' : 'disconnected',
uptimeKuma: mockUptimeKumaService.isConnected() ? 'connected' : 'disconnected',
timestamp: new Date().toISOString()
}));
} else {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
}
});
await new Promise<void>((resolve) => {
healthServer.listen(testPort, '0.0.0.0', resolve);
});
});
afterAll(async () => {
await new Promise<void>((resolve) => {
healthServer.close(() => resolve());
});
});
beforeEach(() => {
jest.clearAllMocks();
});
test('should return 200 when both services are connected', async () => {
mockDiscordService.isConnected.mockReturnValue(true);
mockUptimeKumaService.isConnected.mockReturnValue(true);
const response = await makeRequest('/health');
expect(response.statusCode).toBe(200);
expect(response.body.status).toBe('healthy');
expect(response.body.discord).toBe('connected');
expect(response.body.uptimeKuma).toBe('connected');
expect(response.body.timestamp).toBeDefined();
});
test('should return 503 when Discord is disconnected', async () => {
mockDiscordService.isConnected.mockReturnValue(false);
mockUptimeKumaService.isConnected.mockReturnValue(true);
const response = await makeRequest('/health');
expect(response.statusCode).toBe(503);
expect(response.body.status).toBe('unhealthy');
expect(response.body.discord).toBe('disconnected');
expect(response.body.uptimeKuma).toBe('connected');
});
test('should return 503 when Uptime Kuma is disconnected', async () => {
mockDiscordService.isConnected.mockReturnValue(true);
mockUptimeKumaService.isConnected.mockReturnValue(false);
const response = await makeRequest('/health');
expect(response.statusCode).toBe(503);
expect(response.body.status).toBe('unhealthy');
expect(response.body.discord).toBe('connected');
expect(response.body.uptimeKuma).toBe('disconnected');
});
test('should return 503 when both services are disconnected', async () => {
mockDiscordService.isConnected.mockReturnValue(false);
mockUptimeKumaService.isConnected.mockReturnValue(false);
const response = await makeRequest('/health');
expect(response.statusCode).toBe(503);
expect(response.body.status).toBe('unhealthy');
expect(response.body.discord).toBe('disconnected');
expect(response.body.uptimeKuma).toBe('disconnected');
});
test('should return 404 for non-health endpoints', async () => {
const response = await makeRequest('/invalid');
expect(response.statusCode).toBe(404);
expect(response.body).toBe('Not Found');
});
});
// Helper function to make HTTP requests
function makeRequest(path: string): Promise<{ statusCode: number; body: any }> {
return new Promise((resolve, reject) => {
const req = http.request({
hostname: 'localhost',
port: 3001,
path,
method: 'GET',
}, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const body = res.headers['content-type']?.includes('application/json')
? JSON.parse(data)
: data;
resolve({ statusCode: res.statusCode!, body });
} catch (error) {
resolve({ statusCode: res.statusCode!, body: data });
}
});
});
req.on('error', reject);
req.end();
});
}
+19
View File
@@ -0,0 +1,19 @@
// Test setup file
import { config } from '../src/config/config';
// Mock environment variables for testing
process.env.DISCORD_BOT_TOKEN = 'test-token';
process.env.UPTIME_KUMA_URL = 'http://localhost:3001';
process.env.UPTIME_KUMA_USERNAME = 'test-user';
process.env.UPTIME_KUMA_PASSWORD = 'test-password';
process.env.HEALTH_PORT = '3000';
// Mock console methods to reduce noise in tests
global.console = {
...console,
log: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
+191
View File
@@ -0,0 +1,191 @@
import { ConfigStorage, GuildConfig, MonitorGroup } from '../src/config/storage';
import { existsSync, unlinkSync, mkdirSync, rmdirSync } from 'fs';
import { join } from 'path';
describe('ConfigStorage', () => {
let storage: ConfigStorage;
let testDataDir: string;
let testConfigPath: string;
beforeEach(() => {
// Create a temporary test directory
testDataDir = join(__dirname, 'temp-data');
testConfigPath = join(testDataDir, 'bot-config.json');
// Set test data directory
process.env.DATA_DIR = testDataDir;
// Clean up any existing test files
if (existsSync(testConfigPath)) {
unlinkSync(testConfigPath);
}
if (existsSync(testDataDir)) {
rmdirSync(testDataDir);
}
// Create fresh storage instance
storage = new ConfigStorage();
});
afterEach(() => {
// Clean up test files
if (existsSync(testConfigPath)) {
unlinkSync(testConfigPath);
}
if (existsSync(testDataDir)) {
rmdirSync(testDataDir);
}
// Reset environment
delete process.env.DATA_DIR;
});
test('should create default configuration for new guild', () => {
const guildId = '123456789';
const config = storage.getConfig(guildId);
expect(config.channelId).toBeNull();
expect(config.messageIds).toEqual([]);
expect(config.monitorIds).toEqual([]);
expect(config.groups).toEqual([]);
expect(config.updateInterval).toBe(60000);
expect(config.embedColor).toBe(5814783);
expect(config.statusMessage).toBe('Service Status');
});
test('should save and load guild configuration', () => {
const guildId = '123456789';
// Set individual config values
storage.setChannelId(guildId, '987654321');
storage.setMessageIds(guildId, ['msg1', 'msg2']);
storage.setMonitorIds(guildId, [1, 2, 3]);
storage.setUpdateInterval(guildId, 30000);
storage.setEmbedColor(guildId, 16711680);
storage.setStatusMessage(guildId, 'My Custom Status');
const loadedConfig = storage.getConfig(guildId);
expect(loadedConfig.channelId).toBe('987654321');
expect(loadedConfig.messageIds).toEqual(['msg1', 'msg2']);
expect(loadedConfig.monitorIds).toEqual([1, 2, 3]);
expect(loadedConfig.updateInterval).toBe(30000);
expect(loadedConfig.embedColor).toBe(16711680);
expect(loadedConfig.statusMessage).toBe('My Custom Status');
});
test('should update specific fields in guild configuration', () => {
const guildId = '123456789';
// Set initial config
storage.setChannelId(guildId, 'channel123');
storage.setMessageIds(guildId, ['msg1', 'msg2']);
storage.setMonitorIds(guildId, [1, 2, 3]);
const config = storage.getConfig(guildId);
expect(config.channelId).toBe('channel123');
expect(config.messageIds).toEqual(['msg1', 'msg2']);
expect(config.monitorIds).toEqual([1, 2, 3]);
});
test('should manage monitor groups', () => {
const guildId = '123456789';
// Create groups
storage.addGroup(guildId, 'Media Servers');
storage.addGroup(guildId, 'Gaming');
const groups = storage.getGroups(guildId);
expect(groups).toHaveLength(2);
expect(groups[0].name).toBe('Media Servers');
expect(groups[1].name).toBe('Gaming');
// Add monitors to group
storage.addMonitorToGroup(guildId, 'Media Servers', 1);
storage.addMonitorToGroup(guildId, 'Media Servers', 2);
storage.addMonitorToGroup(guildId, 'Gaming', 3);
const updatedGroups = storage.getGroups(guildId);
expect(updatedGroups[0].monitorIds).toEqual([1, 2]);
expect(updatedGroups[1].monitorIds).toEqual([3]);
});
test('should remove monitors from groups', () => {
const guildId = '123456789';
storage.addGroup(guildId, 'Media Servers');
storage.addMonitorToGroup(guildId, 'Media Servers', 1);
storage.addMonitorToGroup(guildId, 'Media Servers', 2);
storage.removeMonitorFromGroup(guildId, 1);
const groups = storage.getGroups(guildId);
expect(groups[0].monitorIds).toEqual([2]);
});
test('should delete groups', () => {
const guildId = '123456789';
storage.addGroup(guildId, 'Media Servers');
storage.addGroup(guildId, 'Gaming');
expect(storage.getGroups(guildId)).toHaveLength(2);
storage.removeGroup(guildId, 'Media Servers');
const groups = storage.getGroups(guildId);
expect(groups).toHaveLength(1);
expect(groups[0].name).toBe('Gaming');
});
test('should get all guild IDs', () => {
storage.setChannelId('guild1', 'channel1');
storage.setChannelId('guild2', 'channel2');
storage.setChannelId('guild3', 'channel3');
const guildIds = storage.getAllGuildIds();
expect(guildIds).toHaveLength(3);
expect(guildIds).toContain('guild1');
expect(guildIds).toContain('guild2');
expect(guildIds).toContain('guild3');
});
test('should handle non-existent groups gracefully', () => {
const guildId = '123456789';
// Try to add monitor to non-existent group
expect(() => storage.addMonitorToGroup(guildId, 'Non-existent', 1)).not.toThrow();
// Try to remove monitor from non-existent group
expect(() => storage.removeMonitorFromGroup(guildId, 1)).not.toThrow();
// Try to delete non-existent group
expect(() => storage.removeGroup(guildId, 'Non-existent')).not.toThrow();
});
test('should persist configuration to file', () => {
const guildId = '123456789';
storage.setChannelId(guildId, 'channel123');
storage.setMonitorIds(guildId, [1, 2, 3]);
// Create new storage instance to test persistence
const newStorage = new ConfigStorage();
const config = newStorage.getConfig(guildId);
expect(config.channelId).toBe('channel123');
expect(config.monitorIds).toEqual([1, 2, 3]);
});
test('should handle file read errors gracefully', () => {
// Create invalid JSON file
const fs = require('fs');
fs.writeFileSync(testConfigPath, 'invalid json');
// Should not throw error, should create default config
expect(() => new ConfigStorage()).not.toThrow();
const storage = new ConfigStorage();
const config = storage.getConfig('test-guild');
expect(config.channelId).toBeNull();
});
});
+76
View File
@@ -0,0 +1,76 @@
import { UptimeKumaService } from '../src/services/uptime-kuma.service';
// Mock socket.io-client
jest.mock('socket.io-client', () => ({
io: jest.fn().mockImplementation(() => ({
connected: false,
connect: jest.fn(),
disconnect: jest.fn(),
on: jest.fn(),
emit: jest.fn(),
})),
}));
// Mock other dependencies
jest.mock('../src/config/config');
jest.mock('../src/utils/logger');
describe('UptimeKumaService', () => {
let uptimeKumaService: UptimeKumaService;
beforeEach(() => {
jest.clearAllMocks();
uptimeKumaService = new UptimeKumaService();
});
test('should create service instance', () => {
expect(uptimeKumaService).toBeDefined();
});
test('should return connection status', () => {
// Initially should be disconnected
expect(uptimeKumaService.isConnected()).toBe(false);
});
test('should handle connection state changes', () => {
// Mock socket connection
const mockSocket = {
connected: true,
connect: jest.fn(),
disconnect: jest.fn(),
on: jest.fn(),
emit: jest.fn(),
};
// Simulate connection by mocking the socket property and authentication
(uptimeKumaService as any).socket = mockSocket;
(uptimeKumaService as any).isAuthenticated = true;
expect(uptimeKumaService.isConnected()).toBe(true);
// Simulate disconnection
mockSocket.connected = false;
expect(uptimeKumaService.isConnected()).toBe(false);
});
test('should handle force reconnect', async () => {
// Mock the connect method to avoid socket.once issues
const mockConnect = jest.fn().mockResolvedValue(undefined);
(uptimeKumaService as any).connect = mockConnect;
// Should not throw error even when not connected
await expect(uptimeKumaService.forceReconnect()).resolves.not.toThrow();
expect(mockConnect).toHaveBeenCalled();
});
test('should handle disconnect', () => {
// Should not throw error
expect(() => uptimeKumaService.disconnect()).not.toThrow();
});
test('should get monitor stats', () => {
const stats = uptimeKumaService.getMonitorStats();
expect(stats).toBeDefined();
expect(Array.isArray(stats)).toBe(true);
});
});