refactor(discord): share message handler test scaffolding

This commit is contained in:
Peter Steinberger
2026-03-07 16:56:28 +00:00
parent 3381efc5c1
commit 9849ee8390
3 changed files with 152 additions and 192 deletions

View File

@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
import {
DEFAULT_DISCORD_BOT_USER_ID,
createDiscordHandlerParams,
createDiscordPreflightContext,
} from "./message-handler.test-helpers.js";
const preflightDiscordMessageMock = vi.hoisted(() => vi.fn());
const processDiscordMessageMock = vi.hoisted(() => vi.fn());
@@ -15,53 +18,12 @@ vi.mock("./message-handler.process.js", () => ({
const { createDiscordMessageHandler } = await import("./message-handler.js");
const BOT_USER_ID = "bot-123";
function createHandlerParams(overrides?: Partial<{ botUserId: string }>) {
const cfg: OpenClawConfig = {
channels: {
discord: {
enabled: true,
token: "test-token",
groupPolicy: "allowlist",
},
},
messages: {
inbound: {
debounceMs: 0,
},
},
};
return {
cfg,
discordConfig: cfg.channels?.discord,
accountId: "default",
token: "test-token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
botUserId: overrides?.botUserId ?? BOT_USER_ID,
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off" as const,
dmEnabled: true,
groupDmEnabled: false,
threadBindings: createNoopThreadBindingManager("default"),
};
}
function createMessageData(authorId: string, channelId = "ch-1") {
return {
author: { id: authorId, bot: authorId === BOT_USER_ID },
author: { id: authorId, bot: authorId === DEFAULT_DISCORD_BOT_USER_ID },
message: {
id: "msg-1",
author: { id: authorId, bot: authorId === BOT_USER_ID },
author: { id: authorId, bot: authorId === DEFAULT_DISCORD_BOT_USER_ID },
content: "hello",
channel_id: channelId,
},
@@ -70,26 +32,7 @@ function createMessageData(authorId: string, channelId = "ch-1") {
}
function createPreflightContext(channelId = "ch-1") {
return {
data: {
channel_id: channelId,
message: {
id: `msg-${channelId}`,
channel_id: channelId,
attachments: [],
},
},
message: {
id: `msg-${channelId}`,
channel_id: channelId,
attachments: [],
},
route: {
sessionKey: `agent:main:discord:channel:${channelId}`,
},
baseSessionKey: `agent:main:discord:channel:${channelId}`,
messageChannelId: channelId,
};
return createDiscordPreflightContext(channelId);
}
describe("createDiscordMessageHandler bot-self filter", () => {
@@ -97,10 +40,10 @@ describe("createDiscordMessageHandler bot-self filter", () => {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
const handler = createDiscordMessageHandler(createHandlerParams());
const handler = createDiscordMessageHandler(createDiscordHandlerParams());
await expect(
handler(createMessageData(BOT_USER_ID) as never, {} as never),
handler(createMessageData(DEFAULT_DISCORD_BOT_USER_ID) as never, {} as never),
).resolves.toBeUndefined();
expect(preflightDiscordMessageMock).not.toHaveBeenCalled();
@@ -115,7 +58,7 @@ describe("createDiscordMessageHandler bot-self filter", () => {
createPreflightContext(params.data.channel_id),
);
const handler = createDiscordMessageHandler(createHandlerParams());
const handler = createDiscordMessageHandler(createDiscordHandlerParams());
await expect(
handler(createMessageData("user-456") as never, {} as never),

View File

@@ -1,6 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
import {
createDiscordHandlerParams,
createDiscordPreflightContext,
} from "./message-handler.test-helpers.js";
const preflightDiscordMessageMock = vi.hoisted(() => vi.fn());
const processDiscordMessageMock = vi.hoisted(() => vi.fn());
@@ -24,52 +26,6 @@ function createDeferred<T = void>() {
return { promise, resolve };
}
function createHandlerParams(overrides?: {
setStatus?: (patch: Record<string, unknown>) => void;
abortSignal?: AbortSignal;
workerRunTimeoutMs?: number;
}) {
const cfg: OpenClawConfig = {
channels: {
discord: {
enabled: true,
token: "test-token",
groupPolicy: "allowlist",
},
},
messages: {
inbound: {
debounceMs: 0,
},
},
};
return {
cfg,
discordConfig: cfg.channels?.discord,
accountId: "default",
token: "test-token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
botUserId: "bot-123",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2_000,
replyToMode: "off" as const,
dmEnabled: true,
groupDmEnabled: false,
threadBindings: createNoopThreadBindingManager("default"),
setStatus: overrides?.setStatus,
abortSignal: overrides?.abortSignal,
workerRunTimeoutMs: overrides?.workerRunTimeoutMs,
};
}
function createMessageData(messageId: string, channelId = "ch-1") {
return {
channel_id: channelId,
@@ -85,25 +41,43 @@ function createMessageData(messageId: string, channelId = "ch-1") {
}
function createPreflightContext(channelId = "ch-1") {
return createDiscordPreflightContext(channelId);
}
async function createLifecycleStopScenario(params: {
createHandler: (status: ReturnType<typeof vi.fn>) => {
handler: (data: never, opts: never) => Promise<void>;
stop: () => void;
};
}) {
const runInFlight = createDeferred();
processDiscordMessageMock.mockImplementation(async () => {
await runInFlight.promise;
});
preflightDiscordMessageMock.mockImplementation(
async (contextParams: { data: { channel_id: string } }) =>
createPreflightContext(contextParams.data.channel_id),
);
const setStatus = vi.fn();
const { handler, stop } = params.createHandler(setStatus);
await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined();
await vi.waitFor(() => {
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
});
const callsBeforeStop = setStatus.mock.calls.length;
stop();
return {
data: {
channel_id: channelId,
message: {
id: `msg-${channelId}`,
channel_id: channelId,
attachments: [],
},
setStatus,
callsBeforeStop,
finish: async () => {
runInFlight.resolve();
await runInFlight.promise;
await Promise.resolve();
},
message: {
id: `msg-${channelId}`,
channel_id: channelId,
attachments: [],
},
route: {
sessionKey: `agent:main:discord:channel:${channelId}`,
},
baseSessionKey: `agent:main:discord:channel:${channelId}`,
messageChannelId: channelId,
};
}
@@ -113,7 +87,7 @@ describe("createDiscordMessageHandler queue behavior", () => {
processDiscordMessageMock.mockReset();
const setStatus = vi.fn();
createDiscordMessageHandler(createHandlerParams({ setStatus }));
createDiscordMessageHandler(createDiscordHandlerParams({ setStatus }));
expect(setStatus).toHaveBeenCalledWith(
expect.objectContaining({
@@ -142,7 +116,7 @@ describe("createDiscordMessageHandler queue behavior", () => {
);
const setStatus = vi.fn();
const handler = createDiscordMessageHandler(createHandlerParams({ setStatus }));
const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus }));
await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined();
@@ -205,7 +179,7 @@ describe("createDiscordMessageHandler queue behavior", () => {
createPreflightContext(params.data.channel_id),
);
const params = createHandlerParams({ workerRunTimeoutMs: 50 });
const params = createDiscordHandlerParams({ workerRunTimeoutMs: 50 });
const handler = createDiscordMessageHandler(params);
await expect(
@@ -256,7 +230,7 @@ describe("createDiscordMessageHandler queue behavior", () => {
createPreflightContext(params.data.channel_id),
);
const params = createHandlerParams({ workerRunTimeoutMs: 0 });
const params = createDiscordHandlerParams({ workerRunTimeoutMs: 0 });
const handler = createDiscordMessageHandler(params);
await expect(
@@ -305,7 +279,7 @@ describe("createDiscordMessageHandler queue behavior", () => {
try {
const setStatus = vi.fn();
const handler = createDiscordMessageHandler(createHandlerParams({ setStatus }));
const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus }));
await expect(
handler(createMessageData("m-1") as never, {} as never),
).resolves.toBeUndefined();
@@ -342,67 +316,35 @@ describe("createDiscordMessageHandler queue behavior", () => {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
const runInFlight = createDeferred();
processDiscordMessageMock.mockImplementation(async () => {
await runInFlight.promise;
});
preflightDiscordMessageMock.mockImplementation(
async (params: { data: { channel_id: string } }) =>
createPreflightContext(params.data.channel_id),
);
const setStatus = vi.fn();
const abortController = new AbortController();
const handler = createDiscordMessageHandler(
createHandlerParams({ setStatus, abortSignal: abortController.signal }),
);
await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined();
await vi.waitFor(() => {
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
const { setStatus, callsBeforeStop, finish } = await createLifecycleStopScenario({
createHandler: (status) => {
const abortController = new AbortController();
const handler = createDiscordMessageHandler(
createDiscordHandlerParams({ setStatus: status, abortSignal: abortController.signal }),
);
return { handler, stop: () => abortController.abort() };
},
});
const callsBeforeAbort = setStatus.mock.calls.length;
abortController.abort();
runInFlight.resolve();
await runInFlight.promise;
await Promise.resolve();
expect(setStatus.mock.calls.length).toBe(callsBeforeAbort);
await finish();
expect(setStatus.mock.calls.length).toBe(callsBeforeStop);
});
it("stops status publishing after handler deactivation", async () => {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
const runInFlight = createDeferred();
processDiscordMessageMock.mockImplementation(async () => {
await runInFlight.promise;
});
preflightDiscordMessageMock.mockImplementation(
async (params: { data: { channel_id: string } }) =>
createPreflightContext(params.data.channel_id),
);
const setStatus = vi.fn();
const handler = createDiscordMessageHandler(createHandlerParams({ setStatus }));
await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined();
await vi.waitFor(() => {
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
const { setStatus, callsBeforeStop, finish } = await createLifecycleStopScenario({
createHandler: (status) => {
const handler = createDiscordMessageHandler(
createDiscordHandlerParams({ setStatus: status }),
);
return { handler, stop: () => handler.deactivate() };
},
});
const callsBeforeDeactivate = setStatus.mock.calls.length;
handler.deactivate();
runInFlight.resolve();
await runInFlight.promise;
await Promise.resolve();
expect(setStatus.mock.calls.length).toBe(callsBeforeDeactivate);
await finish();
expect(setStatus.mock.calls.length).toBe(callsBeforeStop);
});
it("skips queued runs that have not started yet after deactivation", async () => {
@@ -420,7 +362,7 @@ describe("createDiscordMessageHandler queue behavior", () => {
createPreflightContext(params.data.channel_id),
);
const handler = createDiscordMessageHandler(createHandlerParams());
const handler = createDiscordMessageHandler(createDiscordHandlerParams());
await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined();
await vi.waitFor(() => {
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
@@ -460,7 +402,7 @@ describe("createDiscordMessageHandler queue behavior", () => {
processedMessageIds.push(ctx.messageId ?? "unknown");
});
const handler = createDiscordMessageHandler(createHandlerParams());
const handler = createDiscordMessageHandler(createDiscordHandlerParams());
const sequentialDispatch = (async () => {
await handler(createMessageData("m-1") as never, {} as never);
@@ -499,7 +441,7 @@ describe("createDiscordMessageHandler queue behavior", () => {
);
const setStatus = vi.fn();
const handler = createDiscordMessageHandler(createHandlerParams({ setStatus }));
const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus }));
await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined();
await expect(handler(createMessageData("m-2") as never, {} as never)).resolves.toBeUndefined();

View File

@@ -0,0 +1,75 @@
import { vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
export const DEFAULT_DISCORD_BOT_USER_ID = "bot-123";
export function createDiscordHandlerParams(overrides?: {
botUserId?: string;
setStatus?: (patch: Record<string, unknown>) => void;
abortSignal?: AbortSignal;
workerRunTimeoutMs?: number;
}) {
const cfg: OpenClawConfig = {
channels: {
discord: {
enabled: true,
token: "test-token",
groupPolicy: "allowlist",
},
},
messages: {
inbound: {
debounceMs: 0,
},
},
};
return {
cfg,
discordConfig: cfg.channels?.discord,
accountId: "default",
token: "test-token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
botUserId: overrides?.botUserId ?? DEFAULT_DISCORD_BOT_USER_ID,
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2_000,
replyToMode: "off" as const,
dmEnabled: true,
groupDmEnabled: false,
threadBindings: createNoopThreadBindingManager("default"),
setStatus: overrides?.setStatus,
abortSignal: overrides?.abortSignal,
workerRunTimeoutMs: overrides?.workerRunTimeoutMs,
};
}
export function createDiscordPreflightContext(channelId = "ch-1") {
return {
data: {
channel_id: channelId,
message: {
id: `msg-${channelId}`,
channel_id: channelId,
attachments: [],
},
},
message: {
id: `msg-${channelId}`,
channel_id: channelId,
attachments: [],
},
route: {
sessionKey: `agent:main:discord:channel:${channelId}`,
},
baseSessionKey: `agent:main:discord:channel:${channelId}`,
messageChannelId: channelId,
};
}