diff --git a/src/commands/admin/health.test.ts b/src/commands/admin/health.test.ts new file mode 100644 index 0000000..dcd86fd --- /dev/null +++ b/src/commands/admin/health.test.ts @@ -0,0 +1,84 @@ +import { describe, test, expect, mock, beforeEach } from "bun:test"; +import { health } from "./health"; +import { ChatInputCommandInteraction, Colors } from "discord.js"; +import { AuroraClient } from "@/lib/BotClient"; + +// Mock DrizzleClient +const executeMock = mock(() => Promise.resolve()); +mock.module("@/lib/DrizzleClient", () => ({ + DrizzleClient: { + execute: executeMock + } +})); + +// Mock BotClient (already has lastCommandTimestamp if imported, but we might want to control it) +AuroraClient.lastCommandTimestamp = 1641481200000; // Fixed timestamp for testing + +describe("Health Command", () => { + beforeEach(() => { + executeMock.mockClear(); + }); + + test("should execute successfully and return health embed", async () => { + const interaction = { + deferReply: mock(() => Promise.resolve()), + editReply: mock(() => Promise.resolve()), + client: { + ws: { + ping: 42 + } + }, + user: { id: "123", username: "testuser" }, + commandName: "health" + } as unknown as ChatInputCommandInteraction; + + await health.execute(interaction); + + expect(interaction.deferReply).toHaveBeenCalled(); + expect(executeMock).toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalled(); + + const editReplyCall = (interaction.editReply as any).mock.calls[0][0]; + const embed = editReplyCall.embeds[0]; + + expect(embed.data.title).toBe("System Health Status"); + expect(embed.data.color).toBe(Colors.Aqua); + + // Check fields + const fields = embed.data.fields; + expect(fields).toBeDefined(); + + // Connectivity field + const connectivityField = fields.find((f: any) => f.name === "📡 Connectivity"); + expect(connectivityField.value).toContain("42ms"); + expect(connectivityField.value).toContain("Connected"); + + // Activity field + const activityField = fields.find((f: any) => f.name === "⌨️ Activity"); + expect(activityField.value).toContain("R>"); // Relative Discord timestamp + }); + + test("should handle database disconnection", async () => { + executeMock.mockImplementationOnce(() => Promise.reject(new Error("DB Down"))); + + const interaction = { + deferReply: mock(() => Promise.resolve()), + editReply: mock(() => Promise.resolve()), + client: { + ws: { + ping: 42 + } + }, + user: { id: "123", username: "testuser" }, + commandName: "health" + } as unknown as ChatInputCommandInteraction; + + await health.execute(interaction); + + const editReplyCall = (interaction.editReply as any).mock.calls[0][0]; + const embed = editReplyCall.embeds[0]; + const connectivityField = embed.data.fields.find((f: any) => f.name === "📡 Connectivity"); + + expect(connectivityField.value).toContain("Disconnected"); + }); +}); diff --git a/src/commands/admin/health.ts b/src/commands/admin/health.ts new file mode 100644 index 0000000..6835cf4 --- /dev/null +++ b/src/commands/admin/health.ts @@ -0,0 +1,60 @@ +import { createCommand } from "@lib/utils"; +import { AuroraClient } from "@/lib/BotClient"; +import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, Colors } from "discord.js"; +import { DrizzleClient } from "@/lib/DrizzleClient"; +import { sql } from "drizzle-orm"; +import { createBaseEmbed } from "@lib/embeds"; + +export const health = createCommand({ + data: new SlashCommandBuilder() + .setName("health") + .setDescription("Check the bot's health status") + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + execute: async (interaction) => { + await interaction.deferReply(); + + // 1. Check Discord API latency + const wsPing = interaction.client.ws.ping; + + // 2. Verify database connection + let dbStatus = "Connected"; + let dbPing = -1; + try { + const start = Date.now(); + await DrizzleClient.execute(sql`SELECT 1`); + dbPing = Date.now() - start; + } catch (error) { + dbStatus = "Disconnected"; + console.error("Health check DB error:", error); + } + + // 3. Uptime + const uptime = process.uptime(); + const days = Math.floor(uptime / 86400); + const hours = Math.floor((uptime % 86400) / 3600); + const minutes = Math.floor((uptime % 3600) / 60); + const seconds = Math.floor(uptime % 60); + const uptimeString = `${days}d ${hours}h ${minutes}m ${seconds}s`; + + // 4. Memory usage + const memory = process.memoryUsage(); + const heapUsed = (memory.heapUsed / 1024 / 1024).toFixed(2); + const heapTotal = (memory.heapTotal / 1024 / 1024).toFixed(2); + const rss = (memory.rss / 1024 / 1024).toFixed(2); + + // 5. Last successful command + const lastCommand = AuroraClient.lastCommandTimestamp + ? `` + : "None since startup"; + + const embed = createBaseEmbed("System Health Status", undefined, Colors.Aqua) + .addFields( + { name: "📡 Connectivity", value: `**Discord WS:** ${wsPing}ms\n**Database:** ${dbStatus} ${dbPing >= 0 ? `(${dbPing}ms)` : ""}`, inline: true }, + { name: "⏱️ Uptime", value: uptimeString, inline: true }, + { name: "🧠 Memory Usage", value: `**RSS:** ${rss} MB\n**Heap:** ${heapUsed} / ${heapTotal} MB`, inline: false }, + { name: "⌨️ Activity", value: `**Last Command:** ${lastCommand}`, inline: true } + ); + + await interaction.editReply({ embeds: [embed] }); + } +}); diff --git a/src/lib/BotClient.ts b/src/lib/BotClient.ts index b883b54..d3a0a9a 100644 --- a/src/lib/BotClient.ts +++ b/src/lib/BotClient.ts @@ -9,6 +9,7 @@ import { logger } from "@lib/logger"; export class Client extends DiscordClient { commands: Collection; + lastCommandTimestamp: number | null = null; private commandLoader: CommandLoader; private eventLoader: EventLoader; diff --git a/src/lib/handlers/CommandHandler.test.ts b/src/lib/handlers/CommandHandler.test.ts new file mode 100644 index 0000000..d56906c --- /dev/null +++ b/src/lib/handlers/CommandHandler.test.ts @@ -0,0 +1,59 @@ +import { describe, test, expect, mock, beforeEach } from "bun:test"; +import { CommandHandler } from "./CommandHandler"; +import { AuroraClient } from "@/lib/BotClient"; +import { ChatInputCommandInteraction } from "discord.js"; + +// Mock UserService +mock.module("@/modules/user/user.service", () => ({ + userService: { + getOrCreateUser: mock(() => Promise.resolve()) + } +})); + +describe("CommandHandler", () => { + beforeEach(() => { + AuroraClient.commands.clear(); + AuroraClient.lastCommandTimestamp = null; + }); + + test("should update lastCommandTimestamp on successful execution", async () => { + const executeSuccess = mock(() => Promise.resolve()); + AuroraClient.commands.set("test", { + data: { name: "test" } as any, + execute: executeSuccess + } as any); + + const interaction = { + commandName: "test", + user: { id: "123", username: "testuser" } + } as unknown as ChatInputCommandInteraction; + + await CommandHandler.handle(interaction); + + expect(executeSuccess).toHaveBeenCalled(); + expect(AuroraClient.lastCommandTimestamp).not.toBeNull(); + expect(AuroraClient.lastCommandTimestamp).toBeGreaterThan(0); + }); + + test("should not update lastCommandTimestamp on failed execution", async () => { + const executeError = mock(() => Promise.reject(new Error("Command Failed"))); + AuroraClient.commands.set("fail", { + data: { name: "fail" } as any, + execute: executeError + } as any); + + const interaction = { + commandName: "fail", + user: { id: "123", username: "testuser" }, + replied: false, + deferred: false, + reply: mock(() => Promise.resolve()), + followUp: mock(() => Promise.resolve()) + } as unknown as ChatInputCommandInteraction; + + await CommandHandler.handle(interaction); + + expect(executeError).toHaveBeenCalled(); + expect(AuroraClient.lastCommandTimestamp).toBeNull(); + }); +}); diff --git a/src/lib/handlers/CommandHandler.ts b/src/lib/handlers/CommandHandler.ts index 0c82411..61f7210 100644 --- a/src/lib/handlers/CommandHandler.ts +++ b/src/lib/handlers/CommandHandler.ts @@ -26,6 +26,7 @@ export class CommandHandler { try { await command.execute(interaction); + AuroraClient.lastCommandTimestamp = Date.now(); } catch (error) { logger.error(String(error)); const errorEmbed = createErrorEmbed('There was an error while executing this command!');