Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
607
extensions/twitch/src/outbound.test.ts
Normal file
607
extensions/twitch/src/outbound.test.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* Tests for outbound.ts module
|
||||
*
|
||||
* Tests cover:
|
||||
* - resolveTarget with various modes (explicit, implicit, heartbeat)
|
||||
* - sendText with markdown stripping
|
||||
* - sendMedia delegation to sendText
|
||||
* - Error handling for missing accounts/channels
|
||||
* - Abort signal handling
|
||||
*/
|
||||
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
verifyChannelMessageAdapterCapabilityProofs,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveTwitchAccountContext } from "./config.js";
|
||||
import { twitchMessageAdapter, twitchOutbound } from "./outbound.js";
|
||||
import {
|
||||
BASE_TWITCH_TEST_ACCOUNT,
|
||||
installTwitchTestHooks,
|
||||
makeTwitchTestConfig,
|
||||
} from "./test-fixtures.js";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("./config.js", () => ({
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
resolveTwitchAccountContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageTwitchInternal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./utils/markdown.js", () => ({
|
||||
chunkTextForTwitch: vi.fn(chunkMockTextForTwitch),
|
||||
}));
|
||||
|
||||
vi.mock("./utils/twitch.js", () => ({
|
||||
normalizeTwitchChannel: (channel: string) => channel.toLowerCase().replace(/^#/, ""),
|
||||
missingTargetError: (channel: string, hint: string) =>
|
||||
new Error(`Missing target for ${channel}. Provide ${hint}`),
|
||||
}));
|
||||
|
||||
function chunkMockTextForTwitch(text: string): string[] {
|
||||
const chunks: string[] = [];
|
||||
for (const chunk of text.split(/(.{500})/)) {
|
||||
if (chunk.length > 0) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function assertResolvedTarget(
|
||||
result: ReturnType<NonNullable<typeof twitchOutbound.resolveTarget>>,
|
||||
): string {
|
||||
if (!result.ok) {
|
||||
throw result.error;
|
||||
}
|
||||
return result.to;
|
||||
}
|
||||
|
||||
function expectTargetError(
|
||||
resolveTarget: NonNullable<typeof twitchOutbound.resolveTarget>,
|
||||
params: Parameters<NonNullable<typeof twitchOutbound.resolveTarget>>[0],
|
||||
expectedMessage: string,
|
||||
) {
|
||||
const result = resolveTarget(params);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("expected resolveTarget to fail");
|
||||
}
|
||||
expect(result.error.message).toContain(expectedMessage);
|
||||
}
|
||||
|
||||
function twitchTestReceipt(messageId: string) {
|
||||
return createMessageReceiptFromOutboundResults({
|
||||
results: [
|
||||
{
|
||||
channel: "twitch",
|
||||
conversationId: "testchannel",
|
||||
messageId,
|
||||
},
|
||||
],
|
||||
kind: "text",
|
||||
});
|
||||
}
|
||||
|
||||
describe("outbound", () => {
|
||||
const mockAccount = {
|
||||
...BASE_TWITCH_TEST_ACCOUNT,
|
||||
accessToken: "oauth:test123",
|
||||
};
|
||||
const resolveTarget = twitchOutbound.resolveTarget!;
|
||||
|
||||
const mockConfig = makeTwitchTestConfig(mockAccount);
|
||||
installTwitchTestHooks();
|
||||
|
||||
function setupAccountContext(params?: {
|
||||
account?: typeof mockAccount | null;
|
||||
availableAccountIds?: string[];
|
||||
}) {
|
||||
const account = params?.account === undefined ? mockAccount : params.account;
|
||||
vi.mocked(resolveTwitchAccountContext).mockImplementation((_cfg, accountId) => ({
|
||||
accountId: accountId?.trim() || "default",
|
||||
account,
|
||||
tokenResolution: { source: "config", token: account?.accessToken ?? "" },
|
||||
configured: account !== null,
|
||||
availableAccountIds: params?.availableAccountIds ?? ["default"],
|
||||
}));
|
||||
}
|
||||
|
||||
const abortedSendCases = [
|
||||
{
|
||||
name: "sendText",
|
||||
invoke: (signal: AbortSignal) =>
|
||||
twitchOutbound.sendText!({
|
||||
cfg: mockConfig,
|
||||
to: "#testchannel",
|
||||
text: "Hello!",
|
||||
accountId: "default",
|
||||
signal,
|
||||
} as Parameters<NonNullable<typeof twitchOutbound.sendText>>[0]),
|
||||
},
|
||||
{
|
||||
name: "sendMedia",
|
||||
invoke: (signal: AbortSignal) =>
|
||||
twitchOutbound.sendMedia!({
|
||||
cfg: mockConfig,
|
||||
to: "#testchannel",
|
||||
text: "Check this:",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
accountId: "default",
|
||||
signal,
|
||||
} as Parameters<NonNullable<typeof twitchOutbound.sendMedia>>[0]),
|
||||
},
|
||||
];
|
||||
|
||||
describe("abort handling", () => {
|
||||
it.each(abortedSendCases)("$name should handle abort signal", async ({ invoke }) => {
|
||||
const abortController = new AbortController();
|
||||
abortController.abort();
|
||||
|
||||
await expect(invoke(abortController.signal)).rejects.toThrow("Outbound delivery aborted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have direct delivery mode", () => {
|
||||
expect(twitchOutbound.deliveryMode).toBe("direct");
|
||||
});
|
||||
|
||||
it("should have 500 character text chunk limit", () => {
|
||||
expect(twitchOutbound.textChunkLimit).toBe(500);
|
||||
});
|
||||
|
||||
it("should chunk long messages at 500 characters", () => {
|
||||
const chunker = twitchOutbound.chunker;
|
||||
if (!chunker) {
|
||||
throw new Error("twitch outbound.chunker unavailable");
|
||||
}
|
||||
|
||||
expect(chunker("a".repeat(600), 500)).toEqual(["a".repeat(500), "a".repeat(100)]);
|
||||
});
|
||||
|
||||
it("declares message adapter durable text and media with receipt proofs", async () => {
|
||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||
|
||||
setupAccountContext();
|
||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||
ok: true,
|
||||
messageId: "twitch-msg-123",
|
||||
receipt: twitchTestReceipt("twitch-msg-123"),
|
||||
});
|
||||
|
||||
const proofResults = await verifyChannelMessageAdapterCapabilityProofs({
|
||||
adapterName: "twitch",
|
||||
adapter: twitchMessageAdapter,
|
||||
proofs: {
|
||||
text: async () => {
|
||||
const result = await twitchMessageAdapter.send?.text?.({
|
||||
cfg: mockConfig,
|
||||
to: "#testchannel",
|
||||
text: "Hello Twitch!",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(result?.receipt?.platformMessageIds).toEqual(["twitch-msg-123"]);
|
||||
},
|
||||
media: async () => {
|
||||
const result = await twitchMessageAdapter.send?.media?.({
|
||||
cfg: mockConfig,
|
||||
to: "#testchannel",
|
||||
text: "image",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(result?.receipt?.platformMessageIds).toEqual(["twitch-msg-123"]);
|
||||
expect(sendMessageTwitchInternal).toHaveBeenLastCalledWith(
|
||||
"testchannel",
|
||||
"image https://example.com/image.png",
|
||||
mockConfig,
|
||||
"default",
|
||||
true,
|
||||
console,
|
||||
);
|
||||
},
|
||||
messageSendingHooks: () => {
|
||||
expect(twitchMessageAdapter.durableFinal?.capabilities?.messageSendingHooks).toBe(true);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(proofResults).toEqual([
|
||||
{ capability: "text", status: "verified" },
|
||||
{ capability: "media", status: "verified" },
|
||||
{ capability: "poll", status: "not_declared" },
|
||||
{ capability: "payload", status: "not_declared" },
|
||||
{ capability: "silent", status: "not_declared" },
|
||||
{ capability: "replyTo", status: "not_declared" },
|
||||
{ capability: "thread", status: "not_declared" },
|
||||
{ capability: "nativeQuote", status: "not_declared" },
|
||||
{ capability: "messageSendingHooks", status: "verified" },
|
||||
{ capability: "batch", status: "not_declared" },
|
||||
{ capability: "reconcileUnknownSend", status: "not_declared" },
|
||||
{ capability: "afterSendSuccess", status: "not_declared" },
|
||||
{ capability: "afterCommit", status: "not_declared" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("adapts outbound progress into message receipts", async () => {
|
||||
const progress = {
|
||||
channel: "twitch",
|
||||
messageId: "twitch-progress-1",
|
||||
receipt: twitchTestReceipt("twitch-progress-1"),
|
||||
};
|
||||
const sendText = twitchOutbound.sendText;
|
||||
if (!sendText) {
|
||||
throw new Error("Twitch text sending is not available.");
|
||||
}
|
||||
const sendSpy = vi.spyOn(twitchOutbound, "sendText").mockImplementationOnce(async (ctx) => {
|
||||
await ctx.onDeliveryResult?.(progress);
|
||||
return progress;
|
||||
});
|
||||
const onDeliveryResult = vi.fn();
|
||||
|
||||
try {
|
||||
await twitchMessageAdapter.send?.text?.({
|
||||
cfg: mockConfig,
|
||||
to: "#testchannel",
|
||||
text: "Hello Twitch!",
|
||||
accountId: "default",
|
||||
onDeliveryResult,
|
||||
});
|
||||
} finally {
|
||||
sendSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(onDeliveryResult).toHaveBeenCalledOnce();
|
||||
expect(onDeliveryResult.mock.calls[0]?.[0]?.receipt.platformMessageIds).toEqual([
|
||||
"twitch-progress-1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTarget", () => {
|
||||
it("should normalize and return target in explicit mode", () => {
|
||||
const result = resolveTarget({
|
||||
to: "#MyChannel",
|
||||
mode: "explicit",
|
||||
allowFrom: [],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(assertResolvedTarget(result)).toBe("mychannel");
|
||||
});
|
||||
|
||||
it("should return target in implicit mode with wildcard allowlist", () => {
|
||||
const result = resolveTarget({
|
||||
to: "#AnyChannel",
|
||||
mode: "implicit",
|
||||
allowFrom: ["*"],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(assertResolvedTarget(result)).toBe("anychannel");
|
||||
});
|
||||
|
||||
it("should return target in implicit mode when in allowlist", () => {
|
||||
const result = resolveTarget({
|
||||
to: "#allowed",
|
||||
mode: "implicit",
|
||||
allowFrom: ["#allowed", "#other"],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(assertResolvedTarget(result)).toBe("allowed");
|
||||
});
|
||||
|
||||
it("should error when target not in allowlist (implicit mode)", () => {
|
||||
expectTargetError(
|
||||
resolveTarget,
|
||||
{
|
||||
to: "#notallowed",
|
||||
mode: "implicit",
|
||||
allowFrom: ["#primary", "#secondary"],
|
||||
},
|
||||
"Twitch",
|
||||
);
|
||||
});
|
||||
|
||||
it("should accept any target when allowlist is empty", () => {
|
||||
const result = resolveTarget({
|
||||
to: "#anychannel",
|
||||
mode: "heartbeat",
|
||||
allowFrom: [],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(assertResolvedTarget(result)).toBe("anychannel");
|
||||
});
|
||||
|
||||
it("should error when no target provided with allowlist", () => {
|
||||
expectTargetError(
|
||||
resolveTarget,
|
||||
{
|
||||
to: undefined,
|
||||
mode: "implicit",
|
||||
allowFrom: ["#fallback", "#other"],
|
||||
},
|
||||
"Twitch",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return error when no target and no allowlist", () => {
|
||||
expectTargetError(
|
||||
resolveTarget,
|
||||
{
|
||||
to: undefined,
|
||||
mode: "explicit",
|
||||
allowFrom: [],
|
||||
},
|
||||
"Missing target",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle whitespace-only target", () => {
|
||||
expectTargetError(
|
||||
resolveTarget,
|
||||
{
|
||||
to: " ",
|
||||
mode: "explicit",
|
||||
allowFrom: [],
|
||||
},
|
||||
"Missing target",
|
||||
);
|
||||
});
|
||||
|
||||
it("should error when target normalizes to empty string", () => {
|
||||
expectTargetError(
|
||||
resolveTarget,
|
||||
{
|
||||
to: "#",
|
||||
mode: "explicit",
|
||||
allowFrom: [],
|
||||
},
|
||||
"Twitch",
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter wildcard from allowlist when checking membership", () => {
|
||||
const result = resolveTarget({
|
||||
to: "#mychannel",
|
||||
mode: "implicit",
|
||||
allowFrom: ["*", "#specific"],
|
||||
});
|
||||
|
||||
// With wildcard, any target is accepted
|
||||
expect(result.ok).toBe(true);
|
||||
expect(assertResolvedTarget(result)).toBe("mychannel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendText", () => {
|
||||
it("should send message successfully", async () => {
|
||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||
|
||||
setupAccountContext();
|
||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||
ok: true,
|
||||
messageId: "twitch-msg-123",
|
||||
receipt: twitchTestReceipt("twitch-msg-123"),
|
||||
});
|
||||
|
||||
const result = await twitchOutbound.sendText!({
|
||||
cfg: mockConfig,
|
||||
to: "#testchannel",
|
||||
text: "Hello Twitch!",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(result.channel).toBe("twitch");
|
||||
expect(result.messageId).toBe("twitch-msg-123");
|
||||
expect(result.receipt?.platformMessageIds).toEqual(["twitch-msg-123"]);
|
||||
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
||||
"testchannel",
|
||||
"Hello Twitch!",
|
||||
mockConfig,
|
||||
"default",
|
||||
true,
|
||||
console,
|
||||
);
|
||||
expect(result.timestamp).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should throw when account not found", async () => {
|
||||
setupAccountContext({ account: null });
|
||||
|
||||
await expect(
|
||||
twitchOutbound.sendText!({
|
||||
cfg: mockConfig,
|
||||
to: "#testchannel",
|
||||
text: "Hello!",
|
||||
accountId: "nonexistent",
|
||||
}),
|
||||
).rejects.toThrow("Twitch account not found: nonexistent");
|
||||
});
|
||||
|
||||
it("should throw when no channel specified", async () => {
|
||||
const accountWithoutChannel = { ...mockAccount, channel: undefined as unknown as string };
|
||||
setupAccountContext({ account: accountWithoutChannel });
|
||||
|
||||
await expect(
|
||||
twitchOutbound.sendText!({
|
||||
cfg: mockConfig,
|
||||
to: "",
|
||||
text: "Hello!",
|
||||
accountId: "default",
|
||||
}),
|
||||
).rejects.toThrow("No channel specified");
|
||||
});
|
||||
|
||||
it("should use account channel when target not provided", async () => {
|
||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||
|
||||
setupAccountContext();
|
||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||
ok: true,
|
||||
messageId: "msg-456",
|
||||
receipt: twitchTestReceipt("msg-456"),
|
||||
});
|
||||
|
||||
await twitchOutbound.sendText!({
|
||||
cfg: mockConfig,
|
||||
to: "",
|
||||
text: "Hello!",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
||||
"testchannel",
|
||||
"Hello!",
|
||||
mockConfig,
|
||||
"default",
|
||||
true,
|
||||
console,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when accountId is omitted", async () => {
|
||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||
|
||||
vi.mocked(resolveTwitchAccountContext)
|
||||
.mockImplementationOnce(() => ({
|
||||
accountId: "secondary",
|
||||
account: {
|
||||
...mockAccount,
|
||||
channel: "secondary-channel",
|
||||
},
|
||||
tokenResolution: { source: "config", token: mockAccount.accessToken },
|
||||
configured: true,
|
||||
availableAccountIds: ["default", "secondary"],
|
||||
}))
|
||||
.mockImplementation((_cfg, accountId) => ({
|
||||
accountId: accountId?.trim() || "secondary",
|
||||
account: {
|
||||
...mockAccount,
|
||||
channel: "secondary-channel",
|
||||
},
|
||||
tokenResolution: { source: "config", token: mockAccount.accessToken },
|
||||
configured: true,
|
||||
availableAccountIds: ["default", "secondary"],
|
||||
}));
|
||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||
ok: true,
|
||||
messageId: "msg-secondary",
|
||||
receipt: twitchTestReceipt("msg-secondary"),
|
||||
});
|
||||
|
||||
const defaultAccountConfig = {
|
||||
channels: {
|
||||
twitch: {
|
||||
defaultAccount: "secondary",
|
||||
},
|
||||
},
|
||||
} as typeof mockConfig;
|
||||
|
||||
await twitchOutbound.sendText!({
|
||||
cfg: defaultAccountConfig,
|
||||
to: "#secondary-channel",
|
||||
text: "Hello!",
|
||||
});
|
||||
|
||||
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
||||
"secondary-channel",
|
||||
"Hello!",
|
||||
defaultAccountConfig,
|
||||
"secondary",
|
||||
true,
|
||||
console,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw on send failure", async () => {
|
||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||
|
||||
setupAccountContext();
|
||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||
ok: false,
|
||||
messageId: "failed-msg",
|
||||
receipt: createMessageReceiptFromOutboundResults({ results: [] }),
|
||||
error: "Connection lost",
|
||||
});
|
||||
|
||||
await expect(
|
||||
twitchOutbound.sendText!({
|
||||
cfg: mockConfig,
|
||||
to: "#testchannel",
|
||||
text: "Hello!",
|
||||
accountId: "default",
|
||||
}),
|
||||
).rejects.toThrow("Connection lost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMedia", () => {
|
||||
it("should combine text and media URL", async () => {
|
||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||
|
||||
setupAccountContext();
|
||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||
ok: true,
|
||||
messageId: "media-msg-123",
|
||||
receipt: twitchTestReceipt("media-msg-123"),
|
||||
});
|
||||
|
||||
const result = await twitchOutbound.sendMedia!({
|
||||
cfg: mockConfig,
|
||||
to: "#testchannel",
|
||||
text: "Check this:",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(result.channel).toBe("twitch");
|
||||
expect(result.messageId).toBe("media-msg-123");
|
||||
expect(result.receipt?.platformMessageIds).toEqual(["media-msg-123"]);
|
||||
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
||||
"testchannel",
|
||||
"Check this: https://example.com/image.png",
|
||||
mockConfig,
|
||||
"default",
|
||||
true,
|
||||
console,
|
||||
);
|
||||
});
|
||||
|
||||
it("should send media URL only when no text", async () => {
|
||||
const { sendMessageTwitchInternal } = await import("./send.js");
|
||||
|
||||
setupAccountContext();
|
||||
vi.mocked(sendMessageTwitchInternal).mockResolvedValue({
|
||||
ok: true,
|
||||
messageId: "media-only-msg",
|
||||
receipt: twitchTestReceipt("media-only-msg"),
|
||||
});
|
||||
|
||||
await twitchOutbound.sendMedia!({
|
||||
cfg: mockConfig,
|
||||
to: "#testchannel",
|
||||
text: "",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(sendMessageTwitchInternal).toHaveBeenCalledWith(
|
||||
"testchannel",
|
||||
"https://example.com/image.png",
|
||||
mockConfig,
|
||||
"default",
|
||||
true,
|
||||
console,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user