import { describe, expect, test, mock, beforeEach, afterAll } from "bun:test"; // Mock child_process BEFORE importing the service const mockExec = mock((cmd: string, callback?: any) => { // Handle calls without callback (like exec().unref()) if (!callback) { return { unref: () => { } }; } // Simulate successful command execution let stdout = ""; if (cmd.includes("git rev-parse --abbrev-ref")) { stdout = "main\n"; } else if (cmd.includes("git rev-parse --short")) { stdout = "abc1234\n"; } else if (cmd.includes("git rev-parse HEAD")) { stdout = "abc1234567890\n"; } else if (cmd.includes("git fetch")) { stdout = ""; } else if (cmd.includes("git log")) { stdout = "abcdef|Update 1|Author1\n123456|Update 2|Author2"; } else if (cmd.includes("git diff")) { stdout = "package.json\nsrc/index.ts\nshared/lib/schema.ts"; } else if (cmd.includes("git reset")) { stdout = "HEAD is now at abcdef Update 1"; } else if (cmd.includes("bun install")) { stdout = "Installed dependencies"; } else if (cmd.includes("drizzle-kit migrate")) { stdout = "Migrations applied"; } else if (cmd.includes("bun run build")) { stdout = "Build completed"; } callback(null, stdout, ""); }); mock.module("child_process", () => ({ exec: mockExec })); // Mock fs/promises const mockWriteFile = mock((_path: string, _content: string) => Promise.resolve()); const mockReadFile = mock((_path: string, _encoding: string) => Promise.resolve("{}")); const mockUnlink = mock((_path: string) => Promise.resolve()); mock.module("fs/promises", () => ({ writeFile: mockWriteFile, readFile: mockReadFile, unlink: mockUnlink })); // Mock view module to avoid import issues mock.module("@/modules/admin/update.view", () => ({ getPostRestartEmbed: () => ({ embeds: [{ title: "Update Complete" }], components: [] }), getPostRestartProgressEmbed: () => ({ title: "Progress..." }), })); describe("UpdateService", () => { let UpdateService: any; beforeEach(async () => { mockExec.mockClear(); mockWriteFile.mockClear(); mockReadFile.mockClear(); mockUnlink.mockClear(); // Dynamically import to ensure mock is used const module = await import("./update.service"); UpdateService = module.UpdateService; }); afterAll(() => { mock.restore(); }); describe("checkForUpdates", () => { test("should return updates if git log has output", async () => { const result = await UpdateService.checkForUpdates(); expect(result.hasUpdates).toBe(true); expect(result.branch).toBe("main"); expect(result.commits.length).toBeGreaterThan(0); expect(result.commits[0].message).toContain("Update 1"); }); test("should call git rev-parse, fetch, and log commands", async () => { await UpdateService.checkForUpdates(); const calls = mockExec.mock.calls.map((c: any) => c[0]); expect(calls.some((cmd: string) => cmd.includes("git rev-parse"))).toBe(true); expect(calls.some((cmd: string) => cmd.includes("git fetch"))).toBe(true); expect(calls.some((cmd: string) => cmd.includes("git log"))).toBe(true); }); test("should include requirements in the response", async () => { const result = await UpdateService.checkForUpdates(); expect(result.requirements).toBeDefined(); expect(result.requirements.needsRootInstall).toBe(true); // package.json is in mock expect(result.requirements.needsMigrations).toBe(true); // schema.ts is in mock expect(result.requirements.changedFiles).toContain("package.json"); }); }); describe("performUpdate", () => { test("should run git reset --hard with correct branch", async () => { await UpdateService.performUpdate("main"); const calls = mockExec.mock.calls.map((c: any) => c[0]); expect(calls.some((cmd: string) => cmd.includes("git reset --hard origin/main"))).toBe(true); }); }); describe("checkUpdateRequirements (deprecated)", () => { test("should detect package.json and schema.ts changes", async () => { const result = await UpdateService.checkUpdateRequirements("main"); expect(result.needsRootInstall).toBe(true); expect(result.needsMigrations).toBe(true); expect(result.error).toBeUndefined(); }); test("should call git diff with correct branch", async () => { await UpdateService.checkUpdateRequirements("develop"); const calls = mockExec.mock.calls.map((c: any) => c[0]); expect(calls.some((cmd: string) => cmd.includes("git diff HEAD..origin/develop"))).toBe(true); }); }); describe("installDependencies", () => { test("should run bun install for root only", async () => { const output = await UpdateService.installDependencies({ root: true, web: false }); expect(output).toContain("Root"); const calls = mockExec.mock.calls.map((c: any) => c[0]); expect(calls.some((cmd: string) => cmd === "bun install")).toBe(true); }); test("should run bun install for both root and web in parallel", async () => { const output = await UpdateService.installDependencies({ root: true, web: true }); expect(output).toContain("Root"); expect(output).toContain("Web"); const calls = mockExec.mock.calls.map((c: any) => c[0]); expect(calls.some((cmd: string) => cmd === "bun install")).toBe(true); expect(calls.some((cmd: string) => cmd.includes("cd web && bun install"))).toBe(true); }); }); describe("categorizeChanges", () => { test("should categorize files correctly", () => { const files = [ "bot/commands/admin/update.ts", "bot/modules/admin/update.view.ts", "web/src/components/Button.tsx", "shared/lib/utils.ts", "package.json", "drizzle/0001_migration.sql" ]; const categories = UpdateService.categorizeChanges(files); expect(categories["Commands"]).toBe(1); expect(categories["Modules"]).toBe(1); expect(categories["Web Dashboard"]).toBe(1); expect(categories["Library"]).toBe(1); expect(categories["Dependencies"]).toBe(1); expect(categories["Database"]).toBe(1); }); }); describe("prepareRestartContext", () => { test("should write context to file", async () => { const context = { channelId: "123", userId: "456", timestamp: Date.now(), runMigrations: true, installDependencies: false, buildWebAssets: false, previousCommit: "abc1234", newCommit: "def5678" }; await UpdateService.prepareRestartContext(context); expect(mockWriteFile).toHaveBeenCalled(); const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined; expect(lastCall).toBeDefined(); expect(lastCall![0]).toContain("restart_context"); expect(JSON.parse(lastCall![1])).toEqual(context); }); }); describe("saveRollbackPoint", () => { test("should save current commit hash to file", async () => { const commit = await UpdateService.saveRollbackPoint(); expect(commit).toBeTruthy(); expect(mockWriteFile).toHaveBeenCalled(); const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined; expect(lastCall![0]).toContain("rollback_commit"); }); }); describe("hasRollbackPoint", () => { test("should return true when rollback file exists", async () => { mockReadFile.mockImplementationOnce(() => Promise.resolve("abc123")); // Clear cache first (UpdateService as any).rollbackPointExists = null; const result = await UpdateService.hasRollbackPoint(); expect(result).toBe(true); }); test("should return false when rollback file does not exist", async () => { mockReadFile.mockImplementationOnce(() => Promise.reject(new Error("ENOENT"))); // Clear cache first (UpdateService as any).rollbackPointExists = null; const result = await UpdateService.hasRollbackPoint(); expect(result).toBe(false); }); }); describe("triggerRestart", () => { test("should use RESTART_COMMAND env var when set", async () => { const originalEnv = process.env.RESTART_COMMAND; process.env.RESTART_COMMAND = "pm2 restart bot"; await UpdateService.triggerRestart(); const calls = mockExec.mock.calls.map((c: any) => c[0]); expect(calls.some((cmd: string) => cmd === "pm2 restart bot")).toBe(true); process.env.RESTART_COMMAND = originalEnv; }); test("should call process.exit when no env var is set", async () => { const originalEnv = process.env.RESTART_COMMAND; delete process.env.RESTART_COMMAND; // Just verify it doesn't throw - actual process.exit is mocked by setTimeout await UpdateService.triggerRestart(); process.env.RESTART_COMMAND = originalEnv; }); }); describe("handlePostRestart", () => { const createMockClient = (channel: any = null) => ({ channels: { fetch: mock(() => Promise.resolve(channel)) } }); const createMockChannel = () => ({ isSendable: () => true, send: mock(() => Promise.resolve({ edit: mock(() => Promise.resolve()), delete: mock(() => Promise.resolve()) })) }); test("should ignore stale context (>10 mins old)", async () => { const staleContext = { channelId: "123", userId: "456", timestamp: Date.now() - (15 * 60 * 1000), // 15 mins ago runMigrations: true, installDependencies: true, buildWebAssets: false, previousCommit: "abc", newCommit: "def" }; mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(staleContext))); const mockChannel = createMockChannel(); // Create mock with instanceof support const channel = Object.assign(mockChannel, { constructor: { name: "TextChannel" } }); Object.setPrototypeOf(channel, Object.create({ constructor: { name: "TextChannel" } })); const mockClient = createMockClient(channel); await UpdateService.handlePostRestart(mockClient); // Should not send any message for stale context expect(mockChannel.send).not.toHaveBeenCalled(); // Should clean up the context file expect(mockUnlink).toHaveBeenCalled(); }); test("should do nothing if no context file exists", async () => { mockReadFile.mockImplementationOnce(() => Promise.reject(new Error("ENOENT"))); const mockClient = createMockClient(); await UpdateService.handlePostRestart(mockClient); // Should not throw and not try to clean up expect(mockUnlink).not.toHaveBeenCalled(); }); test("should clean up context file after processing", async () => { const validContext = { channelId: "123", userId: "456", timestamp: Date.now(), runMigrations: false, installDependencies: false, buildWebAssets: false, previousCommit: "abc", newCommit: "def" }; mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(validContext))); // Create a proper TextChannel mock const { TextChannel } = await import("discord.js"); const mockChannel = Object.create(TextChannel.prototype); mockChannel.isSendable = () => true; mockChannel.send = mock(() => Promise.resolve({ edit: mock(() => Promise.resolve()), delete: mock(() => Promise.resolve()) })); const mockClient = createMockClient(mockChannel); await UpdateService.handlePostRestart(mockClient); expect(mockUnlink).toHaveBeenCalled(); }); }); });