test: dedupe extension channel fixtures

This commit is contained in:
Peter Steinberger
2026-03-26 19:47:27 +00:00
parent e8f9d68bec
commit be328e6cd1
8 changed files with 323 additions and 392 deletions

View File

@@ -6,6 +6,7 @@ import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js";
import { sendMessageBlueBubbles, resolveChatGuidForTarget, createChatForHandle } from "./send.js";
import {
BLUE_BUBBLES_PRIVATE_API_STATUS,
createBlueBubblesFetchGuardPassthroughInstaller,
installBlueBubblesFetchTestHooks,
mockBlueBubblesPrivateApiStatusOnce,
} from "./test-harness.js";
@@ -13,6 +14,7 @@ import { _setFetchGuardForTesting, type BlueBubblesSendTarget } from "./types.js
const mockFetch = vi.fn();
const privateApiStatusMock = vi.mocked(getCachedBlueBubblesPrivateApiStatus);
const setFetchGuardPassthrough = createBlueBubblesFetchGuardPassthroughInstaller();
installBlueBubblesFetchTestHooks({
mockFetch,
@@ -62,29 +64,8 @@ function mockNewChatSendResponse(guid: string) {
}
function installSsrFPolicyCapture(policies: unknown[]) {
_setFetchGuardForTesting(async (params) => {
policies.push(params.policy);
const raw = await globalThis.fetch(params.url, params.init);
let body: ArrayBuffer;
if (typeof raw.arrayBuffer === "function") {
body = await raw.arrayBuffer();
} else {
const text =
typeof (raw as { text?: () => Promise<string> }).text === "function"
? await (raw as { text: () => Promise<string> }).text()
: typeof (raw as { json?: () => Promise<unknown> }).json === "function"
? JSON.stringify(await (raw as { json: () => Promise<unknown> }).json())
: "";
body = new TextEncoder().encode(text).buffer;
}
return {
response: new Response(body, {
status: (raw as { status?: number }).status ?? 200,
headers: (raw as { headers?: HeadersInit }).headers,
}),
release: async () => {},
finalUrl: params.url,
};
setFetchGuardPassthrough((policy) => {
policies.push(policy);
});
}

View File

@@ -68,12 +68,29 @@ export function installBlueBubblesFetchTestHooks(params: {
mockReturnValue: (value: boolean | null) => unknown;
};
}) {
const setFetchGuardPassthrough = createBlueBubblesFetchGuardPassthroughInstaller();
beforeEach(() => {
vi.stubGlobal("fetch", params.mockFetch);
// Replace the SSRF guard with a passthrough that delegates to the mocked global.fetch,
// wrapping the result in a real Response so callers can call .arrayBuffer() on it.
_setFetchGuardForTesting(async (p) => {
const raw = await globalThis.fetch(p.url, p.init);
setFetchGuardPassthrough();
params.mockFetch.mockReset();
params.privateApiStatusMock.mockReset?.();
params.privateApiStatusMock.mockClear?.();
params.privateApiStatusMock.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown);
});
afterEach(() => {
_setFetchGuardForTesting(null);
vi.unstubAllGlobals();
});
}
export function createBlueBubblesFetchGuardPassthroughInstaller() {
return (capturePolicy?: (policy: unknown) => void) => {
_setFetchGuardForTesting(async (params) => {
capturePolicy?.(params.policy);
const raw = await globalThis.fetch(params.url, params.init);
let body: ArrayBuffer;
if (typeof raw.arrayBuffer === "function") {
body = await raw.arrayBuffer();
@@ -92,17 +109,8 @@ export function installBlueBubblesFetchTestHooks(params: {
headers: (raw as { headers?: HeadersInit }).headers,
}),
release: async () => {},
finalUrl: p.url,
finalUrl: params.url,
};
});
params.mockFetch.mockReset();
params.privateApiStatusMock.mockReset?.();
params.privateApiStatusMock.mockClear?.();
params.privateApiStatusMock.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown);
});
afterEach(() => {
_setFetchGuardForTesting(null);
vi.unstubAllGlobals();
});
};
}

View File

@@ -55,6 +55,61 @@ function getSentParams() {
return requestMock.mock.calls[0]?.[1] as Record<string, unknown>;
}
async function expectDirectOutboundResult(params: {
invoke: () => Promise<{ channel: string; messageId: string }>;
sendIMessage: ReturnType<typeof vi.fn>;
to: string;
text: string;
expectedOptions: Record<string, unknown>;
expectedResult: { channel: string; messageId: string };
}) {
const result = await params.invoke();
expect(params.sendIMessage).toHaveBeenCalledWith(
params.to,
params.text,
expect.objectContaining(params.expectedOptions),
);
expect(result).toEqual(params.expectedResult);
}
async function expectReplyToTextForwarding(params: {
invoke: () => Promise<{ channel: string; messageId: string }>;
sendIMessage: ReturnType<typeof vi.fn>;
}) {
await expectDirectOutboundResult({
invoke: params.invoke,
sendIMessage: params.sendIMessage,
to: "chat_id:12",
text: "hello",
expectedOptions: {
accountId: "default",
replyToId: "reply-1",
maxBytes: 3 * 1024 * 1024,
},
expectedResult: { channel: "imessage", messageId: "m-text" },
});
}
async function expectMediaLocalRootsForwarding(params: {
invoke: () => Promise<{ channel: string; messageId: string }>;
sendIMessage: ReturnType<typeof vi.fn>;
}) {
await expectDirectOutboundResult({
invoke: params.invoke,
sendIMessage: params.sendIMessage,
to: "chat_id:88",
text: "caption",
expectedOptions: {
mediaUrl: "/tmp/workspace/pic.png",
mediaLocalRoots: ["/tmp/workspace"],
accountId: "acct-1",
replyToId: "reply-2",
maxBytes: 3 * 1024 * 1024,
},
expectedResult: { channel: "imessage", messageId: "m-media-local" },
});
}
describe("imessagePlugin outbound", () => {
const cfg = {
channels: {
@@ -68,25 +123,18 @@ describe("imessagePlugin outbound", () => {
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "m-text" });
const sendText = requireIMessageSendText();
const result = await sendText({
cfg,
to: "chat_id:12",
text: "hello",
accountId: "default",
replyToId: "reply-1",
deps: { sendIMessage },
await expectReplyToTextForwarding({
invoke: async () =>
await sendText({
cfg,
to: "chat_id:12",
text: "hello",
accountId: "default",
replyToId: "reply-1",
deps: { sendIMessage },
}),
sendIMessage,
});
expect(sendIMessage).toHaveBeenCalledWith(
"chat_id:12",
"hello",
expect.objectContaining({
accountId: "default",
replyToId: "reply-1",
maxBytes: 3 * 1024 * 1024,
}),
);
expect(result).toEqual({ channel: "imessage", messageId: "m-text" });
});
it("forwards replyToId on direct sendMedia adapter path", async () => {
@@ -121,27 +169,20 @@ describe("imessagePlugin outbound", () => {
const sendMedia = requireIMessageSendMedia();
const mediaLocalRoots = ["/tmp/workspace"];
const result = await sendMedia({
cfg,
to: "chat_id:88",
text: "caption",
mediaUrl: "/tmp/workspace/pic.png",
mediaLocalRoots,
accountId: "acct-1",
deps: { sendIMessage },
await expectMediaLocalRootsForwarding({
invoke: async () =>
await sendMedia({
cfg,
to: "chat_id:88",
text: "caption",
mediaUrl: "/tmp/workspace/pic.png",
mediaLocalRoots,
accountId: "acct-1",
replyToId: "reply-2",
deps: { sendIMessage },
}),
sendIMessage,
});
expect(sendIMessage).toHaveBeenCalledWith(
"chat_id:88",
"caption",
expect.objectContaining({
mediaUrl: "/tmp/workspace/pic.png",
mediaLocalRoots,
accountId: "acct-1",
maxBytes: 3 * 1024 * 1024,
}),
);
expect(result).toEqual({ channel: "imessage", messageId: "m-media-local" });
});
});
@@ -157,53 +198,37 @@ describe("imessageOutbound", () => {
it("forwards replyToId on direct text sends", async () => {
const sendIMessage = vi.fn().mockResolvedValueOnce({ messageId: "m-text" });
const result = await imessageOutbound.sendText!({
cfg,
to: "chat_id:12",
text: "hello",
accountId: "default",
replyToId: "reply-1",
deps: { sendIMessage },
await expectReplyToTextForwarding({
invoke: async () =>
await imessageOutbound.sendText!({
cfg,
to: "chat_id:12",
text: "hello",
accountId: "default",
replyToId: "reply-1",
deps: { sendIMessage },
}),
sendIMessage,
});
expect(sendIMessage).toHaveBeenCalledWith(
"chat_id:12",
"hello",
expect.objectContaining({
accountId: "default",
replyToId: "reply-1",
maxBytes: 3 * 1024 * 1024,
}),
);
expect(result).toEqual({ channel: "imessage", messageId: "m-text" });
});
it("forwards mediaLocalRoots on direct media sends", async () => {
const sendIMessage = vi.fn().mockResolvedValueOnce({ messageId: "m-media-local" });
const result = await imessageOutbound.sendMedia!({
cfg,
to: "chat_id:88",
text: "caption",
mediaUrl: "/tmp/workspace/pic.png",
mediaLocalRoots: ["/tmp/workspace"],
accountId: "acct-1",
replyToId: "reply-2",
deps: { sendIMessage },
await expectMediaLocalRootsForwarding({
invoke: async () =>
await imessageOutbound.sendMedia!({
cfg,
to: "chat_id:88",
text: "caption",
mediaUrl: "/tmp/workspace/pic.png",
mediaLocalRoots: ["/tmp/workspace"],
accountId: "acct-1",
replyToId: "reply-2",
deps: { sendIMessage },
}),
sendIMessage,
});
expect(sendIMessage).toHaveBeenCalledWith(
"chat_id:88",
"caption",
expect.objectContaining({
mediaUrl: "/tmp/workspace/pic.png",
mediaLocalRoots: ["/tmp/workspace"],
accountId: "acct-1",
replyToId: "reply-2",
maxBytes: 3 * 1024 * 1024,
}),
);
expect(result).toEqual({ channel: "imessage", messageId: "m-media-local" });
});
});

View File

@@ -77,6 +77,65 @@ function mockContinueConversationFailure(error: string) {
return mockContinueConversation;
}
function createSharePointSendContext(params: {
conversationId: string;
graphChatId: string | null;
siteId: string;
}) {
return {
adapter: {
continueConversation: vi.fn(
async (
_id: string,
_ref: unknown,
fn: (ctx: { sendActivity: () => { id: "msg-1" } }) => Promise<void>,
) => fn({ sendActivity: () => ({ id: "msg-1" }) }),
),
},
appId: "app-id",
conversationId: params.conversationId,
graphChatId: params.graphChatId,
ref: {},
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
conversationType: "groupChat" as const,
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
mediaMaxBytes: 8 * 1024 * 1024,
sharePointSiteId: params.siteId,
};
}
function mockSharePointPdfUpload(params: {
bufferSize: number;
fileName: string;
itemId: string;
uniqueId: string;
}) {
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
buffer: Buffer.alloc(params.bufferSize, "pdf"),
contentType: "application/pdf",
fileName: params.fileName,
kind: "file",
});
mockState.requiresFileConsent.mockReturnValue(false);
mockState.uploadAndShareSharePoint.mockResolvedValue({
itemId: params.itemId,
webUrl: `https://sp.example.com/${params.fileName}`,
shareUrl: `https://sp.example.com/share/${params.fileName}`,
name: params.fileName,
});
mockState.getDriveItemProperties.mockResolvedValue({
eTag: `"${params.uniqueId},1"`,
webDavUrl: `https://sp.example.com/dav/${params.fileName}`,
name: params.fileName,
});
mockState.buildTeamsFileInfoCard.mockReturnValue({
contentType: "application/vnd.microsoft.teams.card.file.info",
contentUrl: `https://sp.example.com/dav/${params.fileName}`,
name: params.fileName,
content: { uniqueId: params.uniqueId, fileType: "pdf" },
});
}
describe("sendMessageMSTeams", () => {
beforeEach(() => {
mockState.loadOutboundMediaFromUrl.mockReset();
@@ -148,51 +207,18 @@ describe("sendMessageMSTeams", () => {
const graphChatId = "19:graph-native-chat-id@thread.tacv2";
const botFrameworkConversationId = "19:bot-framework-id@thread.tacv2";
mockState.resolveMSTeamsSendContext.mockResolvedValue({
adapter: {
continueConversation: vi.fn(
async (
_id: string,
_ref: unknown,
fn: (ctx: { sendActivity: () => { id: "msg-1" } }) => Promise<void>,
) => fn({ sendActivity: () => ({ id: "msg-1" }) }),
),
},
appId: "app-id",
conversationId: botFrameworkConversationId,
graphChatId,
ref: {},
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
conversationType: "groupChat",
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
mediaMaxBytes: 8 * 1024 * 1024,
sharePointSiteId: "site-123",
});
const pdfBuffer = Buffer.alloc(100, "pdf");
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
buffer: pdfBuffer,
contentType: "application/pdf",
mockState.resolveMSTeamsSendContext.mockResolvedValue(
createSharePointSendContext({
conversationId: botFrameworkConversationId,
graphChatId,
siteId: "site-123",
}),
);
mockSharePointPdfUpload({
bufferSize: 100,
fileName: "doc.pdf",
kind: "file",
});
mockState.requiresFileConsent.mockReturnValue(false);
mockState.uploadAndShareSharePoint.mockResolvedValue({
itemId: "item-1",
webUrl: "https://sp.example.com/doc.pdf",
shareUrl: "https://sp.example.com/share/doc.pdf",
name: "doc.pdf",
});
mockState.getDriveItemProperties.mockResolvedValue({
eTag: '"{GUID-123},1"',
webDavUrl: "https://sp.example.com/dav/doc.pdf",
name: "doc.pdf",
});
mockState.buildTeamsFileInfoCard.mockReturnValue({
contentType: "application/vnd.microsoft.teams.card.file.info",
contentUrl: "https://sp.example.com/dav/doc.pdf",
name: "doc.pdf",
content: { uniqueId: "GUID-123", fileType: "pdf" },
uniqueId: "{GUID-123}",
});
await sendMessageMSTeams({
@@ -214,51 +240,18 @@ describe("sendMessageMSTeams", () => {
it("falls back to conversationId when graphChatId is not available", async () => {
const botFrameworkConversationId = "19:fallback-id@thread.tacv2";
mockState.resolveMSTeamsSendContext.mockResolvedValue({
adapter: {
continueConversation: vi.fn(
async (
_id: string,
_ref: unknown,
fn: (ctx: { sendActivity: () => { id: "msg-1" } }) => Promise<void>,
) => fn({ sendActivity: () => ({ id: "msg-1" }) }),
),
},
appId: "app-id",
conversationId: botFrameworkConversationId,
graphChatId: null, // resolution failed — must fall back
ref: {},
log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
conversationType: "groupChat",
tokenProvider: { getAccessToken: vi.fn(async () => "token") },
mediaMaxBytes: 8 * 1024 * 1024,
sharePointSiteId: "site-456",
});
const pdfBuffer = Buffer.alloc(50, "pdf");
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
buffer: pdfBuffer,
contentType: "application/pdf",
mockState.resolveMSTeamsSendContext.mockResolvedValue(
createSharePointSendContext({
conversationId: botFrameworkConversationId,
graphChatId: null,
siteId: "site-456",
}),
);
mockSharePointPdfUpload({
bufferSize: 50,
fileName: "report.pdf",
kind: "file",
});
mockState.requiresFileConsent.mockReturnValue(false);
mockState.uploadAndShareSharePoint.mockResolvedValue({
itemId: "item-2",
webUrl: "https://sp.example.com/report.pdf",
shareUrl: "https://sp.example.com/share/report.pdf",
name: "report.pdf",
});
mockState.getDriveItemProperties.mockResolvedValue({
eTag: '"{GUID-456},1"',
webDavUrl: "https://sp.example.com/dav/report.pdf",
name: "report.pdf",
});
mockState.buildTeamsFileInfoCard.mockReturnValue({
contentType: "application/vnd.microsoft.teams.card.file.info",
contentUrl: "https://sp.example.com/dav/report.pdf",
name: "report.pdf",
content: { uniqueId: "GUID-456", fileType: "pdf" },
uniqueId: "{GUID-456}",
});
await sendMessageMSTeams({

View File

@@ -47,6 +47,24 @@ function createCommandContext(
}
describe("talk-voice plugin", () => {
function createElevenlabsVoiceSetHarness(channel = "webchat", scopes?: string[]) {
const { command, runtime } = createHarness({
talk: {
provider: "elevenlabs",
providers: {
elevenlabs: {
apiKey: "sk-eleven",
},
},
},
});
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]);
return {
runtime,
run: async () => await command.handler(createCommandContext("set Claudia", channel, scopes)),
};
}
it("reports active provider status", async () => {
const { command } = createHarness({
talk: {
@@ -206,81 +224,32 @@ describe("talk-voice plugin", () => {
});
it("rejects /voice set from gateway client with only operator.write scope", async () => {
const { command, runtime } = createHarness({
talk: {
provider: "elevenlabs",
providers: {
elevenlabs: {
apiKey: "sk-eleven",
},
},
},
});
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]);
const result = await command.handler(
createCommandContext("set Claudia", "webchat", ["operator.write"]),
);
const { runtime, run } = createElevenlabsVoiceSetHarness("webchat", ["operator.write"]);
const result = await run();
expect(result.text).toContain("requires operator.admin");
expect(runtime.config.writeConfigFile).not.toHaveBeenCalled();
});
it("allows /voice set from gateway client with operator.admin scope", async () => {
const { command, runtime } = createHarness({
talk: {
provider: "elevenlabs",
providers: {
elevenlabs: {
apiKey: "sk-eleven",
},
},
},
});
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]);
const result = await command.handler(
createCommandContext("set Claudia", "webchat", ["operator.admin"]),
);
const { runtime, run } = createElevenlabsVoiceSetHarness("webchat", ["operator.admin"]);
const result = await run();
expect(runtime.config.writeConfigFile).toHaveBeenCalled();
expect(result.text).toContain("voice-a");
});
it("rejects /voice set from webchat channel with no scopes (TUI/internal)", async () => {
const { command, runtime } = createHarness({
talk: {
provider: "elevenlabs",
providers: {
elevenlabs: {
apiKey: "sk-eleven",
},
},
},
});
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]);
// gatewayClientScopes omitted — simulates internal webchat session without scopes
const result = await command.handler(createCommandContext("set Claudia", "webchat"));
const { runtime, run } = createElevenlabsVoiceSetHarness();
const result = await run();
expect(result.text).toContain("requires operator.admin");
expect(runtime.config.writeConfigFile).not.toHaveBeenCalled();
});
it("allows /voice set from non-gateway channels without scope check", async () => {
const { command, runtime } = createHarness({
talk: {
provider: "elevenlabs",
providers: {
elevenlabs: {
apiKey: "sk-eleven",
},
},
},
});
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]);
const result = await command.handler(createCommandContext("set Claudia", "telegram"));
const { runtime, run } = createElevenlabsVoiceSetHarness("telegram");
const result = await run();
expect(runtime.config.writeConfigFile).toHaveBeenCalled();
expect(result.text).toContain("voice-a");

View File

@@ -21,6 +21,15 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889
});
}
function expectRecordedRoute(params: { to: string; threadId?: string }) {
const updateLastRoute = getRecordedUpdateLastRoute(0) as
| { threadId?: string; to?: string }
| undefined;
expect(updateLastRoute).toBeDefined();
expect(updateLastRoute?.to).toBe(params.to);
expect(updateLastRoute?.threadId).toBe(params.threadId);
}
afterEach(() => {
clearRuntimeConfigSnapshot();
recordInboundSessionMock.mockClear();
@@ -46,13 +55,7 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889
expect(ctx).not.toBeNull();
expect(recordInboundSessionMock).toHaveBeenCalled();
// Check that updateLastRoute includes threadId
const updateLastRoute = getRecordedUpdateLastRoute(0) as
| { threadId?: string; to?: string }
| undefined;
expect(updateLastRoute).toBeDefined();
expect(updateLastRoute?.to).toBe("telegram:1234");
expect(updateLastRoute?.threadId).toBe("42");
expectRecordedRoute({ to: "telegram:1234", threadId: "42" });
});
it("does not pass threadId for regular DM without topic", async () => {
@@ -65,13 +68,7 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889
expect(ctx).not.toBeNull();
expect(recordInboundSessionMock).toHaveBeenCalled();
// Check that updateLastRoute does NOT include threadId
const updateLastRoute = getRecordedUpdateLastRoute(0) as
| { threadId?: string; to?: string }
| undefined;
expect(updateLastRoute).toBeDefined();
expect(updateLastRoute?.to).toBe("telegram:1234");
expect(updateLastRoute?.threadId).toBeUndefined();
expectRecordedRoute({ to: "telegram:1234" });
});
it("passes threadId to updateLastRoute for forum topic group messages", async () => {
@@ -88,12 +85,7 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889
expect(ctx).not.toBeNull();
expect(recordInboundSessionMock).toHaveBeenCalled();
const updateLastRoute = getRecordedUpdateLastRoute(0) as
| { threadId?: string; to?: string }
| undefined;
expect(updateLastRoute).toBeDefined();
expect(updateLastRoute?.to).toBe("telegram:-1001234567890");
expect(updateLastRoute?.threadId).toBe("99");
expectRecordedRoute({ to: "telegram:-1001234567890", threadId: "99" });
});
it("passes threadId to updateLastRoute for the forum General topic", async () => {
@@ -109,11 +101,6 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889
expect(ctx).not.toBeNull();
expect(recordInboundSessionMock).toHaveBeenCalled();
const updateLastRoute = getRecordedUpdateLastRoute(0) as
| { threadId?: string; to?: string }
| undefined;
expect(updateLastRoute).toBeDefined();
expect(updateLastRoute?.to).toBe("telegram:-1001234567890");
expect(updateLastRoute?.threadId).toBe("1");
expectRecordedRoute({ to: "telegram:-1001234567890", threadId: "1" });
});
});

View File

@@ -23,6 +23,25 @@ describe("resolveTelegramToken", () => {
return tokenFile;
}
function createUnknownAccountConfig(): OpenClawConfig {
return {
channels: {
telegram: {
botToken: "wrong-bot-token",
accounts: {
knownBot: { botToken: "known-bot-token" },
},
},
},
} as OpenClawConfig;
}
function expectNoTokenForUnknownAccount(cfg: OpenClawConfig) {
const res = resolveTelegramToken(cfg, { accountId: "unknownBot" });
expect(res.token).toBe("");
expect(res.source).toBe("none");
}
afterEach(() => {
vi.unstubAllEnvs();
for (const dir of tempDirs.splice(0)) {
@@ -207,20 +226,7 @@ describe("resolveTelegramToken", () => {
it("does not fall through to channel-level token when non-default accountId is not in config", () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", "");
const cfg = {
channels: {
telegram: {
botToken: "wrong-bot-token",
accounts: {
knownBot: { botToken: "known-bot-token" },
},
},
},
} as OpenClawConfig;
const res = resolveTelegramToken(cfg, { accountId: "unknownBot" });
expect(res.token).toBe("");
expect(res.source).toBe("none");
expectNoTokenForUnknownAccount(createUnknownAccountConfig());
});
it("throws when botToken is an unresolved SecretRef object", () => {
@@ -257,20 +263,7 @@ describe("resolveTelegramToken", () => {
it("still blocks fallthrough for unknown accountId when accounts section exists", () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", "");
const cfg = {
channels: {
telegram: {
botToken: "wrong-bot-token",
accounts: {
knownBot: { botToken: "known-bot-token" },
},
},
},
} as OpenClawConfig;
const res = resolveTelegramToken(cfg, { accountId: "unknownBot" });
expect(res.token).toBe("");
expect(res.source).toBe("none");
expectNoTokenForUnknownAccount(createUnknownAccountConfig());
});
});

View File

@@ -26,6 +26,50 @@ async function runSetup(params: {
}
describe("zalouser setup wizard", () => {
function createQuickstartPrompter(params?: {
note?: ReturnType<typeof createTestWizardPrompter>["note"];
seen?: string[];
dmPolicy?: "pairing" | "allowlist";
groupAccess?: boolean;
groupPolicy?: "allowlist";
textByMessage?: Record<string, string>;
}) {
const select = vi.fn(
async ({ message, options }: { message: string; options: Array<{ value: string }> }) => {
const first = options[0];
if (!first) {
throw new Error("no options");
}
params?.seen?.push(message);
if (message === "Zalo Personal DM policy" && params?.dmPolicy) {
return params.dmPolicy;
}
if (message === "Zalo groups access" && params?.groupPolicy) {
return params.groupPolicy;
}
return first.value;
},
) as ReturnType<typeof createTestWizardPrompter>["select"];
const text = vi.fn(
async ({ message }: { message: string }) => params?.textByMessage?.[message] ?? "",
) as ReturnType<typeof createTestWizardPrompter>["text"];
return createTestWizardPrompter({
...(params?.note ? { note: params.note } : {}),
confirm: vi.fn(async ({ message }: { message: string }) => {
params?.seen?.push(message);
if (message === "Login via QR code now?") {
return false;
}
if (message === "Configure Zalo groups access?") {
return params?.groupAccess ?? false;
}
return false;
}),
select,
text,
});
}
it("enables the account without forcing QR login", async () => {
const prompter = createTestWizardPrompter({
confirm: vi.fn(async ({ message }: { message: string }) => {
@@ -48,31 +92,7 @@ describe("zalouser setup wizard", () => {
it("prompts DM policy before group access in quickstart", async () => {
const seen: string[] = [];
const prompter = createTestWizardPrompter({
confirm: vi.fn(async ({ message }: { message: string }) => {
seen.push(message);
if (message === "Login via QR code now?") {
return false;
}
if (message === "Configure Zalo groups access?") {
return false;
}
return false;
}),
select: vi.fn(
async ({ message, options }: { message: string; options: Array<{ value: string }> }) => {
const first = options[0];
if (!first) {
throw new Error("no options");
}
seen.push(message);
if (message === "Zalo Personal DM policy") {
return "pairing";
}
return first.value;
},
) as ReturnType<typeof createTestWizardPrompter>["select"],
});
const prompter = createQuickstartPrompter({ seen, dmPolicy: "pairing" });
const result = await runSetup({
prompter,
@@ -92,35 +112,12 @@ describe("zalouser setup wizard", () => {
it("allows an empty quickstart DM allowlist with a warning", async () => {
const note = vi.fn(async (_message: string, _title?: string) => {});
const prompter = createTestWizardPrompter({
const prompter = createQuickstartPrompter({
note,
confirm: vi.fn(async ({ message }: { message: string }) => {
if (message === "Login via QR code now?") {
return false;
}
if (message === "Configure Zalo groups access?") {
return false;
}
return false;
}),
select: vi.fn(
async ({ message, options }: { message: string; options: Array<{ value: string }> }) => {
const first = options[0];
if (!first) {
throw new Error("no options");
}
if (message === "Zalo Personal DM policy") {
return "allowlist";
}
return first.value;
},
) as ReturnType<typeof createTestWizardPrompter>["select"],
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "Zalouser allowFrom (name or user id)") {
return "";
}
return "";
}) as ReturnType<typeof createTestWizardPrompter>["text"],
dmPolicy: "allowlist",
textByMessage: {
"Zalouser allowFrom (name or user id)": "",
},
});
const result = await runSetup({
@@ -142,35 +139,13 @@ describe("zalouser setup wizard", () => {
it("allows an empty group allowlist with a warning", async () => {
const note = vi.fn(async (_message: string, _title?: string) => {});
const prompter = createTestWizardPrompter({
const prompter = createQuickstartPrompter({
note,
confirm: vi.fn(async ({ message }: { message: string }) => {
if (message === "Login via QR code now?") {
return false;
}
if (message === "Configure Zalo groups access?") {
return true;
}
return false;
}),
select: vi.fn(
async ({ message, options }: { message: string; options: Array<{ value: string }> }) => {
const first = options[0];
if (!first) {
throw new Error("no options");
}
if (message === "Zalo groups access") {
return "allowlist";
}
return first.value;
},
) as ReturnType<typeof createTestWizardPrompter>["select"],
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "Zalo groups allowlist (comma-separated)") {
return "";
}
return "";
}) as ReturnType<typeof createTestWizardPrompter>["text"],
groupAccess: true,
groupPolicy: "allowlist",
textByMessage: {
"Zalo groups allowlist (comma-separated)": "",
},
});
const result = await runSetup({ prompter });