/** * Unit tests for DIS-151: channel categorization + lifecycle management * * Covers reconcile(), handleChannelDelete(), ensureCategories() with * mocked node:fs and Discord.js Guild objects. * * All path comparisons use path.join() so tests pass on Windows and Unix. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { MockInstance } from "vitest"; import * as path from "node:path"; // --------------------------------------------------------------------------- // Mock node:fs β€” must be hoisted before any imports that use it // --------------------------------------------------------------------------- vi.mock("node:fs", () => { return { existsSync: vi.fn(() => false), readdirSync: vi.fn(() => [] as string[]), statSync: vi.fn(() => ({ isDirectory: () => true })), readFileSync: vi.fn(() => ""), rmSync: vi.fn(), mkdirSync: vi.fn(), writeFileSync: vi.fn(), }; }); import * as nodefs from "node:fs"; // --------------------------------------------------------------------------- // Helpers to build mock Discord structures // --------------------------------------------------------------------------- /** A Collection-like Map that also has Discord.js Collection.find() */ function makeCollection(entries: [string, V][] = []) { const m = new Map(entries); (m as unknown as { find: (fn: (v: V) => boolean) => V | undefined }).find = (fn: (v: V) => boolean) => { for (const v of m.values()) { if (fn(v)) return v; } return undefined; }; return m as Map & { find: (fn: (v: V) => boolean) => V | undefined }; } type AnyChannelObj = { id: string; name: string; type: number; setParent: ReturnType; guild?: unknown; }; function makeCategoryChannel(id: string, name: string): AnyChannelObj { return { id, name, type: 4, // ChannelType.GuildCategory = 4 setParent: vi.fn().mockResolvedValue(undefined), }; } function makeTextChannel(id: string, name: string): AnyChannelObj { return { id, name, type: 0, // ChannelType.GuildText = 0 setParent: vi.fn().mockResolvedValue(undefined), }; } function makeGuild(channels: AnyChannelObj[]) { const cache = makeCollection(channels.map((c) => [c.id, c] as [string, AnyChannelObj])); const guild: { id: string; name: string; channels: { cache: typeof cache; fetch: ReturnType; create: ReturnType; }; } = { id: "guild-1", name: "Test Guild", channels: { cache, fetch: vi.fn().mockResolvedValue(cache), create: vi.fn().mockImplementation(({ name, type }: { name: string; type: number }) => { const newId = `created-${name}`; let ch: AnyChannelObj; if (type === 4) { ch = makeCategoryChannel(newId, name); } else { ch = makeTextChannel(newId, name); } ch.guild = guild; cache.set(newId, ch); return Promise.resolve(ch); }), }, }; // Attach guild reference to all channels so moveToCategory can access cache for (const ch of channels) { ch.guild = guild; } return guild; } type MockGuild = ReturnType; function makeDb(workspaces: Array<{ id: number; channel_id: string; agent_name: string; workspace_path: string; guild_id?: string; created_at?: string }>) { const rows = workspaces.map((w) => ({ id: w.id, channel_id: w.channel_id, guild_id: w.guild_id ?? "guild-1", agent_name: w.agent_name, workspace_path: w.workspace_path, created_at: w.created_at ?? "2025-01-01T00:00:00Z", })); return { getAllWorkspaces: vi.fn(() => [...rows]), getWorkspaceByChannelId: vi.fn((channelId: string) => rows.find((r) => r.channel_id === channelId) ), getWorkspaceByName: vi.fn((name: string) => rows.find((r) => r.agent_name === name) ), createWorkspace: vi.fn(), deleteWorkspaceByChannelId: vi.fn((channelId: string) => { const idx = rows.findIndex((r) => r.channel_id === channelId); if (idx === -1) return false; rows.splice(idx, 1); return true; }), addConversation: vi.fn(), getConversationHistory: vi.fn(() => []), close: vi.fn(), }; } // --------------------------------------------------------------------------- // Import modules under test (after mocks are registered) // --------------------------------------------------------------------------- import { reconcile } from "../../src/lifecycle/reconcile"; import { handleChannelDelete } from "../../src/lifecycle/channel-delete-handler"; import { ensureCategories } from "../../src/lifecycle/categories"; // Helpers for typed mock access const fsMock = nodefs as unknown as { existsSync: MockInstance; readdirSync: MockInstance; statSync: MockInstance; readFileSync: MockInstance; rmSync: MockInstance; }; // --------------------------------------------------------------------------- // Shared category setup β€” categories share guild's cache // --------------------------------------------------------------------------- function makeCategories(guild: MockGuild) { const active = makeCategoryChannel("cat-active", "🟒 Aktive Agents"); const inactive = makeCategoryChannel("cat-inactive", "πŸ”΄ Inaktive Agents"); const archive = makeCategoryChannel("cat-archive", "πŸ—„οΈ Archiv"); active.guild = guild; inactive.guild = guild; archive.guild = guild; // Add to guild cache so the category lookup in moveToCategory works guild.channels.cache.set(active.id, active); guild.channels.cache.set(inactive.id, inactive); guild.channels.cache.set(archive.id, archive); return { active, inactive, archive } as unknown as Awaited>; } const WORKSPACES_ROOT = path.resolve("/fake/workspaces"); const MGMT_CHANNEL_ID = "mgmt-111"; beforeEach(() => { vi.clearAllMocks(); // Default: workspacesRoot does not exist fsMock.existsSync.mockReturnValue(false); fsMock.readdirSync.mockReturnValue([]); }); // =========================================================================== // reconcile() tests // =========================================================================== describe("reconcile()", () => { // ------------------------------------------------------------------------- // Test 1: aktiv β€” DB entry + workspace dir + Discord channel all exist // ------------------------------------------------------------------------- it("1: aktiv: DB + Workspace + Channel β†’ moveToCategory(active) aufgerufen", async () => { const agentCh = makeTextChannel("ch-agent-1", "my-agent"); const guild = makeGuild([agentCh]); const categories = makeCategories(guild); const wsPath = path.join(WORKSPACES_ROOT, "my-agent"); const db = makeDb([ { id: 1, channel_id: "ch-agent-1", agent_name: "my-agent", workspace_path: wsPath }, ]); const yamlPath = path.join(WORKSPACES_ROOT, "my-agent", "agent.yaml"); fsMock.existsSync.mockImplementation((p: string) => { if (p === WORKSPACES_ROOT) return true; if (p === wsPath) return true; if (p === yamlPath) return true; return false; }); fsMock.readdirSync.mockReturnValue(["my-agent"]); fsMock.statSync.mockReturnValue({ isDirectory: () => true }); fsMock.readFileSync.mockReturnValue('name: my-agent\nchannel_id: "ch-agent-1"\n'); const result = await reconcile( guild as unknown as Parameters[0], db as unknown as Parameters[1], categories, WORKSPACES_ROOT, MGMT_CHANNEL_ID ); expect(result.active).toContain("ch-agent-1"); expect(agentCh.setParent).toHaveBeenCalledWith(categories.active.id); }); // ------------------------------------------------------------------------- // Test 2: inaktiv β€” DB entry exists but channel is NOT in guild // ------------------------------------------------------------------------- it("2: inaktiv: DB + kein Channel β†’ in result.inactive", async () => { const guild = makeGuild([]); const categories = makeCategories(guild); const wsPath = path.join(WORKSPACES_ROOT, "ghost-agent"); const db = makeDb([ { id: 1, channel_id: "ch-ghost", agent_name: "ghost-agent", workspace_path: wsPath }, ]); fsMock.existsSync.mockReturnValue(false); const result = await reconcile( guild as unknown as Parameters[0], db as unknown as Parameters[1], categories, WORKSPACES_ROOT, MGMT_CHANNEL_ID ); expect(result.inactive).toContain("ch-ghost"); expect(result.active).not.toContain("ch-ghost"); }); // ------------------------------------------------------------------------- // Test 3: inaktiv β€” workspace dir with agent.yaml exists but no DB entry // and channel IS in guild // ------------------------------------------------------------------------- it("3: inaktiv: Workspace + kein DB β†’ moveToCategory(inactive) aufgerufen", async () => { const agentCh = makeTextChannel("ch-orphan", "orphan-agent"); const guild = makeGuild([agentCh]); const categories = makeCategories(guild); const db = makeDb([]); const yamlPath = path.join(WORKSPACES_ROOT, "orphan-agent", "agent.yaml"); fsMock.existsSync.mockImplementation((p: string) => { if (p === WORKSPACES_ROOT) return true; if (p === yamlPath) return true; return false; }); fsMock.readdirSync.mockReturnValue(["orphan-agent"]); fsMock.statSync.mockReturnValue({ isDirectory: () => true }); fsMock.readFileSync.mockReturnValue('name: orphan-agent\nchannel_id: "ch-orphan"\n'); const result = await reconcile( guild as unknown as Parameters[0], db as unknown as Parameters[1], categories, WORKSPACES_ROOT, MGMT_CHANNEL_ID ); expect(result.inactive).toContain("ch-orphan"); expect(agentCh.setParent).toHaveBeenCalledWith(categories.inactive.id); }); // ------------------------------------------------------------------------- // Test 4: archiv β€” text channel in guild with no DB entry and no workspace // ------------------------------------------------------------------------- it("4: archiv: Channel ohne DB und Workspace β†’ moveToCategory(archive) aufgerufen", async () => { const randomCh = makeTextChannel("ch-random", "random-channel"); const guild = makeGuild([randomCh]); const categories = makeCategories(guild); const db = makeDb([]); fsMock.existsSync.mockReturnValue(false); fsMock.readdirSync.mockReturnValue([]); const result = await reconcile( guild as unknown as Parameters[0], db as unknown as Parameters[1], categories, WORKSPACES_ROOT, MGMT_CHANNEL_ID ); expect(result.archived).toContain("ch-random"); expect(randomCh.setParent).toHaveBeenCalledWith(categories.archive.id); }); // ------------------------------------------------------------------------- // Test 5: cleanup β€” workspace dir exists but no channel and no DB entry // β†’ directory is deleted // ------------------------------------------------------------------------- it("5: cleanup: Workspace ohne Channel und DB β†’ fs.rmSync aufgerufen", async () => { const guild = makeGuild([]); const categories = makeCategories(guild); const db = makeDb([]); const deadDir = path.join(WORKSPACES_ROOT, "dead-agent"); const deadYaml = path.join(WORKSPACES_ROOT, "dead-agent", "agent.yaml"); fsMock.existsSync.mockImplementation((p: string) => { if (p === WORKSPACES_ROOT) return true; if (p === deadYaml) return true; return false; }); fsMock.readdirSync.mockReturnValue(["dead-agent"]); fsMock.statSync.mockReturnValue({ isDirectory: () => true }); fsMock.readFileSync.mockReturnValue('name: dead-agent\nchannel_id: "ch-dead"\n'); const result = await reconcile( guild as unknown as Parameters[0], db as unknown as Parameters[1], categories, WORKSPACES_ROOT, MGMT_CHANNEL_ID ); expect(fsMock.rmSync).toHaveBeenCalledWith( deadDir, expect.objectContaining({ recursive: true }) ); expect(result.deletedWorkspaces).toContain("dead-agent"); }); // ------------------------------------------------------------------------- // Test 6: management channel is never categorized // ------------------------------------------------------------------------- it("6: management-channel ΓΌbersprungen β€” wird nicht kategorisiert", async () => { const mgmtCh = makeTextChannel(MGMT_CHANNEL_ID, "disclaw"); const guild = makeGuild([mgmtCh]); const categories = makeCategories(guild); const db = makeDb([]); fsMock.existsSync.mockReturnValue(false); const result = await reconcile( guild as unknown as Parameters[0], db as unknown as Parameters[1], categories, WORKSPACES_ROOT, MGMT_CHANNEL_ID ); expect(result.archived).not.toContain(MGMT_CHANNEL_ID); expect(result.active).not.toContain(MGMT_CHANNEL_ID); expect(result.inactive).not.toContain(MGMT_CHANNEL_ID); expect(mgmtCh.setParent).not.toHaveBeenCalled(); }); // ------------------------------------------------------------------------- // Test 7: category channels themselves are never archived // ------------------------------------------------------------------------- it("7: kategorie-channels ΓΌbersprungen β€” nicht als archiv eingestuft", async () => { const guild = makeGuild([]); const categories = makeCategories(guild); // categories are already in guild.channels.cache via makeCategories() const db = makeDb([]); fsMock.existsSync.mockReturnValue(false); const result = await reconcile( guild as unknown as Parameters[0], db as unknown as Parameters[1], categories, WORKSPACES_ROOT, MGMT_CHANNEL_ID ); expect(result.archived).not.toContain(categories.active.id); expect(result.archived).not.toContain(categories.inactive.id); expect(result.archived).not.toContain(categories.archive.id); }); // ------------------------------------------------------------------------- // Test 8: empty workspacesRoot β€” no error, empty result // ------------------------------------------------------------------------- it("8: leerer workspaces_root β€” kein Fehler, leeres Result", async () => { const guild = makeGuild([]); const categories = makeCategories(guild); const db = makeDb([]); fsMock.existsSync.mockReturnValue(false); const result = await reconcile( guild as unknown as Parameters[0], db as unknown as Parameters[1], categories, WORKSPACES_ROOT, MGMT_CHANNEL_ID ); expect(result.active).toHaveLength(0); expect(result.inactive).toHaveLength(0); expect(result.archived).toHaveLength(0); expect(result.deletedWorkspaces).toHaveLength(0); }); }); // =========================================================================== // handleChannelDelete() tests // =========================================================================== describe("handleChannelDelete()", () => { function makeConfig() { return { management_channel: "disclaw", workspaces_root: WORKSPACES_ROOT, claude_command: "claude", claude_timeout_seconds: 120, max_concurrent_agents: 4, env_blocklist: [] as string[], }; } // ------------------------------------------------------------------------- // Test 9: Agent channel deleted β†’ DB + workspace removed // ------------------------------------------------------------------------- it("9: channelDelete: Agent-Channel β†’ DB + Workspace gelΓΆscht", async () => { const agentCh = makeTextChannel("ch-del-agent", "my-agent"); const guild = makeGuild([agentCh]); const categories = makeCategories(guild); const wsPath = path.join(WORKSPACES_ROOT, "my-agent"); const db = makeDb([ { id: 1, channel_id: "ch-del-agent", agent_name: "my-agent", workspace_path: wsPath }, ]); fsMock.existsSync.mockReturnValue(true); await handleChannelDelete(agentCh as unknown as Parameters[0], { db: db as unknown as Parameters[1]["db"], config: makeConfig() as unknown as Parameters[1]["config"], guild: guild as unknown as Parameters[1]["guild"], categories, managementChannelId: MGMT_CHANNEL_ID, setManagementChannelId: vi.fn(), setCategories: vi.fn(), }); expect(db.deleteWorkspaceByChannelId).toHaveBeenCalledWith("ch-del-agent"); expect(fsMock.rmSync).toHaveBeenCalledWith( wsPath, expect.objectContaining({ recursive: true }) ); }); // ------------------------------------------------------------------------- // Test 10: Management channel deleted β†’ new channel created, state updated // ------------------------------------------------------------------------- it("10: channelDelete: Management-Channel β†’ neuer Channel erstellt", async () => { const mgmtCh = makeTextChannel(MGMT_CHANNEL_ID, "disclaw"); const guild = makeGuild([]); const categories = makeCategories(guild); const db = makeDb([ { id: 99, channel_id: MGMT_CHANNEL_ID, agent_name: "disclaw", workspace_path: "__management__" }, ]); const setManagementChannelId = vi.fn(); await handleChannelDelete(mgmtCh as unknown as Parameters[0], { db: db as unknown as Parameters[1]["db"], config: makeConfig() as unknown as Parameters[1]["config"], guild: guild as unknown as Parameters[1]["guild"], categories, managementChannelId: MGMT_CHANNEL_ID, setManagementChannelId, setCategories: vi.fn(), }); expect(guild.channels.create).toHaveBeenCalledWith( expect.objectContaining({ name: "disclaw" }) ); expect(setManagementChannelId).toHaveBeenCalled(); expect(db.createWorkspace).toHaveBeenCalled(); }); // ------------------------------------------------------------------------- // Test 11: Lifecycle category channel deleted β†’ ensureCategories called again // ------------------------------------------------------------------------- it("11: channelDelete: Kategorie-Channel β†’ ensureCategories erneut aufgerufen", async () => { const guild = makeGuild([]); const categories = makeCategories(guild); // Simulate deletion of the active category channel const deletedCategoryChannel = makeTextChannel(categories.active.id, "🟒 Aktive Agents"); // Add replacement category channels so ensureCategories finds them const newActive = makeCategoryChannel("cat-active-v2", "🟒 Aktive Agents"); const newInactive = makeCategoryChannel("cat-inactive", "πŸ”΄ Inaktive Agents"); const newArchive = makeCategoryChannel("cat-archive", "πŸ—„οΈ Archiv"); newActive.guild = guild; newInactive.guild = guild; newArchive.guild = guild; guild.channels.cache.set("cat-active-v2", newActive); guild.channels.cache.set("cat-inactive", newInactive); guild.channels.cache.set("cat-archive", newArchive); const db = makeDb([]); const setCategories = vi.fn(); await handleChannelDelete(deletedCategoryChannel as unknown as Parameters[0], { db: db as unknown as Parameters[1]["db"], config: makeConfig() as unknown as Parameters[1]["config"], guild: guild as unknown as Parameters[1]["guild"], categories, managementChannelId: MGMT_CHANNEL_ID, setManagementChannelId: vi.fn(), setCategories, }); expect(setCategories).toHaveBeenCalled(); }); }); // =========================================================================== // ensureCategories() tests // =========================================================================== describe("ensureCategories()", () => { // ------------------------------------------------------------------------- // Test 12: All three categories missing β†’ all three created // ------------------------------------------------------------------------- it("12: ensureCategories: alle fehlen β†’ 3 Kategorien erstellt", async () => { const guild = makeGuild([]); const result = await ensureCategories(guild as unknown as Parameters[0]); expect(guild.channels.create).toHaveBeenCalledTimes(3); expect(result.active).toBeDefined(); expect(result.inactive).toBeDefined(); expect(result.archive).toBeDefined(); }); // ------------------------------------------------------------------------- // Test 13: Some categories present β†’ only missing ones created // ------------------------------------------------------------------------- it("13: ensureCategories: teilweise vorhanden β†’ nur fehlende erstellt", async () => { const existingActive = makeCategoryChannel("existing-active", "🟒 Aktive Agents"); const existingArchive = makeCategoryChannel("existing-archive", "πŸ—„οΈ Archiv"); const guild = makeGuild([existingActive, existingArchive]); const result = await ensureCategories(guild as unknown as Parameters[0]); // Only the inactive category should be created expect(guild.channels.create).toHaveBeenCalledTimes(1); expect(guild.channels.create).toHaveBeenCalledWith( expect.objectContaining({ name: "πŸ”΄ Inaktive Agents" }) ); expect(result.active.id).toBe("existing-active"); expect(result.archive.id).toBe("existing-archive"); expect(result.inactive).toBeDefined(); }); });