test: tighten msteams regression assertions

This commit is contained in:
Peter Steinberger
2026-03-22 17:22:44 +00:00
parent 14074d3337
commit c8a36c621e
7 changed files with 113 additions and 48 deletions

View File

@@ -314,11 +314,14 @@ const expectMediaBufferSaved = () => {
};
const expectFirstMedia = (media: DownloadedMedia, expected: DownloadedMediaExpectation) => {
const first = media[0];
if (!first) {
throw new Error("expected one downloaded media item");
}
if (expected.path !== undefined) {
expect(first?.path).toBe(expected.path);
expect(first.path).toBe(expected.path);
}
if (expected.placeholder !== undefined) {
expect(first?.placeholder).toBe(expected.placeholder);
expect(first.placeholder).toBe(expected.placeholder);
}
};
const expectMSTeamsMediaPayload = (
@@ -331,6 +334,21 @@ const expectMSTeamsMediaPayload = (
expect(payload.MediaUrls).toEqual(expected.paths);
expect(payload.MediaTypes).toEqual(expected.types);
};
const requireFetchCall = (
fetchMock: { mock: { calls: unknown[][] } },
index: number,
): [string, RequestInit | undefined] => {
const call = fetchMock.mock.calls[index];
if (!call) {
throw new Error(`expected fetch call ${index + 1}`);
}
return [String(call[0]), call[1] as RequestInit | undefined];
};
const expectGraphMessagePath = (url: string, expectedPath: string) => {
const parsed = new URL(url);
expect(parsed.origin).toBe(`https://${GRAPH_HOST}`);
expect(parsed.pathname).toBe(`/v1.0${expectedPath}`);
};
type AttachmentPlaceholderCase = LabeledCase & {
attachments: AttachmentPlaceholderInput;
expected: string;
@@ -359,7 +377,7 @@ type AttachmentAuthRetryCase = LabeledCase & {
};
type GraphUrlExpectationCase = LabeledCase & {
params: GraphMessageUrlParams;
expectedPath: string;
expectedPaths: string[];
};
type ChannelGraphUrlCaseParams = {
messageId: string;
@@ -522,7 +540,12 @@ const GRAPH_URL_EXPECTATION_CASES: GraphUrlExpectationCase[] = [
...CHANNEL_GRAPH_URL_CASES.map<GraphUrlExpectationCase>(({ label, ...params }) =>
withLabel(label, {
params: createChannelGraphMessageUrlParams(params),
expectedPath: buildExpectedChannelMessagePath(params),
expectedPaths: params.replyToId
? [
buildExpectedChannelMessagePath(params),
buildExpectedChannelMessagePath({ messageId: params.messageId }),
]
: [buildExpectedChannelMessagePath(params)],
}),
),
withLabel("builds chat message urls", {
@@ -531,7 +554,7 @@ const GRAPH_URL_EXPECTATION_CASES: GraphUrlExpectationCase[] = [
conversationId: "19:chat@thread.v2",
messageId: "456",
},
expectedPath: "/chats/19%3Achat%40thread.v2/messages/456",
expectedPaths: ["/chats/19%3Achat%40thread.v2/messages/456"],
}),
];
@@ -740,7 +763,16 @@ describe("msteams attachments", () => {
expectAttachmentMediaLength(media, 1);
expect(tokenProvider.getAccessToken).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls.map(([calledUrl]) => String(calledUrl))).toContain(redirectedUrl);
expect(fetchMock).toHaveBeenCalledTimes(3);
const [firstUrl, firstInit] = requireFetchCall(fetchMock, 0);
const [secondUrl, secondInit] = requireFetchCall(fetchMock, 1);
const [thirdUrl, thirdInit] = requireFetchCall(fetchMock, 2);
expect(firstUrl).toBe(TEST_URL_IMAGE);
expect(new Headers(firstInit?.headers).get("Authorization")).toBeNull();
expect(secondUrl).toBe(TEST_URL_IMAGE);
expect(new Headers(secondInit?.headers).get("Authorization")).toBe("Bearer token");
expect(thirdUrl).toBe(redirectedUrl);
expect(new Headers(thirdInit?.headers).get("Authorization")).toBeNull();
});
it("continues scope fallback after non-auth failure and succeeds on later scope", async () => {
@@ -806,8 +838,10 @@ describe("msteams attachments", () => {
const redirected = seen.find(
(entry) => entry.url === "https://attacker.azureedge.net/collect",
);
expect(redirected).toBeDefined();
expect(redirected?.auth).toBe("");
if (!redirected) {
throw new Error("expected redirected Azure Edge fetch to be observed");
}
expect(redirected.auth).toBe("");
});
it("skips urls outside the allowlist", async () => {
@@ -851,9 +885,16 @@ describe("msteams attachments", () => {
});
describe("buildMSTeamsGraphMessageUrls", () => {
it.each(GRAPH_URL_EXPECTATION_CASES)("$label", ({ params, expectedPath }) => {
it.each(GRAPH_URL_EXPECTATION_CASES)("$label", ({ params, expectedPaths }) => {
const urls = buildMSTeamsGraphMessageUrls(params);
expect(urls[0]).toContain(expectedPath);
expect(urls).toHaveLength(expectedPaths.length);
urls.forEach((url, index) => {
const expectedPath = expectedPaths[index];
if (!expectedPath) {
throw new Error(`missing expected graph path ${index + 1}`);
}
expectGraphMessagePath(url, expectedPath);
});
});
});
@@ -899,8 +940,10 @@ describe("msteams attachments", () => {
expectAttachmentMediaLength(media.media, 1);
const redirected = seen.find((entry) => entry.url === escapedUrl);
expect(redirected).toBeDefined();
expect(redirected?.auth).toBe("");
if (!redirected) {
throw new Error("expected redirected SharePoint fetch to be observed");
}
expect(redirected.auth).toBe("");
});
it("blocks SharePoint redirects to hosts outside allowHosts", async () => {

View File

@@ -6,8 +6,18 @@ import {
import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js";
import { msteamsPlugin } from "./channel.js";
function requireDirectorySelf(
directory: typeof msteamsPlugin.directory | null | undefined,
): NonNullable<NonNullable<typeof msteamsPlugin.directory>["self"]> {
if (!directory?.self) {
throw new Error("expected msteams directory.self");
}
return directory.self;
}
describe("msteams directory", () => {
const runtimeEnv = createDirectoryTestRuntime() as RuntimeEnv;
const directorySelf = requireDirectorySelf(msteamsPlugin.directory);
describe("self()", () => {
it("returns bot identity when credentials are configured", async () => {
@@ -21,13 +31,13 @@ describe("msteams directory", () => {
},
} as unknown as OpenClawConfig;
const result = await msteamsPlugin.directory?.self?.({ cfg, runtime: runtimeEnv });
const result = await directorySelf({ cfg, runtime: runtimeEnv });
expect(result).toEqual({ kind: "user", id: "test-app-id-1234", name: "test-app-id-1234" });
});
it("returns null when credentials are not configured", async () => {
const cfg = { channels: {} } as unknown as OpenClawConfig;
const result = await msteamsPlugin.directory?.self?.({ cfg, runtime: runtimeEnv });
const result = await directorySelf({ cfg, runtime: runtimeEnv });
expect(result).toBeNull();
});
});

View File

@@ -140,9 +140,18 @@ describe("resolveGraphChatId", () => {
headers: expect.objectContaining({ Authorization: "Bearer graph-token" }),
}),
);
// Should filter by user AAD object ID
const callUrl = (fetchFn.mock.calls[0] as unknown[])[0];
expect(callUrl).toContain("user-aad-object-id-123");
const firstCall = fetchFn.mock.calls[0];
if (!firstCall) {
throw new Error("expected Graph chat lookup request");
}
const [callUrlRaw] = firstCall as unknown as [string, RequestInit?];
const callUrl = new URL(callUrlRaw);
expect(callUrl.origin).toBe("https://graph.microsoft.com");
expect(callUrl.pathname).toBe("/v1.0/me/chats");
expect(callUrl.searchParams.get("$filter")).toBe(
"chatType eq 'oneOnOne' and members/any(m:m/microsoft.graph.aadUserConversationMember/userId eq 'user-aad-object-id-123')",
);
expect(callUrl.searchParams.get("$select")).toBe("id");
expect(result).toBe("19:dm-chat-id@unq.gbl.spaces");
});

View File

@@ -9,13 +9,17 @@ function requireFirstEntity(result: ReturnType<typeof parseMentions>) {
return entity;
}
function requireOnlyEntity(result: ReturnType<typeof parseMentions>) {
expect(result.entities).toHaveLength(1);
return requireFirstEntity(result);
}
describe("parseMentions", () => {
it("parses single mention", () => {
const result = parseMentions("Hello @[John Doe](28:a1b2c3-d4e5f6)!");
expect(result.text).toBe("Hello <at>John Doe</at>!");
expect(result.entities).toHaveLength(1);
expect(result.entities[0]).toEqual({
expect(requireOnlyEntity(result)).toEqual({
type: "mention",
text: "<at>John Doe</at>",
mentioned: {
@@ -72,7 +76,7 @@ describe("parseMentions", () => {
it("trims whitespace from id and name", () => {
const result = parseMentions("@[ John Doe ]( 28:a1b2c3 )");
expect(result.entities[0]).toEqual({
expect(requireOnlyEntity(result)).toEqual({
type: "mention",
text: "<at>John Doe</at>",
mentioned: {
@@ -87,8 +91,7 @@ describe("parseMentions", () => {
const result = parseMentions(input);
expect(result.text).toBe("<at>タナカ タロウ</at> スキル化完了しました!");
expect(result.entities).toHaveLength(1);
expect(result.entities[0]).toEqual({
expect(requireOnlyEntity(result)).toEqual({
type: "mention",
text: "<at>タナカ タロウ</at>",
mentioned: {
@@ -115,8 +118,7 @@ describe("parseMentions", () => {
const result = parseMentions(input);
// Only the real mention should be parsed; the documentation example should be left as-is
expect(result.entities).toHaveLength(1);
const firstEntity = requireFirstEntity(result);
const firstEntity = requireOnlyEntity(result);
expect(firstEntity.mentioned.id).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
expect(firstEntity.mentioned.name).toBe("タナカ タロウ");
@@ -126,28 +128,24 @@ describe("parseMentions", () => {
it("accepts Bot Framework IDs (28:xxx)", () => {
const result = parseMentions("@[Bot](28:abc-123)");
expect(result.entities).toHaveLength(1);
expect(requireFirstEntity(result).mentioned.id).toBe("28:abc-123");
expect(requireOnlyEntity(result).mentioned.id).toBe("28:abc-123");
});
it("accepts Bot Framework IDs with non-hex payloads (29:xxx)", () => {
const result = parseMentions("@[Bot](29:08q2j2o3jc09au90eucae)");
expect(result.entities).toHaveLength(1);
expect(requireFirstEntity(result).mentioned.id).toBe("29:08q2j2o3jc09au90eucae");
expect(requireOnlyEntity(result).mentioned.id).toBe("29:08q2j2o3jc09au90eucae");
});
it("accepts org-scoped IDs with extra segments (8:orgid:...)", () => {
const result = parseMentions("@[User](8:orgid:2d8c2d2c-1111-2222-3333-444444444444)");
expect(result.entities).toHaveLength(1);
expect(requireFirstEntity(result).mentioned.id).toBe(
expect(requireOnlyEntity(result).mentioned.id).toBe(
"8:orgid:2d8c2d2c-1111-2222-3333-444444444444",
);
});
it("accepts AAD object IDs (UUIDs)", () => {
const result = parseMentions("@[User](a1b2c3d4-e5f6-7890-abcd-ef1234567890)");
expect(result.entities).toHaveLength(1);
expect(requireFirstEntity(result).mentioned.id).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
expect(requireOnlyEntity(result).mentioned.id).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
});
it("rejects non-ID strings as mention targets", () => {

View File

@@ -79,6 +79,21 @@ const createRecordedSendActivity = (
const REVOCATION_ERROR = "Cannot perform 'set' on a proxy that has been revoked";
function requireConversationId(ref: { conversation?: { id?: string } }) {
if (!ref.conversation?.id) {
throw new Error("expected Teams top-level send to preserve conversation id");
}
return ref.conversation.id;
}
function requireSentMessage(sent: Array<{ text?: string; entities?: unknown[] }>) {
const firstSent = sent[0];
if (!firstSent?.text) {
throw new Error("expected Teams message send to include rendered text");
}
return firstSent;
}
const createFallbackAdapter = (proactiveSent: string[]): MSTeamsAdapter => ({
continueConversation: async (_appId, _reference, logic) => {
await logic({
@@ -222,10 +237,7 @@ describe("msteams messenger", () => {
conversation?: { id?: string };
};
expect(ref.activityId).toBeUndefined();
if (!ref.conversation?.id) {
throw new Error("expected Teams top-level send to preserve conversation id");
}
expect(ref.conversation.id).toBe("19:abc@thread.tacv2");
expect(requireConversationId(ref)).toBe("19:abc@thread.tacv2");
});
it("preserves parsed mentions when appending OneDrive fallback file links", async () => {
@@ -265,10 +277,7 @@ describe("msteams messenger", () => {
expect(ids).toEqual(["id:one"]);
expect(graphUploadMockState.uploadAndShareOneDrive).toHaveBeenCalledOnce();
expect(sent).toHaveLength(1);
const firstSent = sent[0];
if (!firstSent?.text) {
throw new Error("expected Teams message send to include rendered text");
}
const firstSent = requireSentMessage(sent);
expect(firstSent.text).toContain("Hello <at>John</at>");
expect(firstSent.text).toContain(
"📎 [upload.txt](https://onedrive.example.com/share/item123)",

View File

@@ -192,11 +192,9 @@ describe("monitorMSTeamsProvider lifecycle", () => {
expect(early).toBe("pending");
abort.abort();
await expect(task).resolves.toEqual(
expect.objectContaining({
shutdown: expect.any(Function),
}),
);
const result = await task;
expect(result.app).not.toBeNull();
await expect(result.shutdown()).resolves.toBeUndefined();
});
it("rejects startup when webhook port is already in use", async () => {

View File

@@ -20,9 +20,7 @@ describe("msteams polls", () => {
expect(card.pollId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
expect(card.fallbackText).toContain("Poll: Lunch?");
expect(card.fallbackText).toContain("1. Pizza");
expect(card.fallbackText).toContain("2. Sushi");
expect(card.fallbackText).toBe("Poll: Lunch?\n1. Pizza\n2. Sushi");
});
it("extracts poll votes from activity values", () => {