disclaw/tests/unit/reconcile.test.ts
Nick Tabeling 56b861f34c
Some checks failed
CI / build-and-test (ubuntu-latest) (pull_request) Has been cancelled
CI / build-and-test (windows-latest) (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
feat(DIS-151): channel categorization + lifecycle management
- Add deleteWorkspaceByChannelId() to DisclawDatabase
- Add src/lifecycle/categories.ts: ensureCategories() + moveToCategory()
- Add src/lifecycle/reconcile.ts: reconcile() assigns channels to active/inactive/archive categories on startup
- Add src/lifecycle/channel-delete-handler.ts: handles channelDelete events for management, category, and agent channels
- Wire lifecycle into bot.ts: state object, ensureCategories, reconcile, channelDelete listener
- Extend handleNewAgent() with optional activeCategoryId to place new channels in the active category
- Add 13 unit tests covering all lifecycle scenarios

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:43:32 +02:00

582 lines
22 KiB
TypeScript

/**
* 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<V>(entries: [string, V][] = []) {
const m = new Map<string, V>(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<string, V> & { find: (fn: (v: V) => boolean) => V | undefined };
}
type AnyChannelObj = {
id: string;
name: string;
type: number;
setParent: ReturnType<typeof vi.fn>;
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<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
};
} = {
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<typeof makeGuild>;
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<ReturnType<typeof ensureCategories>>;
}
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<typeof reconcile>[0],
db as unknown as Parameters<typeof reconcile>[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<typeof reconcile>[0],
db as unknown as Parameters<typeof reconcile>[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<typeof reconcile>[0],
db as unknown as Parameters<typeof reconcile>[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<typeof reconcile>[0],
db as unknown as Parameters<typeof reconcile>[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<typeof reconcile>[0],
db as unknown as Parameters<typeof reconcile>[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<typeof reconcile>[0],
db as unknown as Parameters<typeof reconcile>[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<typeof reconcile>[0],
db as unknown as Parameters<typeof reconcile>[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<typeof reconcile>[0],
db as unknown as Parameters<typeof reconcile>[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<typeof handleChannelDelete>[0], {
db: db as unknown as Parameters<typeof handleChannelDelete>[1]["db"],
config: makeConfig() as unknown as Parameters<typeof handleChannelDelete>[1]["config"],
guild: guild as unknown as Parameters<typeof handleChannelDelete>[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<typeof handleChannelDelete>[0], {
db: db as unknown as Parameters<typeof handleChannelDelete>[1]["db"],
config: makeConfig() as unknown as Parameters<typeof handleChannelDelete>[1]["config"],
guild: guild as unknown as Parameters<typeof handleChannelDelete>[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<typeof handleChannelDelete>[0], {
db: db as unknown as Parameters<typeof handleChannelDelete>[1]["db"],
config: makeConfig() as unknown as Parameters<typeof handleChannelDelete>[1]["config"],
guild: guild as unknown as Parameters<typeof handleChannelDelete>[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<typeof ensureCategories>[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<typeof ensureCategories>[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();
});
});