mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-28 10:22:32 +00:00
test: dedupe extension channel fixtures
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user