diff --git a/src/infra/outbound/directory-cache.test.ts b/src/infra/outbound/directory-cache.test.ts new file mode 100644 index 00000000000..5234662b6cf --- /dev/null +++ b/src/infra/outbound/directory-cache.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { DirectoryCache, buildDirectoryCacheKey } from "./directory-cache.js"; + +describe("buildDirectoryCacheKey", () => { + it("includes account and signature fallbacks", () => { + expect( + buildDirectoryCacheKey({ + channel: "slack", + kind: "channel", + source: "cache", + }), + ).toBe("slack:default:channel:cache:default"); + + expect( + buildDirectoryCacheKey({ + channel: "discord", + accountId: "work", + kind: "user", + source: "live", + signature: "v2", + }), + ).toBe("discord:work:user:live:v2"); + }); +}); + +describe("DirectoryCache", () => { + it("expires entries after ttl and resets when config ref changes", () => { + vi.useFakeTimers(); + const cache = new DirectoryCache(1_000); + const cfgA = {} as OpenClawConfig; + const cfgB = {} as OpenClawConfig; + + cache.set("a", "first", cfgA); + expect(cache.get("a", cfgA)).toBe("first"); + + vi.advanceTimersByTime(1_001); + expect(cache.get("a", cfgA)).toBeUndefined(); + + cache.set("b", "second", cfgA); + expect(cache.get("b", cfgB)).toBeUndefined(); + + vi.useRealTimers(); + }); + + it("evicts least-recent entries, refreshes insertion order, and clears matches", () => { + const cache = new DirectoryCache(60_000, 2); + const cfg = {} as OpenClawConfig; + + cache.set("a", "A", cfg); + cache.set("b", "B", cfg); + cache.set("a", "A2", cfg); + cache.set("c", "C", cfg); + + expect(cache.get("a", cfg)).toBe("A2"); + expect(cache.get("b", cfg)).toBeUndefined(); + expect(cache.get("c", cfg)).toBe("C"); + + cache.clearMatching((key) => key.startsWith("c")); + expect(cache.get("c", cfg)).toBeUndefined(); + + cache.clear(cfg); + expect(cache.get("a", cfg)).toBeUndefined(); + }); +}); diff --git a/src/infra/outbound/format.test.ts b/src/infra/outbound/format.test.ts new file mode 100644 index 00000000000..db30cd4c511 --- /dev/null +++ b/src/infra/outbound/format.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import { + buildOutboundDeliveryJson, + formatGatewaySummary, + formatOutboundDeliverySummary, +} from "./format.js"; + +describe("formatOutboundDeliverySummary", () => { + it("formats fallback and provider-specific detail variants", () => { + const cases = [ + { + name: "fallback telegram", + channel: "telegram" as const, + result: undefined, + expected: "✅ Sent via Telegram. Message ID: unknown", + }, + { + name: "fallback imessage", + channel: "imessage" as const, + result: undefined, + expected: "✅ Sent via iMessage. Message ID: unknown", + }, + { + name: "telegram with chat detail", + channel: "telegram" as const, + result: { + channel: "telegram" as const, + messageId: "m1", + chatId: "c1", + }, + expected: "✅ Sent via Telegram. Message ID: m1 (chat c1)", + }, + { + name: "discord with channel detail", + channel: "discord" as const, + result: { + channel: "discord" as const, + messageId: "d1", + channelId: "chan", + }, + expected: "✅ Sent via Discord. Message ID: d1 (channel chan)", + }, + { + name: "slack with room detail", + channel: "slack" as const, + result: { + channel: "slack" as const, + messageId: "s1", + roomId: "room-1", + }, + expected: "✅ Sent via Slack. Message ID: s1 (room room-1)", + }, + { + name: "msteams with conversation detail", + channel: "msteams" as const, + result: { + channel: "msteams" as const, + messageId: "t1", + conversationId: "conv-1", + }, + expected: "✅ Sent via msteams. Message ID: t1 (conversation conv-1)", + }, + ]; + + for (const testCase of cases) { + expect(formatOutboundDeliverySummary(testCase.channel, testCase.result), testCase.name).toBe( + testCase.expected, + ); + } + }); +}); + +describe("buildOutboundDeliveryJson", () => { + it("builds delivery payloads across provider-specific fields", () => { + const cases = [ + { + name: "telegram direct payload", + input: { + channel: "telegram" as const, + to: "123", + result: { channel: "telegram" as const, messageId: "m1", chatId: "c1" }, + mediaUrl: "https://example.com/a.png", + }, + expected: { + channel: "telegram", + via: "direct", + to: "123", + messageId: "m1", + mediaUrl: "https://example.com/a.png", + chatId: "c1", + }, + }, + { + name: "whatsapp metadata", + input: { + channel: "whatsapp" as const, + to: "+1", + result: { channel: "whatsapp" as const, messageId: "w1", toJid: "jid" }, + }, + expected: { + channel: "whatsapp", + via: "direct", + to: "+1", + messageId: "w1", + mediaUrl: null, + toJid: "jid", + }, + }, + { + name: "signal timestamp", + input: { + channel: "signal" as const, + to: "+1", + result: { channel: "signal" as const, messageId: "s1", timestamp: 123 }, + }, + expected: { + channel: "signal", + via: "direct", + to: "+1", + messageId: "s1", + mediaUrl: null, + timestamp: 123, + }, + }, + { + name: "gateway payload with meta and explicit via", + input: { + channel: "discord" as const, + to: "channel:1", + via: "gateway" as const, + result: { + messageId: "g1", + channelId: "1", + meta: { thread: "2" }, + }, + }, + expected: { + channel: "discord", + via: "gateway", + to: "channel:1", + messageId: "g1", + mediaUrl: null, + channelId: "1", + meta: { thread: "2" }, + }, + }, + ]; + + for (const testCase of cases) { + expect(buildOutboundDeliveryJson(testCase.input), testCase.name).toEqual(testCase.expected); + } + }); +}); + +describe("formatGatewaySummary", () => { + it("formats default and custom gateway action summaries", () => { + const cases = [ + { + name: "default send action", + input: { channel: "whatsapp", messageId: "m1" }, + expected: "✅ Sent via gateway (whatsapp). Message ID: m1", + }, + { + name: "custom action", + input: { action: "Poll sent", channel: "discord", messageId: "p1" }, + expected: "✅ Poll sent via gateway (discord). Message ID: p1", + }, + { + name: "missing channel and message id", + input: {}, + expected: "✅ Sent via gateway. Message ID: unknown", + }, + ]; + + for (const testCase of cases) { + expect(formatGatewaySummary(testCase.input), testCase.name).toBe(testCase.expected); + } + }); +}); diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index b04c0462e43..dff375cba62 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { typedCases } from "../../test-utils/typed-cases.js"; @@ -18,12 +18,6 @@ import { moveToFailed, recoverPendingDeliveries, } from "./delivery-queue.js"; -import { DirectoryCache } from "./directory-cache.js"; -import { - buildOutboundDeliveryJson, - formatGatewaySummary, - formatOutboundDeliverySummary, -} from "./format.js"; import { applyCrossContextDecoration, buildCrossContextDecoration, @@ -616,184 +610,6 @@ describe("delivery-queue", () => { }); }); -describe("DirectoryCache", () => { - const cfg = {} as OpenClawConfig; - - afterEach(() => { - vi.useRealTimers(); - }); - - it("expires entries after ttl", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - const cache = new DirectoryCache(1000, 10); - - cache.set("a", "value-a", cfg); - expect(cache.get("a", cfg)).toBe("value-a"); - - vi.setSystemTime(new Date("2026-01-01T00:00:02.000Z")); - expect(cache.get("a", cfg)).toBeUndefined(); - }); - - it("evicts least-recent entries when capacity is exceeded", () => { - const cases = [ - { - actions: [ - ["set", "a", "value-a"], - ["set", "b", "value-b"], - ["set", "c", "value-c"], - ] as const, - expected: { a: undefined, b: "value-b", c: "value-c" }, - }, - { - actions: [ - ["set", "a", "value-a"], - ["set", "b", "value-b"], - ["set", "a", "value-a2"], - ["set", "c", "value-c"], - ] as const, - expected: { a: "value-a2", b: undefined, c: "value-c" }, - }, - ]; - - for (const testCase of cases) { - const cache = new DirectoryCache(60_000, 2); - for (const action of testCase.actions) { - cache.set(action[1], action[2], cfg); - } - expect(cache.get("a", cfg)).toBe(testCase.expected.a); - expect(cache.get("b", cfg)).toBe(testCase.expected.b); - expect(cache.get("c", cfg)).toBe(testCase.expected.c); - } - }); -}); - -describe("formatOutboundDeliverySummary", () => { - it("formats fallback and channel-specific detail variants", () => { - const cases = [ - { - name: "fallback telegram", - channel: "telegram" as const, - result: undefined, - expected: "✅ Sent via Telegram. Message ID: unknown", - }, - { - name: "fallback imessage", - channel: "imessage" as const, - result: undefined, - expected: "✅ Sent via iMessage. Message ID: unknown", - }, - { - name: "telegram with chat detail", - channel: "telegram" as const, - result: { - channel: "telegram" as const, - messageId: "m1", - chatId: "c1", - }, - expected: "✅ Sent via Telegram. Message ID: m1 (chat c1)", - }, - { - name: "discord with channel detail", - channel: "discord" as const, - result: { - channel: "discord" as const, - messageId: "d1", - channelId: "chan", - }, - expected: "✅ Sent via Discord. Message ID: d1 (channel chan)", - }, - ]; - - for (const testCase of cases) { - expect(formatOutboundDeliverySummary(testCase.channel, testCase.result), testCase.name).toBe( - testCase.expected, - ); - } - }); -}); - -describe("buildOutboundDeliveryJson", () => { - it("builds direct delivery payloads across provider-specific fields", () => { - const cases = [ - { - name: "telegram direct payload", - input: { - channel: "telegram" as const, - to: "123", - result: { channel: "telegram" as const, messageId: "m1", chatId: "c1" }, - mediaUrl: "https://example.com/a.png", - }, - expected: { - channel: "telegram", - via: "direct", - to: "123", - messageId: "m1", - mediaUrl: "https://example.com/a.png", - chatId: "c1", - }, - }, - { - name: "whatsapp metadata", - input: { - channel: "whatsapp" as const, - to: "+1", - result: { channel: "whatsapp" as const, messageId: "w1", toJid: "jid" }, - }, - expected: { - channel: "whatsapp", - via: "direct", - to: "+1", - messageId: "w1", - mediaUrl: null, - toJid: "jid", - }, - }, - { - name: "signal timestamp", - input: { - channel: "signal" as const, - to: "+1", - result: { channel: "signal" as const, messageId: "s1", timestamp: 123 }, - }, - expected: { - channel: "signal", - via: "direct", - to: "+1", - messageId: "s1", - mediaUrl: null, - timestamp: 123, - }, - }, - ]; - - for (const testCase of cases) { - expect(buildOutboundDeliveryJson(testCase.input), testCase.name).toEqual(testCase.expected); - } - }); -}); - -describe("formatGatewaySummary", () => { - it("formats default and custom gateway action summaries", () => { - const cases = [ - { - name: "default send action", - input: { channel: "whatsapp", messageId: "m1" }, - expected: "✅ Sent via gateway (whatsapp). Message ID: m1", - }, - { - name: "custom action", - input: { action: "Poll sent", channel: "discord", messageId: "p1" }, - expected: "✅ Poll sent via gateway (discord). Message ID: p1", - }, - ]; - - for (const testCase of cases) { - expect(formatGatewaySummary(testCase.input), testCase.name).toBe(testCase.expected); - } - }); -}); - const slackConfig = { channels: { slack: {