mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-21 22:21:33 +00:00
test: tighten msteams regression assertions
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user