refactor(mattermost): dedupe reaction flow and test fixtures

This commit is contained in:
Peter Steinberger
2026-02-18 16:08:24 +00:00
parent c7bc94436b
commit 95d52b06d5
4 changed files with 208 additions and 186 deletions

View File

@@ -1,7 +1,13 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk"; import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { createReplyPrefixOptions } from "openclaw/plugin-sdk"; import { createReplyPrefixOptions } from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { mattermostPlugin } from "./channel.js"; import { mattermostPlugin } from "./channel.js";
import { resetMattermostReactionBotUserCacheForTests } from "./mattermost/reactions.js";
import {
createMattermostReactionFetchMock,
createMattermostTestConfig,
withMockedGlobalFetch,
} from "./mattermost/reactions.test-helpers.js";
describe("mattermostPlugin", () => { describe("mattermostPlugin", () => {
describe("messaging", () => { describe("messaging", () => {
@@ -44,6 +50,10 @@ describe("mattermostPlugin", () => {
}); });
describe("messageActions", () => { describe("messageActions", () => {
beforeEach(() => {
resetMattermostReactionBotUserCacheForTests();
});
it("exposes react when mattermost is configured", () => { it("exposes react when mattermost is configured", () => {
const cfg: OpenClawConfig = { const cfg: OpenClawConfig = {
channels: { channels: {
@@ -142,41 +152,14 @@ describe("mattermostPlugin", () => {
}); });
it("handles react by calling Mattermost reactions API", async () => { it("handles react by calling Mattermost reactions API", async () => {
const cfg: OpenClawConfig = { const cfg = createMattermostTestConfig();
channels: { const fetchImpl = createMattermostReactionFetchMock({
mattermost: { mode: "add",
enabled: true, postId: "POST1",
botToken: "test-token", emojiName: "thumbsup",
baseUrl: "https://chat.example.com",
},
},
};
const fetchImpl = vi.fn(async (url: any, init?: any) => {
if (String(url).endsWith("/api/v4/users/me")) {
return new Response(JSON.stringify({ id: "BOT123" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (String(url).endsWith("/api/v4/reactions")) {
expect(init?.method).toBe("POST");
expect(JSON.parse(init?.body)).toEqual({
user_id: "BOT123",
post_id: "POST1",
emoji_name: "thumbsup",
});
return new Response(JSON.stringify({ ok: true }), {
status: 201,
headers: { "content-type": "application/json" },
});
}
throw new Error(`unexpected url: ${url}`);
}); });
const prevFetch = globalThis.fetch; const result = await withMockedGlobalFetch(fetchImpl as unknown as typeof fetch, async () => {
(globalThis as any).fetch = fetchImpl;
try {
const result = await mattermostPlugin.actions?.handleAction?.({ const result = await mattermostPlugin.actions?.handleAction?.({
channel: "mattermost", channel: "mattermost",
action: "react", action: "react",
@@ -185,51 +168,22 @@ describe("mattermostPlugin", () => {
accountId: "default", accountId: "default",
} as any); } as any);
expect(result?.content).toEqual([ return result;
{ type: "text", text: "Reacted with :thumbsup: on POST1" }, });
]);
expect(result?.details).toEqual({}); expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]);
} finally { expect(result?.details).toEqual({});
(globalThis as any).fetch = prevFetch;
}
}); });
it("only treats boolean remove flag as removal", async () => { it("only treats boolean remove flag as removal", async () => {
const cfg: OpenClawConfig = { const cfg = createMattermostTestConfig();
channels: { const fetchImpl = createMattermostReactionFetchMock({
mattermost: { mode: "add",
enabled: true, postId: "POST1",
botToken: "test-token", emojiName: "thumbsup",
baseUrl: "https://chat.example.com",
},
},
};
const fetchImpl = vi.fn(async (url: any, init?: any) => {
if (String(url).endsWith("/api/v4/users/me")) {
return new Response(JSON.stringify({ id: "BOT123" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (String(url).endsWith("/api/v4/reactions")) {
expect(init?.method).toBe("POST");
expect(JSON.parse(init?.body)).toEqual({
user_id: "BOT123",
post_id: "POST1",
emoji_name: "thumbsup",
});
return new Response(JSON.stringify({ ok: true }), {
status: 201,
headers: { "content-type": "application/json" },
});
}
throw new Error(`unexpected url: ${url}`);
}); });
const prevFetch = globalThis.fetch; const result = await withMockedGlobalFetch(fetchImpl as unknown as typeof fetch, async () => {
(globalThis as any).fetch = fetchImpl;
try {
const result = await mattermostPlugin.actions?.handleAction?.({ const result = await mattermostPlugin.actions?.handleAction?.({
channel: "mattermost", channel: "mattermost",
action: "react", action: "react",
@@ -238,12 +192,10 @@ describe("mattermostPlugin", () => {
accountId: "default", accountId: "default",
} as any); } as any);
expect(result?.content).toEqual([ return result;
{ type: "text", text: "Reacted with :thumbsup: on POST1" }, });
]);
} finally { expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]);
(globalThis as any).fetch = prevFetch;
}
}); });
}); });

View File

@@ -0,0 +1,83 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { expect, vi } from "vitest";
export function createMattermostTestConfig(): OpenClawConfig {
return {
channels: {
mattermost: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
},
},
};
}
export function createMattermostReactionFetchMock(params: {
postId: string;
emojiName: string;
mode: "add" | "remove" | "both";
userId?: string;
status?: number;
body?: unknown;
}) {
const userId = params.userId ?? "BOT123";
const mode = params.mode;
const allowAdd = mode === "add" || mode === "both";
const allowRemove = mode === "remove" || mode === "both";
const addStatus = params.status ?? 201;
const removeStatus = params.status ?? 204;
const removePath = `/api/v4/users/${userId}/posts/${params.postId}/reactions/${encodeURIComponent(params.emojiName)}`;
return vi.fn(async (url: any, init?: any) => {
if (String(url).endsWith("/api/v4/users/me")) {
return new Response(JSON.stringify({ id: userId }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (allowAdd && String(url).endsWith("/api/v4/reactions")) {
expect(init?.method).toBe("POST");
expect(JSON.parse(init?.body)).toEqual({
user_id: userId,
post_id: params.postId,
emoji_name: params.emojiName,
});
const responseBody = params.body === undefined ? { ok: true } : params.body;
return new Response(
responseBody === null ? null : JSON.stringify(responseBody),
responseBody === null
? { status: addStatus, headers: { "content-type": "text/plain" } }
: { status: addStatus, headers: { "content-type": "application/json" } },
);
}
if (allowRemove && String(url).endsWith(removePath)) {
expect(init?.method).toBe("DELETE");
const responseBody = params.body === undefined ? null : params.body;
return new Response(
responseBody === null ? null : JSON.stringify(responseBody),
responseBody === null
? { status: removeStatus, headers: { "content-type": "text/plain" } }
: { status: removeStatus, headers: { "content-type": "application/json" } },
);
}
throw new Error(`unexpected url: ${url}`);
});
}
export async function withMockedGlobalFetch<T>(
fetchImpl: typeof fetch,
run: () => Promise<T>,
): Promise<T> {
const prevFetch = globalThis.fetch;
(globalThis as any).fetch = fetchImpl;
try {
return await run();
} finally {
(globalThis as any).fetch = prevFetch;
}
}

View File

@@ -1,45 +1,28 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest"; import {
import { addMattermostReaction, removeMattermostReaction } from "./reactions.js"; addMattermostReaction,
removeMattermostReaction,
function createCfg(): OpenClawConfig { resetMattermostReactionBotUserCacheForTests,
return { } from "./reactions.js";
channels: { import {
mattermost: { createMattermostReactionFetchMock,
enabled: true, createMattermostTestConfig,
botToken: "test-token", } from "./reactions.test-helpers.js";
baseUrl: "https://chat.example.com",
},
},
};
}
describe("mattermost reactions", () => { describe("mattermost reactions", () => {
beforeEach(() => {
resetMattermostReactionBotUserCacheForTests();
});
it("adds reactions by calling /users/me then POST /reactions", async () => { it("adds reactions by calling /users/me then POST /reactions", async () => {
const fetchMock = vi.fn(async (url: any, init?: any) => { const fetchMock = createMattermostReactionFetchMock({
if (String(url).endsWith("/api/v4/users/me")) { mode: "add",
return new Response(JSON.stringify({ id: "BOT123" }), { postId: "POST1",
status: 200, emojiName: "thumbsup",
headers: { "content-type": "application/json" },
});
}
if (String(url).endsWith("/api/v4/reactions")) {
expect(init?.method).toBe("POST");
expect(JSON.parse(init?.body)).toEqual({
user_id: "BOT123",
post_id: "POST1",
emoji_name: "thumbsup",
});
return new Response(JSON.stringify({ ok: true }), {
status: 201,
headers: { "content-type": "application/json" },
});
}
throw new Error(`unexpected url: ${url}`);
}); });
const result = await addMattermostReaction({ const result = await addMattermostReaction({
cfg: createCfg(), cfg: createMattermostTestConfig(),
postId: "POST1", postId: "POST1",
emojiName: "thumbsup", emojiName: "thumbsup",
fetchImpl: fetchMock as unknown as typeof fetch, fetchImpl: fetchMock as unknown as typeof fetch,
@@ -50,24 +33,16 @@ describe("mattermost reactions", () => {
}); });
it("returns a Result error when add reaction API call fails", async () => { it("returns a Result error when add reaction API call fails", async () => {
const fetchMock = vi.fn(async (url: any) => { const fetchMock = createMattermostReactionFetchMock({
if (String(url).endsWith("/api/v4/users/me")) { mode: "add",
return new Response(JSON.stringify({ id: "BOT123" }), { postId: "POST1",
status: 200, emojiName: "thumbsup",
headers: { "content-type": "application/json" }, status: 500,
}); body: { id: "err", message: "boom" },
}
if (String(url).endsWith("/api/v4/reactions")) {
return new Response(JSON.stringify({ id: "err", message: "boom" }), {
status: 500,
headers: { "content-type": "application/json" },
});
}
throw new Error(`unexpected url: ${url}`);
}); });
const result = await addMattermostReaction({ const result = await addMattermostReaction({
cfg: createCfg(), cfg: createMattermostTestConfig(),
postId: "POST1", postId: "POST1",
emojiName: "thumbsup", emojiName: "thumbsup",
fetchImpl: fetchMock as unknown as typeof fetch, fetchImpl: fetchMock as unknown as typeof fetch,
@@ -80,25 +55,14 @@ describe("mattermost reactions", () => {
}); });
it("removes reactions by calling /users/me then DELETE /users/:id/posts/:postId/reactions/:emoji", async () => { it("removes reactions by calling /users/me then DELETE /users/:id/posts/:postId/reactions/:emoji", async () => {
const fetchMock = vi.fn(async (url: any, init?: any) => { const fetchMock = createMattermostReactionFetchMock({
if (String(url).endsWith("/api/v4/users/me")) { mode: "remove",
return new Response(JSON.stringify({ id: "BOT123" }), { postId: "POST1",
status: 200, emojiName: "thumbsup",
headers: { "content-type": "application/json" },
});
}
if (String(url).endsWith("/api/v4/users/BOT123/posts/POST1/reactions/thumbsup")) {
expect(init?.method).toBe("DELETE");
return new Response(null, {
status: 204,
headers: { "content-type": "text/plain" },
});
}
throw new Error(`unexpected url: ${url}`);
}); });
const result = await removeMattermostReaction({ const result = await removeMattermostReaction({
cfg: createCfg(), cfg: createMattermostTestConfig(),
postId: "POST1", postId: "POST1",
emojiName: "thumbsup", emojiName: "thumbsup",
fetchImpl: fetchMock as unknown as typeof fetch, fetchImpl: fetchMock as unknown as typeof fetch,
@@ -107,4 +71,33 @@ describe("mattermost reactions", () => {
expect(result).toEqual({ ok: true }); expect(result).toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalled();
}); });
it("caches the bot user id across reaction mutations", async () => {
const fetchMock = createMattermostReactionFetchMock({
mode: "both",
postId: "POST1",
emojiName: "thumbsup",
});
const cfg = createMattermostTestConfig();
const addResult = await addMattermostReaction({
cfg,
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: fetchMock as unknown as typeof fetch,
});
const removeResult = await removeMattermostReaction({
cfg,
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: fetchMock as unknown as typeof fetch,
});
const usersMeCalls = fetchMock.mock.calls.filter((call) =>
String(call[0]).endsWith("/api/v4/users/me"),
);
expect(addResult).toEqual({ ok: true });
expect(removeResult).toEqual({ ok: true });
expect(usersMeCalls).toHaveLength(1);
});
}); });

View File

@@ -3,6 +3,15 @@ import { resolveMattermostAccount } from "./accounts.js";
import { createMattermostClient, fetchMattermostMe, type MattermostClient } from "./client.js"; import { createMattermostClient, fetchMattermostMe, type MattermostClient } from "./client.js";
type Result = { ok: true } | { ok: false; error: string }; type Result = { ok: true } | { ok: false; error: string };
type ReactionParams = {
cfg: OpenClawConfig;
postId: string;
emojiName: string;
accountId?: string | null;
fetchImpl?: typeof fetch;
};
type ReactionMutation = (client: MattermostClient, params: MutationPayload) => Promise<void>;
type MutationPayload = { userId: string; postId: string; emojiName: string };
const BOT_USER_CACHE_TTL_MS = 10 * 60_000; const BOT_USER_CACHE_TTL_MS = 10 * 60_000;
const botUserIdCache = new Map<string, { userId: string; expiresAt: number }>(); const botUserIdCache = new Map<string, { userId: string; expiresAt: number }>();
@@ -31,36 +40,10 @@ export async function addMattermostReaction(params: {
accountId?: string | null; accountId?: string | null;
fetchImpl?: typeof fetch; fetchImpl?: typeof fetch;
}): Promise<Result> { }): Promise<Result> {
const resolved = resolveMattermostAccount({ cfg: params.cfg, accountId: params.accountId }); return runMattermostReaction(params, {
const baseUrl = resolved.baseUrl?.trim(); action: "add",
const botToken = resolved.botToken?.trim(); mutation: createReaction,
if (!baseUrl || !botToken) {
return { ok: false, error: "Mattermost botToken/baseUrl missing." };
}
const client = createMattermostClient({
baseUrl,
botToken,
fetchImpl: params.fetchImpl,
}); });
const cacheKey = `${baseUrl}:${botToken}`;
const userId = await resolveBotUserId(client, cacheKey);
if (!userId) {
return { ok: false, error: "Mattermost reactions failed: could not resolve bot user id." };
}
try {
await createReaction(client, {
userId,
postId: params.postId,
emojiName: params.emojiName,
});
} catch (err) {
return { ok: false, error: `Mattermost add reaction failed: ${String(err)}` };
}
return { ok: true };
} }
export async function removeMattermostReaction(params: { export async function removeMattermostReaction(params: {
@@ -70,6 +53,23 @@ export async function removeMattermostReaction(params: {
accountId?: string | null; accountId?: string | null;
fetchImpl?: typeof fetch; fetchImpl?: typeof fetch;
}): Promise<Result> { }): Promise<Result> {
return runMattermostReaction(params, {
action: "remove",
mutation: deleteReaction,
});
}
export function resetMattermostReactionBotUserCacheForTests(): void {
botUserIdCache.clear();
}
async function runMattermostReaction(
params: ReactionParams,
options: {
action: "add" | "remove";
mutation: ReactionMutation;
},
): Promise<Result> {
const resolved = resolveMattermostAccount({ cfg: params.cfg, accountId: params.accountId }); const resolved = resolveMattermostAccount({ cfg: params.cfg, accountId: params.accountId });
const baseUrl = resolved.baseUrl?.trim(); const baseUrl = resolved.baseUrl?.trim();
const botToken = resolved.botToken?.trim(); const botToken = resolved.botToken?.trim();
@@ -90,22 +90,19 @@ export async function removeMattermostReaction(params: {
} }
try { try {
await deleteReaction(client, { await options.mutation(client, {
userId, userId,
postId: params.postId, postId: params.postId,
emojiName: params.emojiName, emojiName: params.emojiName,
}); });
} catch (err) { } catch (err) {
return { ok: false, error: `Mattermost remove reaction failed: ${String(err)}` }; return { ok: false, error: `Mattermost ${options.action} reaction failed: ${String(err)}` };
} }
return { ok: true }; return { ok: true };
} }
async function createReaction( async function createReaction(client: MattermostClient, params: MutationPayload): Promise<void> {
client: MattermostClient,
params: { userId: string; postId: string; emojiName: string },
): Promise<void> {
await client.request<Record<string, unknown>>("/reactions", { await client.request<Record<string, unknown>>("/reactions", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
@@ -116,10 +113,7 @@ async function createReaction(
}); });
} }
async function deleteReaction( async function deleteReaction(client: MattermostClient, params: MutationPayload): Promise<void> {
client: MattermostClient,
params: { userId: string; postId: string; emojiName: string },
): Promise<void> {
const emoji = encodeURIComponent(params.emojiName); const emoji = encodeURIComponent(params.emojiName);
await client.request<unknown>( await client.request<unknown>(
`/users/${params.userId}/posts/${params.postId}/reactions/${emoji}`, `/users/${params.userId}/posts/${params.postId}/reactions/${emoji}`,