Files
openclaw/extensions/nextcloud-talk/src/message-actions.test.ts
Peter Steinberger 1507a9701b refactor: centralize inbound supplemental context
* refactor: centralize inbound supplemental context

* refactor: trim supplemental finalizer typing

* docs: clarify supplemental context projection

* refactor: move inbound finalization into core

* refactor: simplify channel inbound facts

* refactor: fold supplemental media into inbound finalizer

* refactor: migrate channel inbound callers to builder

* docs: mark inbound finalizer compat types deprecated

* refactor: wire runtime turn context builder

* refactor: replace channel turn runtime API

* fix: respect discord quote visibility

* fix: avoid deprecated line dispatch helper

* refactor: deprecate channel message SDK seams

* docs: trim channel outbound SDK page

* test: migrate irc inbound assertion

* refactor: deprecate outbound SDK facades

* refactor: deprecate channel helper SDK facades

* refactor: deprecate channel streaming SDK facade

* refactor: move direct dm helpers into inbound SDK

* chore: mark legacy test-utils SDK alias deprecated

* refactor: remove unused allow-from read helper

* refactor: route remaining channel dispatch through core

* refactor: enforce modern extension SDK imports

* test: give slow image root tests more time

* ci: support node fallback on windows

* fix: add transcripts tool display metadata

* refactor: trim legacy channel test seams

* fix: preserve channel compat after rebase

* fix: keep deprecated channel inbound aliases

* fix: preserve discord thread context visibility

* fix: clean final rebase conflicts

* fix: preserve channel message dispatch aliases

* fix: sync channel refactor after rebase

* fix: sync channel refactor after latest main

* fix: dedupe memory-core subagent mock

* test: align clickclack inbound dispatch assertions

* fix: sync plugin sdk api hash after rebase

* fix: sync channel refactor after latest main

* fix: sync plugin sdk api hash after rebase

* fix: sync plugin sdk api hash after latest main

* test: remove stale inbound context awaits
2026-05-27 09:26:06 +01:00

271 lines
8.7 KiB
TypeScript

import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "./types.js";
const hoisted = vi.hoisted(() => ({
sendReactionNextcloudTalk: vi.fn(),
sendMessageNextcloudTalk: vi.fn(),
listNextcloudTalkAccountIds: vi.fn(),
resolveNextcloudTalkAccount: vi.fn(),
}));
vi.mock("./send.js", () => ({
sendReactionNextcloudTalk: hoisted.sendReactionNextcloudTalk,
sendMessageNextcloudTalk: hoisted.sendMessageNextcloudTalk,
}));
vi.mock("./accounts.js", () => ({
listNextcloudTalkAccountIds: hoisted.listNextcloudTalkAccountIds,
resolveNextcloudTalkAccount: hoisted.resolveNextcloudTalkAccount,
}));
const { nextcloudTalkMessageActions } = await import("./message-actions.js");
const configuredAccount = {
accountId: "default",
enabled: true,
baseUrl: "https://nc.example.com",
secret: "bot-secret",
} as const;
const unconfiguredAccount = {
accountId: "default",
enabled: true,
baseUrl: "",
secret: null,
} as const;
const disabledAccount = {
accountId: "default",
enabled: false,
baseUrl: "https://nc.example.com",
secret: "bot-secret",
} as const;
describe("nextcloudTalkMessageActions", () => {
beforeEach(() => {
hoisted.sendReactionNextcloudTalk.mockReset();
hoisted.sendReactionNextcloudTalk.mockResolvedValue({ ok: true });
hoisted.sendMessageNextcloudTalk.mockReset();
hoisted.listNextcloudTalkAccountIds.mockReset();
hoisted.resolveNextcloudTalkAccount.mockReset();
});
describe("describeMessageTool", () => {
it("returns null when no accounts are configured", () => {
hoisted.listNextcloudTalkAccountIds.mockReturnValue([]);
const result = nextcloudTalkMessageActions.describeMessageTool?.({
cfg: {} as OpenClawConfig,
});
expect(result).toBeNull();
});
it("returns null when configured account has no secret/baseUrl", () => {
hoisted.listNextcloudTalkAccountIds.mockReturnValue([unconfiguredAccount.accountId]);
hoisted.resolveNextcloudTalkAccount.mockReturnValue(unconfiguredAccount);
const result = nextcloudTalkMessageActions.describeMessageTool?.({
cfg: {} as OpenClawConfig,
});
expect(result).toBeNull();
});
it("returns null when the only listed account is disabled", () => {
hoisted.listNextcloudTalkAccountIds.mockReturnValue([disabledAccount.accountId]);
hoisted.resolveNextcloudTalkAccount.mockReturnValue(disabledAccount);
const result = nextcloudTalkMessageActions.describeMessageTool?.({
cfg: {} as OpenClawConfig,
});
expect(result).toBeNull();
});
it("advertises send + react when an account is configured", () => {
hoisted.listNextcloudTalkAccountIds.mockReturnValue([configuredAccount.accountId]);
hoisted.resolveNextcloudTalkAccount.mockReturnValue(configuredAccount);
const result = nextcloudTalkMessageActions.describeMessageTool?.({
cfg: {} as OpenClawConfig,
});
expect(result?.actions).toEqual(["send", "react"]);
});
it("scopes discovery to a specific accountId when provided", () => {
hoisted.resolveNextcloudTalkAccount.mockReturnValue(configuredAccount);
const result = nextcloudTalkMessageActions.describeMessageTool?.({
cfg: {} as OpenClawConfig,
accountId: "work",
});
expect(hoisted.resolveNextcloudTalkAccount).toHaveBeenCalledWith({
cfg: {},
accountId: "work",
});
expect(hoisted.listNextcloudTalkAccountIds).not.toHaveBeenCalled();
expect(result?.actions).toEqual(["send", "react"]);
});
it("returns null when the targeted account is disabled", () => {
hoisted.resolveNextcloudTalkAccount.mockReturnValue(disabledAccount);
const result = nextcloudTalkMessageActions.describeMessageTool?.({
cfg: {} as OpenClawConfig,
accountId: "work",
});
expect(result).toBeNull();
});
});
describe("supportsAction", () => {
it("delegates send back to outbound", () => {
expect(nextcloudTalkMessageActions.supportsAction?.({ action: "send" })).toBe(false);
});
it("handles react locally", () => {
expect(nextcloudTalkMessageActions.supportsAction?.({ action: "react" })).toBe(true);
});
});
describe("handleAction", () => {
const cfg = {} as CoreConfig;
it("invokes sendReactionNextcloudTalk with normalized params for the react action", async () => {
const result = await nextcloudTalkMessageActions.handleAction?.({
channel: "nextcloud-talk",
action: "react",
params: { to: "room:abc123", messageId: "42", emoji: "👍" },
cfg,
accountId: "work",
});
expect(hoisted.sendReactionNextcloudTalk).toHaveBeenCalledTimes(1);
expect(hoisted.sendReactionNextcloudTalk).toHaveBeenCalledWith("room:abc123", "42", "👍", {
accountId: "work",
cfg,
});
expect(result).toMatchObject({
details: { ok: true, added: "👍" },
});
});
it("uses toolContext.currentMessageId when params.messageId is missing", async () => {
await nextcloudTalkMessageActions.handleAction?.({
channel: "nextcloud-talk",
action: "react",
params: { to: "room:abc123", emoji: "✅" },
cfg,
accountId: null,
toolContext: { currentMessageId: 99 },
});
expect(hoisted.sendReactionNextcloudTalk).toHaveBeenCalledWith("room:abc123", "99", "✅", {
accountId: undefined,
cfg,
});
});
it("requires a target room token", async () => {
await expect(
nextcloudTalkMessageActions.handleAction?.({
channel: "nextcloud-talk",
action: "react",
params: { messageId: "1", emoji: "👍" },
cfg,
}),
).rejects.toThrow(/to \(room token\) required/);
expect(hoisted.sendReactionNextcloudTalk).not.toHaveBeenCalled();
});
it("requires a messageId (explicit or via toolContext)", async () => {
await expect(
nextcloudTalkMessageActions.handleAction?.({
channel: "nextcloud-talk",
action: "react",
params: { to: "room:abc123", emoji: "👍" },
cfg,
}),
).rejects.toThrow(/messageId required/);
expect(hoisted.sendReactionNextcloudTalk).not.toHaveBeenCalled();
});
it("requires an emoji", async () => {
await expect(
nextcloudTalkMessageActions.handleAction?.({
channel: "nextcloud-talk",
action: "react",
params: { to: "room:abc123", messageId: "1" },
cfg,
}),
).rejects.toThrow(/emoji required/);
expect(hoisted.sendReactionNextcloudTalk).not.toHaveBeenCalled();
});
it("rejects send through the action handler (outbound owns send)", async () => {
await expect(
nextcloudTalkMessageActions.handleAction?.({
channel: "nextcloud-talk",
action: "send",
params: { to: "room:abc123", text: "hi" },
cfg,
}),
).rejects.toThrow(/handled by outbound/);
});
it("rejects unsupported actions", async () => {
await expect(
nextcloudTalkMessageActions.handleAction?.({
channel: "nextcloud-talk",
action: "delete",
params: {},
cfg,
}),
).rejects.toThrow(/Action delete not supported for nextcloud-talk/);
});
it("rejects reaction removal requests without calling the add-reaction sender", async () => {
await expect(
nextcloudTalkMessageActions.handleAction?.({
channel: "nextcloud-talk",
action: "react",
params: { to: "room:abc123", messageId: "1", emoji: "👍", remove: true },
cfg,
}),
).rejects.toThrow(/removal is not supported/);
expect(hoisted.sendReactionNextcloudTalk).not.toHaveBeenCalled();
});
it("still adds the reaction when remove is explicitly false", async () => {
await nextcloudTalkMessageActions.handleAction?.({
channel: "nextcloud-talk",
action: "react",
params: { to: "room:abc123", messageId: "1", emoji: "👍", remove: false },
cfg,
});
expect(hoisted.sendReactionNextcloudTalk).toHaveBeenCalledTimes(1);
});
it("propagates errors from sendReactionNextcloudTalk", async () => {
hoisted.sendReactionNextcloudTalk.mockRejectedValueOnce(
new Error("Nextcloud Talk reaction failed: 403 forbidden"),
);
await expect(
nextcloudTalkMessageActions.handleAction?.({
channel: "nextcloud-talk",
action: "react",
params: { to: "room:abc123", messageId: "1", emoji: "👍" },
cfg,
}),
).rejects.toThrow(/403 forbidden/);
});
});
});