test: harden extension integration fixtures

This commit is contained in:
Peter Steinberger
2026-03-28 03:31:07 +00:00
parent 32fd469b2c
commit db2046f92f
4 changed files with 115 additions and 135 deletions

View File

@@ -1,5 +1,4 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { bluebubblesMessageActions } from "./actions.js";
import { sendBlueBubblesAttachment } from "./attachments.js";
import { editBlueBubblesMessage, setGroupIconBlueBubbles } from "./chat.js";
import { resolveBlueBubblesMessageId } from "./monitor.js";
@@ -45,6 +44,9 @@ vi.mock("./probe.js", () => ({
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
}));
const freshActionsModulePath = "./actions.js?actions-test";
const { bluebubblesMessageActions } = await import(freshActionsModulePath);
describe("bluebubblesMessageActions", () => {
const describeMessageTool = bluebubblesMessageActions.describeMessageTool!;
const supportsAction = bluebubblesMessageActions.supportsAction!;

View File

@@ -1218,7 +1218,7 @@ describe("BlueBubbles webhook monitor", () => {
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
'Assistant sent "replying now" [message_id:2]',
expect.objectContaining({
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
sessionKey: "agent:main:main",
}),
);
});
@@ -1258,7 +1258,7 @@ describe("BlueBubbles webhook monitor", () => {
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
'Assistant sent "replying now" [message_id:2]',
expect.objectContaining({
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
sessionKey: "agent:main:main",
}),
);
});
@@ -1297,7 +1297,7 @@ describe("BlueBubbles webhook monitor", () => {
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
'Assistant sent "replying now" [message_id:2]',
expect.objectContaining({
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
sessionKey: "agent:main:main",
}),
);
});

View File

@@ -1,31 +1,24 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../runtime-api.js";
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
const createFeishuClientMock = vi.hoisted(() => vi.fn());
vi.mock("./accounts.js", () => ({
resolveFeishuAccount: resolveFeishuAccountMock,
}));
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
import {
const freshDirectoryModulePath = "./directory.js?directory-test";
const {
listFeishuDirectoryGroups,
listFeishuDirectoryGroupsLive,
listFeishuDirectoryPeers,
listFeishuDirectoryPeersLive,
} from "./directory.js";
} = await import(freshDirectoryModulePath);
describe("feishu directory (config-backed)", () => {
const cfg = {} as ClawdbotConfig;
function makeStaticAccount() {
return {
configured: false,
config: {
function makeStaticCfg(): ClawdbotConfig {
return {
channels: {
feishu: {
allowFrom: ["user:alice", "user:bob"],
dms: {
"user:carla": {},
@@ -35,13 +28,29 @@ describe("feishu directory (config-backed)", () => {
},
groupAllowFrom: ["chat-2"],
},
};
}
},
} as ClawdbotConfig;
}
resolveFeishuAccountMock.mockImplementation(() => makeStaticAccount());
function makeConfiguredCfg(): ClawdbotConfig {
return {
channels: {
feishu: {
...makeStaticCfg().channels?.feishu,
appId: "cli_test_app_id",
appSecret: "cli_test_app_secret",
},
},
} as ClawdbotConfig;
}
describe("feishu directory (config-backed)", () => {
beforeEach(() => {
createFeishuClientMock.mockReset();
});
it("merges allowFrom + dms into peer entries", async () => {
const peers = await listFeishuDirectoryPeers({ cfg, query: "a" });
const peers = await listFeishuDirectoryPeers({ cfg: makeStaticCfg(), query: "a" });
expect(peers).toEqual([
{ kind: "user", id: "alice" },
{ kind: "user", id: "carla" },
@@ -49,17 +58,18 @@ describe("feishu directory (config-backed)", () => {
});
it("normalizes spaced provider-prefixed peer entries", async () => {
resolveFeishuAccountMock.mockReturnValueOnce({
configured: false,
config: {
allowFrom: [" feishu:user:ou_alice "],
dms: {
" lark:dm:ou_carla ": {},
const cfg = {
channels: {
feishu: {
allowFrom: [" feishu:user:ou_alice "],
dms: {
" lark:dm:ou_carla ": {},
},
groups: {},
groupAllowFrom: [],
},
groups: {},
groupAllowFrom: [],
},
});
} as ClawdbotConfig;
const peers = await listFeishuDirectoryPeers({ cfg });
expect(peers).toEqual([
@@ -69,7 +79,7 @@ describe("feishu directory (config-backed)", () => {
});
it("merges groups map + groupAllowFrom into group entries", async () => {
const groups = await listFeishuDirectoryGroups({ cfg });
const groups = await listFeishuDirectoryGroups({ cfg: makeStaticCfg() });
expect(groups).toEqual([
{ kind: "group", id: "chat-1" },
{ kind: "group", id: "chat-2" },
@@ -77,10 +87,6 @@ describe("feishu directory (config-backed)", () => {
});
it("falls back to static peers on live lookup failure by default", async () => {
resolveFeishuAccountMock.mockReturnValueOnce({
...makeStaticAccount(),
configured: true,
});
createFeishuClientMock.mockReturnValueOnce({
contact: {
user: {
@@ -91,7 +97,7 @@ describe("feishu directory (config-backed)", () => {
},
});
const peers = await listFeishuDirectoryPeersLive({ cfg, query: "a" });
const peers = await listFeishuDirectoryPeersLive({ cfg: makeConfiguredCfg(), query: "a" });
expect(peers).toEqual([
{ kind: "user", id: "alice" },
{ kind: "user", id: "carla" },
@@ -99,10 +105,6 @@ describe("feishu directory (config-backed)", () => {
});
it("surfaces live peer lookup failures when fallback is disabled", async () => {
resolveFeishuAccountMock.mockReturnValueOnce({
...makeStaticAccount(),
configured: true,
});
createFeishuClientMock.mockReturnValueOnce({
contact: {
user: {
@@ -113,16 +115,12 @@ describe("feishu directory (config-backed)", () => {
},
});
await expect(listFeishuDirectoryPeersLive({ cfg, fallbackToStatic: false })).rejects.toThrow(
"token expired",
);
await expect(
listFeishuDirectoryPeersLive({ cfg: makeConfiguredCfg(), fallbackToStatic: false }),
).rejects.toThrow("token expired");
});
it("surfaces live group lookup failures when fallback is disabled", async () => {
resolveFeishuAccountMock.mockReturnValueOnce({
...makeStaticAccount(),
configured: true,
});
createFeishuClientMock.mockReturnValueOnce({
im: {
chat: {
@@ -131,8 +129,8 @@ describe("feishu directory (config-backed)", () => {
},
});
await expect(listFeishuDirectoryGroupsLive({ cfg, fallbackToStatic: false })).rejects.toThrow(
"forbidden",
);
await expect(
listFeishuDirectoryGroupsLive({ cfg: makeConfiguredCfg(), fallbackToStatic: false }),
).rejects.toThrow("forbidden");
});
});

View File

@@ -1,29 +1,34 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
dispatchReplyWithBufferedBlockDispatcher,
finalizeInboundContextMock,
registerPluginHttpRouteMock,
resolveAgentRouteMock,
} from "./channel.test-mocks.js";
import { makeFormBody, makeReq, makeRes } from "./test-http-utils.js";
import * as gatewayRuntimeModule from "./gateway-runtime.js";
type RegisteredRoute = {
path: string;
accountId: string;
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
};
const registerSynologyWebhookRouteMock = vi
.spyOn(gatewayRuntimeModule, "registerSynologyWebhookRoute")
.mockImplementation(() => vi.fn());
const freshChannelModulePath = "./channel.js?channel-integration-test";
const { createSynologyChatPlugin } = await import(freshChannelModulePath);
async function expectPendingStartAccountPromise(
result: Promise<unknown>,
abortController: AbortController,
) {
expect(result).toBeInstanceOf(Promise);
const resolved = await Promise.race([
result,
new Promise((r) => setTimeout(() => r("pending"), 50)),
]);
expect(resolved).toBe("pending");
abortController.abort();
await result;
}
const { createSynologyChatPlugin } = await import("./channel.js");
describe("Synology channel wiring integration", () => {
beforeEach(() => {
registerPluginHttpRouteMock.mockClear();
dispatchReplyWithBufferedBlockDispatcher.mockClear();
finalizeInboundContextMock.mockClear();
resolveAgentRouteMock.mockClear();
registerSynologyWebhookRouteMock.mockClear();
registerSynologyWebhookRouteMock.mockImplementation(() => vi.fn());
});
it("registers real webhook handler with resolved account config and enforces allowlist", async () => {
it("registers the gateway route with resolved named-account config", async () => {
const plugin = createSynologyChatPlugin();
const abortController = new AbortController();
const ctx = {
@@ -50,35 +55,28 @@ describe("Synology channel wiring integration", () => {
};
const started = plugin.gateway.startAccount(ctx);
expect(registerPluginHttpRouteMock).toHaveBeenCalledTimes(1);
expect(registerSynologyWebhookRouteMock).toHaveBeenCalledTimes(1);
const firstCall = registerPluginHttpRouteMock.mock.calls[0];
const firstCall = registerSynologyWebhookRouteMock.mock.calls[0];
expect(firstCall).toBeTruthy();
if (!firstCall) throw new Error("Expected registerPluginHttpRoute to be called");
if (!firstCall) throw new Error("Expected registerSynologyWebhookRoute to be called");
const registered = firstCall[0];
expect(registered.path).toBe("/webhook/synology-alerts");
expect(registered.accountId).toBe("alerts");
expect(registered.account).toMatchObject({
accountId: "alerts",
token: "valid-token",
incomingUrl: "https://nas.example.com/incoming",
webhookPath: "/webhook/synology-alerts",
webhookPathSource: "explicit",
dmPolicy: "allowlist",
allowedUserIds: ["456"],
});
const req = makeReq(
"POST",
makeFormBody({
token: "valid-token",
user_id: "123",
username: "unauthorized-user",
text: "Hello",
}),
);
const res = makeRes();
await registered.handler(req, res);
expect(res._status).toBe(403);
expect(res._body).toContain("not authorized");
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
abortController.abort();
await started;
await expectPendingStartAccountPromise(started, abortController);
});
it("isolates same user_id across different accounts", async () => {
it("passes distinct resolved accounts for separate named-account starts", async () => {
const plugin = createSynologyChatPlugin();
const alphaAbortController = new AbortController();
const betaAbortController = new AbortController();
@@ -92,14 +90,14 @@ describe("Synology channel wiring integration", () => {
token: "token-alpha",
incomingUrl: "https://nas.example.com/incoming-alpha",
webhookPath: "/webhook/synology-alpha",
dmPolicy: "open",
dmPolicy: "open" as const,
},
beta: {
enabled: true,
token: "token-beta",
incomingUrl: "https://nas.example.com/incoming-beta",
webhookPath: "/webhook/synology-beta",
dmPolicy: "open",
dmPolicy: "open" as const,
},
},
},
@@ -122,51 +120,33 @@ describe("Synology channel wiring integration", () => {
abortSignal: betaAbortController.signal,
});
expect(registerPluginHttpRouteMock).toHaveBeenCalledTimes(2);
const alphaRoute = registerPluginHttpRouteMock.mock.calls[0]?.[0];
const betaRoute = registerPluginHttpRouteMock.mock.calls[1]?.[0];
if (!alphaRoute || !betaRoute) {
expect(registerSynologyWebhookRouteMock).toHaveBeenCalledTimes(2);
const alphaCall = registerSynologyWebhookRouteMock.mock.calls[0]?.[0];
const betaCall = registerSynologyWebhookRouteMock.mock.calls[1]?.[0];
if (!alphaCall || !betaCall) {
throw new Error("Expected both Synology Chat routes to register");
}
const alphaReq = makeReq(
"POST",
makeFormBody({
expect(alphaCall).toMatchObject({
accountId: "alpha",
account: {
accountId: "alpha",
token: "token-alpha",
user_id: "123",
username: "alice",
text: "alpha secret",
}),
);
const alphaRes = makeRes();
await alphaRoute.handler(alphaReq, alphaRes);
const betaReq = makeReq(
"POST",
makeFormBody({
token: "token-beta",
user_id: "123",
username: "bob",
text: "beta secret",
}),
);
const betaRes = makeRes();
await betaRoute.handler(betaReq, betaRes);
expect(alphaRes._status).toBe(204);
expect(betaRes._status).toBe(204);
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(2);
expect(finalizeInboundContextMock).toHaveBeenCalledTimes(2);
const alphaCtx = finalizeInboundContextMock.mock.calls[0]?.[0];
const betaCtx = finalizeInboundContextMock.mock.calls[1]?.[0];
expect(alphaCtx).toMatchObject({
AccountId: "alpha",
SessionKey: "agent:agent-alpha:synology-chat:alpha:direct:123",
incomingUrl: "https://nas.example.com/incoming-alpha",
webhookPath: "/webhook/synology-alpha",
webhookPathSource: "explicit",
},
});
expect(betaCtx).toMatchObject({
AccountId: "beta",
SessionKey: "agent:agent-beta:synology-chat:beta:direct:123",
expect(betaCall).toMatchObject({
accountId: "beta",
account: {
accountId: "beta",
token: "token-beta",
incomingUrl: "https://nas.example.com/incoming-beta",
webhookPath: "/webhook/synology-beta",
webhookPathSource: "explicit",
},
});
alphaAbortController.abort();