diff --git a/extensions/discord/src/outbound-adapter.sendpayload.test.ts b/extensions/discord/src/outbound-adapter.sendpayload.test.ts deleted file mode 100644 index ae5d86f8700..00000000000 --- a/extensions/discord/src/outbound-adapter.sendpayload.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, vi } from "vitest"; -import type { ReplyPayload } from "../../../src/auto-reply/types.js"; -import { - installSendPayloadContractSuite, - primeSendMock, -} from "../../../src/test-utils/send-payload-contract.js"; -import { discordOutbound } from "./outbound-adapter.js"; - -function createHarness(params: { - payload: ReplyPayload; - sendResults?: Array<{ messageId: string }>; -}) { - const sendDiscord = vi.fn(); - primeSendMock(sendDiscord, { messageId: "dc-1", channelId: "123456" }, params.sendResults); - const ctx = { - cfg: {}, - to: "channel:123456", - text: "", - payload: params.payload, - deps: { - sendDiscord, - }, - }; - return { - run: async () => await discordOutbound.sendPayload!(ctx), - sendMock: sendDiscord, - to: ctx.to, - }; -} - -describe("discordOutbound sendPayload", () => { - installSendPayloadContractSuite({ - channel: "discord", - chunking: { mode: "passthrough", longTextLength: 3000 }, - createHarness, - }); -}); diff --git a/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts b/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts index 81f30ea1c71..52b44de49e7 100644 --- a/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts +++ b/extensions/whatsapp/src/outbound-adapter.sendpayload.test.ts @@ -1,40 +1,7 @@ import { describe, expect, it, vi } from "vitest"; -import type { ReplyPayload } from "../../../src/auto-reply/types.js"; -import { - installSendPayloadContractSuite, - primeSendMock, -} from "../../../src/test-utils/send-payload-contract.js"; import { whatsappOutbound } from "./outbound-adapter.js"; -function createHarness(params: { - payload: ReplyPayload; - sendResults?: Array<{ messageId: string }>; -}) { - const sendWhatsApp = vi.fn(); - primeSendMock(sendWhatsApp, { messageId: "wa-1" }, params.sendResults); - const ctx = { - cfg: {}, - to: "5511999999999@c.us", - text: "", - payload: params.payload, - deps: { - sendWhatsApp, - }, - }; - return { - run: async () => await whatsappOutbound.sendPayload!(ctx), - sendMock: sendWhatsApp, - to: ctx.to, - }; -} - describe("whatsappOutbound sendPayload", () => { - installSendPayloadContractSuite({ - channel: "whatsapp", - chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 }, - createHarness, - }); - it("trims leading whitespace for direct text sends", async () => { const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" })); diff --git a/extensions/zalo/src/channel.sendpayload.test.ts b/extensions/zalo/src/channel.sendpayload.test.ts deleted file mode 100644 index 27acb737f9f..00000000000 --- a/extensions/zalo/src/channel.sendpayload.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { ReplyPayload } from "openclaw/plugin-sdk/zalo"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - installSendPayloadContractSuite, - primeSendMock, -} from "../../../src/test-utils/send-payload-contract.js"; -import { zaloPlugin } from "./channel.js"; - -vi.mock("./send.js", () => ({ - sendMessageZalo: vi.fn().mockResolvedValue({ ok: true, messageId: "zl-1" }), -})); - -function baseCtx(payload: ReplyPayload) { - return { - cfg: {}, - to: "123456789", - text: "", - payload, - }; -} - -describe("zaloPlugin outbound sendPayload", () => { - let mockedSend: ReturnType>; - - beforeEach(async () => { - const mod = await import("./send.js"); - mockedSend = vi.mocked(mod.sendMessageZalo); - mockedSend.mockClear(); - mockedSend.mockResolvedValue({ ok: true, messageId: "zl-1" }); - }); - - installSendPayloadContractSuite({ - channel: "zalo", - chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 }, - createHarness: ({ payload, sendResults }) => { - primeSendMock(mockedSend, { ok: true, messageId: "zl-1" }, sendResults); - return { - run: async () => await zaloPlugin.outbound!.sendPayload!(baseCtx(payload)), - sendMock: mockedSend, - to: "123456789", - }; - }, - }); -}); diff --git a/extensions/zalouser/src/channel.sendpayload.test.ts b/extensions/zalouser/src/channel.sendpayload.test.ts index 27a8adf2c0d..2c9d5240ba9 100644 --- a/extensions/zalouser/src/channel.sendpayload.test.ts +++ b/extensions/zalouser/src/channel.sendpayload.test.ts @@ -1,10 +1,7 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser"; import { beforeEach, describe, expect, it, vi } from "vitest"; import "./accounts.test-mocks.js"; -import { - installSendPayloadContractSuite, - primeSendMock, -} from "../../../src/test-utils/send-payload-contract.js"; +import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/suites.js"; import { zalouserPlugin } from "./channel.js"; import { setZalouserRuntime } from "./runtime.js"; @@ -36,8 +33,7 @@ describe("zalouserPlugin outbound sendPayload", () => { } as never); const mod = await import("./send.js"); mockedSend = vi.mocked(mod.sendMessageZalouser); - mockedSend.mockClear(); - mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-1" }); + primeChannelOutboundSendMock(mockedSend, { ok: true, messageId: "zlu-1" }); }); it("group target delegates with isGroup=true and stripped threadId", async () => { @@ -110,19 +106,6 @@ describe("zalouserPlugin outbound sendPayload", () => { ); expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-code" }); }); - - installSendPayloadContractSuite({ - channel: "zalouser", - chunking: { mode: "passthrough", longTextLength: 3000 }, - createHarness: ({ payload, sendResults }) => { - primeSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, sendResults); - return { - run: async () => await zalouserPlugin.outbound!.sendPayload!(baseCtx(payload)), - sendMock: mockedSend, - to: "987654321", - }; - }, - }); }); describe("zalouserPlugin messaging target normalization", () => { diff --git a/src/channels/plugins/contracts/outbound-payload.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.contract.test.ts new file mode 100644 index 00000000000..5faa47893cb --- /dev/null +++ b/src/channels/plugins/contracts/outbound-payload.contract.test.ts @@ -0,0 +1,209 @@ +import { describe, vi } from "vitest"; +import { discordOutbound } from "../../../../extensions/discord/src/outbound-adapter.js"; +import { whatsappOutbound } from "../../../../extensions/whatsapp/src/outbound-adapter.js"; +import { zaloPlugin } from "../../../../extensions/zalo/src/channel.js"; +import { sendMessageZalo } from "../../../../extensions/zalo/src/send.js"; +import "./../../../../extensions/zalouser/src/accounts.test-mocks.js"; +import { zalouserPlugin } from "../../../../extensions/zalouser/src/channel.js"; +import { setZalouserRuntime } from "../../../../extensions/zalouser/src/runtime.js"; +import { sendMessageZalouser } from "../../../../extensions/zalouser/src/send.js"; +import { slackOutbound } from "../../../../test/channel-outbounds.js"; +import type { ReplyPayload } from "../../../auto-reply/types.js"; +import { createDirectTextMediaOutbound } from "../outbound/direct-text-media.js"; +import { + installChannelOutboundPayloadContractSuite, + primeChannelOutboundSendMock, +} from "./suites.js"; + +vi.mock("../../../../extensions/zalo/src/send.js", () => ({ + sendMessageZalo: vi.fn().mockResolvedValue({ ok: true, messageId: "zl-1" }), +})); + +vi.mock("../../../../extensions/zalouser/src/send.js", () => ({ + sendMessageZalouser: vi.fn().mockResolvedValue({ ok: true, messageId: "zlu-1" }), + sendReactionZalouser: vi.fn().mockResolvedValue({ ok: true }), +})); + +type PayloadHarnessParams = { + payload: ReplyPayload; + sendResults?: Array<{ messageId: string }>; +}; + +const mockedSendZalo = vi.mocked(sendMessageZalo); +const mockedSendZalouser = vi.mocked(sendMessageZalouser); + +function createSlackHarness(params: PayloadHarnessParams) { + const sendSlack = vi.fn(); + primeChannelOutboundSendMock( + sendSlack, + { messageId: "sl-1", channelId: "C12345", ts: "1234.5678" }, + params.sendResults, + ); + const ctx = { + cfg: {}, + to: "C12345", + text: "", + payload: params.payload, + deps: { + sendSlack, + }, + }; + return { + run: async () => await slackOutbound.sendPayload!(ctx), + sendMock: sendSlack, + to: ctx.to, + }; +} + +function createDiscordHarness(params: PayloadHarnessParams) { + const sendDiscord = vi.fn(); + primeChannelOutboundSendMock( + sendDiscord, + { messageId: "dc-1", channelId: "123456" }, + params.sendResults, + ); + const ctx = { + cfg: {}, + to: "channel:123456", + text: "", + payload: params.payload, + deps: { + sendDiscord, + }, + }; + return { + run: async () => await discordOutbound.sendPayload!(ctx), + sendMock: sendDiscord, + to: ctx.to, + }; +} + +function createWhatsAppHarness(params: PayloadHarnessParams) { + const sendWhatsApp = vi.fn(); + primeChannelOutboundSendMock(sendWhatsApp, { messageId: "wa-1" }, params.sendResults); + const ctx = { + cfg: {}, + to: "5511999999999@c.us", + text: "", + payload: params.payload, + deps: { + sendWhatsApp, + }, + }; + return { + run: async () => await whatsappOutbound.sendPayload!(ctx), + sendMock: sendWhatsApp, + to: ctx.to, + }; +} + +function createDirectTextMediaHarness(params: PayloadHarnessParams) { + const sendFn = vi.fn(); + primeChannelOutboundSendMock(sendFn, { messageId: "m1" }, params.sendResults); + const outbound = createDirectTextMediaOutbound({ + channel: "imessage", + resolveSender: () => sendFn, + resolveMaxBytes: () => undefined, + buildTextOptions: (opts) => opts as never, + buildMediaOptions: (opts) => opts as never, + }); + const ctx = { + cfg: {}, + to: "user1", + text: "", + payload: params.payload, + }; + return { + run: async () => await outbound.sendPayload!(ctx), + sendMock: sendFn, + to: ctx.to, + }; +} + +describe("channel outbound payload contract", () => { + describe("slack", () => { + installChannelOutboundPayloadContractSuite({ + channel: "slack", + chunking: { mode: "passthrough", longTextLength: 5000 }, + createHarness: createSlackHarness, + }); + }); + + describe("discord", () => { + installChannelOutboundPayloadContractSuite({ + channel: "discord", + chunking: { mode: "passthrough", longTextLength: 3000 }, + createHarness: createDiscordHarness, + }); + }); + + describe("whatsapp", () => { + installChannelOutboundPayloadContractSuite({ + channel: "whatsapp", + chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 }, + createHarness: createWhatsAppHarness, + }); + }); + + describe("zalo", () => { + installChannelOutboundPayloadContractSuite({ + channel: "zalo", + chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 }, + createHarness: ({ payload, sendResults }) => { + primeChannelOutboundSendMock(mockedSendZalo, { ok: true, messageId: "zl-1" }, sendResults); + return { + run: async () => + await zaloPlugin.outbound!.sendPayload!({ + cfg: {}, + to: "123456789", + text: "", + payload, + }), + sendMock: mockedSendZalo, + to: "123456789", + }; + }, + }); + }); + + describe("zalouser", () => { + installChannelOutboundPayloadContractSuite({ + channel: "zalouser", + chunking: { mode: "passthrough", longTextLength: 3000 }, + createHarness: ({ payload, sendResults }) => { + setZalouserRuntime({ + channel: { + text: { + resolveChunkMode: vi.fn(() => "length"), + resolveTextChunkLimit: vi.fn(() => 1200), + }, + }, + } as never); + primeChannelOutboundSendMock( + mockedSendZalouser, + { ok: true, messageId: "zlu-1" }, + sendResults, + ); + return { + run: async () => + await zalouserPlugin.outbound!.sendPayload!({ + cfg: {}, + to: "user:987654321", + text: "", + payload, + }), + sendMock: mockedSendZalouser, + to: "987654321", + }; + }, + }); + }); + + describe("direct-text-media", () => { + installChannelOutboundPayloadContractSuite({ + channel: "imessage", + chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 }, + createHarness: createDirectTextMediaHarness, + }); + }); +}); diff --git a/src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts b/src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts deleted file mode 100644 index 42971f1e89c..00000000000 --- a/src/channels/plugins/outbound/direct-text-media.sendpayload.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, vi } from "vitest"; -import type { ReplyPayload } from "../../../auto-reply/types.js"; -import { - installSendPayloadContractSuite, - primeSendMock, -} from "../../../test-utils/send-payload-contract.js"; -import { createDirectTextMediaOutbound } from "./direct-text-media.js"; - -function createDirectHarness(params: { - payload: ReplyPayload; - sendResults?: Array<{ messageId: string }>; -}) { - const sendFn = vi.fn(); - primeSendMock(sendFn, { messageId: "m1" }, params.sendResults); - const outbound = createDirectTextMediaOutbound({ - channel: "imessage", - resolveSender: () => sendFn, - resolveMaxBytes: () => undefined, - buildTextOptions: (opts) => opts as never, - buildMediaOptions: (opts) => opts as never, - }); - return { outbound, sendFn }; -} - -function baseCtx(payload: ReplyPayload) { - return { - cfg: {}, - to: "user1", - text: "", - payload, - }; -} - -describe("createDirectTextMediaOutbound sendPayload", () => { - installSendPayloadContractSuite({ - channel: "imessage", - chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 }, - createHarness: ({ payload, sendResults }) => { - const { outbound, sendFn } = createDirectHarness({ payload, sendResults }); - return { - run: async () => await outbound.sendPayload!(baseCtx(payload)), - sendMock: sendFn, - to: "user1", - }; - }, - }); -}); diff --git a/src/channels/plugins/outbound/slack.sendpayload.test.ts b/src/channels/plugins/outbound/slack.sendpayload.test.ts index e1175023858..a78916c1336 100644 --- a/src/channels/plugins/outbound/slack.sendpayload.test.ts +++ b/src/channels/plugins/outbound/slack.sendpayload.test.ts @@ -1,17 +1,14 @@ import { describe, expect, it, vi } from "vitest"; import { slackOutbound } from "../../../../test/channel-outbounds.js"; import type { ReplyPayload } from "../../../auto-reply/types.js"; -import { - installSendPayloadContractSuite, - primeSendMock, -} from "../../../test-utils/send-payload-contract.js"; +import { primeChannelOutboundSendMock } from "../contracts/suites.js"; function createHarness(params: { payload: ReplyPayload; sendResults?: Array<{ messageId: string }>; }) { const sendSlack = vi.fn(); - primeSendMock( + primeChannelOutboundSendMock( sendSlack, { messageId: "sl-1", channelId: "C12345", ts: "1234.5678" }, params.sendResults, @@ -33,12 +30,6 @@ function createHarness(params: { } describe("slackOutbound sendPayload", () => { - installSendPayloadContractSuite({ - channel: "slack", - chunking: { mode: "passthrough", longTextLength: 5000 }, - createHarness, - }); - it("forwards Slack blocks from channelData", async () => { const { run, sendMock, to } = createHarness({ payload: { diff --git a/src/test-utils/send-payload-contract.ts b/src/test-utils/send-payload-contract.ts deleted file mode 100644 index 5e78e406a74..00000000000 --- a/src/test-utils/send-payload-contract.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { expect, it, type Mock } from "vitest"; - -type PayloadLike = { - mediaUrl?: string; - mediaUrls?: string[]; - text?: string; -}; - -type SendResultLike = { - messageId: string; - [key: string]: unknown; -}; - -type ChunkingMode = - | { - longTextLength: number; - maxChunkLength: number; - mode: "split"; - } - | { - longTextLength: number; - mode: "passthrough"; - }; - -export function installSendPayloadContractSuite(params: { - channel: string; - chunking: ChunkingMode; - createHarness: (params: { payload: PayloadLike; sendResults?: SendResultLike[] }) => { - run: () => Promise>; - sendMock: Mock; - to: string; - }; -}) { - it("text-only delegates to sendText", async () => { - const { run, sendMock, to } = params.createHarness({ - payload: { text: "hello" }, - }); - const result = await run(); - - expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock).toHaveBeenCalledWith(to, "hello", expect.any(Object)); - expect(result).toMatchObject({ channel: params.channel }); - }); - - it("single media delegates to sendMedia", async () => { - const { run, sendMock, to } = params.createHarness({ - payload: { text: "cap", mediaUrl: "https://example.com/a.jpg" }, - }); - const result = await run(); - - expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock).toHaveBeenCalledWith( - to, - "cap", - expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }), - ); - expect(result).toMatchObject({ channel: params.channel }); - }); - - it("multi-media iterates URLs with caption on first", async () => { - const { run, sendMock, to } = params.createHarness({ - payload: { - text: "caption", - mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], - }, - sendResults: [{ messageId: "m-1" }, { messageId: "m-2" }], - }); - const result = await run(); - - expect(sendMock).toHaveBeenCalledTimes(2); - expect(sendMock).toHaveBeenNthCalledWith( - 1, - to, - "caption", - expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }), - ); - expect(sendMock).toHaveBeenNthCalledWith( - 2, - to, - "", - expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }), - ); - expect(result).toMatchObject({ channel: params.channel, messageId: "m-2" }); - }); - - it("empty payload returns no-op", async () => { - const { run, sendMock } = params.createHarness({ payload: {} }); - const result = await run(); - - expect(sendMock).not.toHaveBeenCalled(); - expect(result).toEqual({ channel: params.channel, messageId: "" }); - }); - - if (params.chunking.mode === "passthrough") { - it("text exceeding chunk limit is sent as-is when chunker is null", async () => { - const text = "a".repeat(params.chunking.longTextLength); - const { run, sendMock, to } = params.createHarness({ payload: { text } }); - const result = await run(); - - expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock).toHaveBeenCalledWith(to, text, expect.any(Object)); - expect(result).toMatchObject({ channel: params.channel }); - }); - return; - } - - const chunking = params.chunking; - - it("chunking splits long text", async () => { - const text = "a".repeat(chunking.longTextLength); - const { run, sendMock } = params.createHarness({ - payload: { text }, - sendResults: [{ messageId: "c-1" }, { messageId: "c-2" }], - }); - const result = await run(); - - expect(sendMock.mock.calls.length).toBeGreaterThanOrEqual(2); - for (const call of sendMock.mock.calls) { - expect((call[1] as string).length).toBeLessThanOrEqual(chunking.maxChunkLength); - } - expect(result).toMatchObject({ channel: params.channel }); - }); -} - -export function primeSendMock( - sendMock: Mock, - fallbackResult: Record, - sendResults: SendResultLike[] = [], -) { - sendMock.mockReset(); - if (sendResults.length === 0) { - sendMock.mockResolvedValue(fallbackResult); - return; - } - for (const result of sendResults) { - sendMock.mockResolvedValueOnce(result); - } -}