Skip to content

Commit a40f781

Browse files
committed
test(mattermost): cover slash and resources
1 parent 383d5ac commit a40f781

File tree

2 files changed

+306
-0
lines changed

2 files changed

+306
-0
lines changed
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
const fetchMattermostChannel = vi.hoisted(() => vi.fn());
4+
const fetchMattermostUser = vi.hoisted(() => vi.fn());
5+
const sendMattermostTyping = vi.hoisted(() => vi.fn());
6+
const updateMattermostPost = vi.hoisted(() => vi.fn());
7+
const buildButtonProps = vi.hoisted(() => vi.fn());
8+
9+
vi.mock("./client.js", () => ({
10+
fetchMattermostChannel,
11+
fetchMattermostUser,
12+
sendMattermostTyping,
13+
updateMattermostPost,
14+
}));
15+
16+
vi.mock("./interactions.js", () => ({
17+
buildButtonProps,
18+
}));
19+
20+
describe("mattermost monitor resources", () => {
21+
it("downloads media, preserves auth headers, and infers media kind", async () => {
22+
const fetchRemoteMedia = vi.fn(async () => ({
23+
buffer: new Uint8Array([1, 2, 3]),
24+
contentType: "image/png",
25+
}));
26+
const saveMediaBuffer = vi.fn(async () => ({
27+
path: "/tmp/file.png",
28+
contentType: "image/png",
29+
}));
30+
const { createMattermostMonitorResources } = await import("./monitor-resources.js");
31+
32+
const resources = createMattermostMonitorResources({
33+
accountId: "default",
34+
callbackUrl: "https://openclaw.test/callback",
35+
client: {
36+
apiBaseUrl: "https://chat.example.com/api/v4",
37+
baseUrl: "https://chat.example.com",
38+
token: "bot-token",
39+
} as never,
40+
logger: {},
41+
mediaMaxBytes: 1024,
42+
fetchRemoteMedia,
43+
saveMediaBuffer,
44+
mediaKindFromMime: () => "image",
45+
});
46+
47+
await expect(resources.resolveMattermostMedia([" file-1 "])).resolves.toEqual([
48+
{
49+
path: "/tmp/file.png",
50+
contentType: "image/png",
51+
kind: "image",
52+
},
53+
]);
54+
55+
expect(fetchRemoteMedia).toHaveBeenCalledWith({
56+
url: "https://chat.example.com/api/v4/files/file-1",
57+
requestInit: {
58+
headers: {
59+
Authorization: "Bearer bot-token",
60+
},
61+
},
62+
filePathHint: "file-1",
63+
maxBytes: 1024,
64+
ssrfPolicy: { allowedHostnames: ["chat.example.com"] },
65+
});
66+
});
67+
68+
it("caches channel and user lookups and falls back to empty picker props", async () => {
69+
fetchMattermostChannel.mockResolvedValue({ id: "chan-1", name: "town-square" });
70+
fetchMattermostUser.mockResolvedValue({ id: "user-1", username: "alice" });
71+
buildButtonProps.mockReturnValue(undefined);
72+
const { createMattermostMonitorResources } = await import("./monitor-resources.js");
73+
74+
const resources = createMattermostMonitorResources({
75+
accountId: "default",
76+
callbackUrl: "https://openclaw.test/callback",
77+
client: {} as never,
78+
logger: {},
79+
mediaMaxBytes: 1024,
80+
fetchRemoteMedia: vi.fn(),
81+
saveMediaBuffer: vi.fn(),
82+
mediaKindFromMime: () => "document",
83+
});
84+
85+
await expect(resources.resolveChannelInfo("chan-1")).resolves.toEqual({
86+
id: "chan-1",
87+
name: "town-square",
88+
});
89+
await expect(resources.resolveChannelInfo("chan-1")).resolves.toEqual({
90+
id: "chan-1",
91+
name: "town-square",
92+
});
93+
await expect(resources.resolveUserInfo("user-1")).resolves.toEqual({
94+
id: "user-1",
95+
username: "alice",
96+
});
97+
await expect(resources.resolveUserInfo("user-1")).resolves.toEqual({
98+
id: "user-1",
99+
username: "alice",
100+
});
101+
102+
expect(fetchMattermostChannel).toHaveBeenCalledTimes(1);
103+
expect(fetchMattermostUser).toHaveBeenCalledTimes(1);
104+
105+
await resources.updateModelPickerPost({
106+
channelId: "chan-1",
107+
postId: "post-1",
108+
message: "Pick a model",
109+
});
110+
111+
expect(updateMattermostPost).toHaveBeenCalledWith(
112+
{},
113+
"post-1",
114+
expect.objectContaining({
115+
message: "Pick a model",
116+
props: { attachments: [] },
117+
}),
118+
);
119+
});
120+
121+
it("proxies typing indicators to the mattermost client helper", async () => {
122+
const { createMattermostMonitorResources } = await import("./monitor-resources.js");
123+
const client = {} as never;
124+
125+
const resources = createMattermostMonitorResources({
126+
accountId: "default",
127+
callbackUrl: "https://openclaw.test/callback",
128+
client,
129+
logger: {},
130+
mediaMaxBytes: 1024,
131+
fetchRemoteMedia: vi.fn(),
132+
saveMediaBuffer: vi.fn(),
133+
mediaKindFromMime: () => "document",
134+
});
135+
136+
await resources.sendTypingIndicator("chan-1", "root-1");
137+
expect(sendMattermostTyping).toHaveBeenCalledWith(client, {
138+
channelId: "chan-1",
139+
parentId: "root-1",
140+
});
141+
});
142+
});
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
3+
const listSkillCommandsForAgents = vi.hoisted(() => vi.fn());
4+
const parseStrictPositiveInteger = vi.hoisted(() => vi.fn());
5+
const fetchMattermostUserTeams = vi.hoisted(() => vi.fn());
6+
const normalizeMattermostBaseUrl = vi.hoisted(() => vi.fn((value: string | undefined) => value));
7+
const isSlashCommandsEnabled = vi.hoisted(() => vi.fn());
8+
const registerSlashCommands = vi.hoisted(() => vi.fn());
9+
const resolveCallbackUrl = vi.hoisted(() => vi.fn());
10+
const resolveSlashCommandConfig = vi.hoisted(() => vi.fn());
11+
const activateSlashCommands = vi.hoisted(() => vi.fn());
12+
13+
vi.mock("../runtime-api.js", () => ({
14+
listSkillCommandsForAgents,
15+
parseStrictPositiveInteger,
16+
}));
17+
18+
vi.mock("./client.js", () => ({
19+
fetchMattermostUserTeams,
20+
normalizeMattermostBaseUrl,
21+
}));
22+
23+
vi.mock("./slash-commands.js", () => ({
24+
DEFAULT_COMMAND_SPECS: [
25+
{ trigger: "ping", description: "ping" },
26+
{ trigger: "ping", description: "duplicate" },
27+
],
28+
isSlashCommandsEnabled,
29+
registerSlashCommands,
30+
resolveCallbackUrl,
31+
resolveSlashCommandConfig,
32+
}));
33+
34+
vi.mock("./slash-state.js", () => ({
35+
activateSlashCommands,
36+
}));
37+
38+
describe("mattermost monitor slash", () => {
39+
afterEach(() => {
40+
vi.unstubAllEnvs();
41+
});
42+
43+
it("returns early when slash commands are disabled", async () => {
44+
resolveSlashCommandConfig.mockReturnValue({ enabled: false });
45+
isSlashCommandsEnabled.mockReturnValue(false);
46+
const { registerMattermostMonitorSlashCommands } = await import("./monitor-slash.js");
47+
48+
await registerMattermostMonitorSlashCommands({
49+
client: {} as never,
50+
cfg: {} as never,
51+
runtime: {} as never,
52+
account: { config: {} } as never,
53+
baseUrl: "https://chat.example.com",
54+
botUserId: "bot-user",
55+
});
56+
57+
expect(fetchMattermostUserTeams).not.toHaveBeenCalled();
58+
expect(activateSlashCommands).not.toHaveBeenCalled();
59+
});
60+
61+
it("registers deduped default and native skill commands across teams", async () => {
62+
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18888");
63+
resolveSlashCommandConfig.mockReturnValue({ enabled: true, nativeSkills: true });
64+
isSlashCommandsEnabled.mockReturnValue(true);
65+
parseStrictPositiveInteger.mockReturnValue(18888);
66+
fetchMattermostUserTeams.mockResolvedValue([{ id: "team-1" }, { id: "team-2" }]);
67+
resolveCallbackUrl.mockReturnValue("https://openclaw.test/slash");
68+
listSkillCommandsForAgents.mockReturnValue([
69+
{ name: "skill", description: "Skill run" },
70+
{ name: "oc_ping", description: "Already prefixed" },
71+
{ name: " ", description: "ignored" },
72+
]);
73+
registerSlashCommands
74+
.mockResolvedValueOnce([{ token: "token-1", trigger: "ping" }])
75+
.mockResolvedValueOnce([{ token: "token-2", trigger: "oc_skill" }]);
76+
const runtime = {
77+
log: vi.fn(),
78+
error: vi.fn(),
79+
};
80+
81+
const { registerMattermostMonitorSlashCommands } = await import("./monitor-slash.js");
82+
83+
await registerMattermostMonitorSlashCommands({
84+
client: {} as never,
85+
cfg: { gateway: { port: 18789 } } as never,
86+
runtime: runtime as never,
87+
account: { config: { commands: {} }, accountId: "default" } as never,
88+
baseUrl: "https://chat.example.com",
89+
botUserId: "bot-user",
90+
});
91+
92+
expect(registerSlashCommands).toHaveBeenCalledTimes(2);
93+
expect(registerSlashCommands.mock.calls[0]?.[0]).toMatchObject({
94+
teamId: "team-1",
95+
creatorUserId: "bot-user",
96+
callbackUrl: "https://openclaw.test/slash",
97+
});
98+
expect(registerSlashCommands.mock.calls[0]?.[0].commands).toEqual([
99+
{ trigger: "ping", description: "ping" },
100+
{
101+
trigger: "oc_skill",
102+
description: "Skill run",
103+
autoComplete: true,
104+
autoCompleteHint: "[args]",
105+
originalName: "skill",
106+
},
107+
{
108+
trigger: "oc_ping",
109+
description: "Already prefixed",
110+
autoComplete: true,
111+
autoCompleteHint: "[args]",
112+
originalName: "oc_ping",
113+
},
114+
]);
115+
expect(activateSlashCommands).toHaveBeenCalledWith(
116+
expect.objectContaining({
117+
commandTokens: ["token-1", "token-2"],
118+
triggerMap: new Map([
119+
["oc_skill", "skill"],
120+
["oc_ping", "oc_ping"],
121+
]),
122+
}),
123+
);
124+
expect(runtime.log).toHaveBeenCalledWith(
125+
"mattermost: slash commands registered (2 commands across 2 teams, callback=https://openclaw.test/slash)",
126+
);
127+
});
128+
129+
it("warns on loopback callback urls and reports partial team failures", async () => {
130+
resolveSlashCommandConfig.mockReturnValue({ enabled: true, nativeSkills: false });
131+
isSlashCommandsEnabled.mockReturnValue(true);
132+
parseStrictPositiveInteger.mockReturnValue(undefined);
133+
fetchMattermostUserTeams.mockResolvedValue([{ id: "team-1" }, { id: "team-2" }]);
134+
resolveCallbackUrl.mockReturnValue("http://127.0.0.1:18789/slash");
135+
registerSlashCommands
136+
.mockResolvedValueOnce([{ token: "token-1", trigger: "ping" }])
137+
.mockRejectedValueOnce(new Error("boom"));
138+
const runtime = {
139+
log: vi.fn(),
140+
error: vi.fn(),
141+
};
142+
143+
const { registerMattermostMonitorSlashCommands } = await import("./monitor-slash.js");
144+
145+
await registerMattermostMonitorSlashCommands({
146+
client: {} as never,
147+
cfg: { gateway: { customBindHost: "loopback" } } as never,
148+
runtime: runtime as never,
149+
account: { config: { commands: {} }, accountId: "default" } as never,
150+
baseUrl: "https://chat.example.com",
151+
botUserId: "bot-user",
152+
});
153+
154+
expect(runtime.error).toHaveBeenCalledWith(
155+
expect.stringContaining("slash commands callbackUrl resolved to http://127.0.0.1:18789/slash"),
156+
);
157+
expect(runtime.error).toHaveBeenCalledWith(
158+
"mattermost: failed to register slash commands for team team-2: Error: boom",
159+
);
160+
expect(runtime.error).toHaveBeenCalledWith(
161+
"mattermost: slash command registration completed with 1 team error(s)",
162+
);
163+
});
164+
});

0 commit comments

Comments
 (0)