- 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>
582 lines
22 KiB
TypeScript
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();
|
|
});
|
|
});
|