From b99b521a926a8a324572146f4a33bb3660333e2e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Mar 2026 05:54:48 +0000 Subject: [PATCH] test: collapse msteams helper suites --- .../msteams/src/channel.directory.test.ts | 68 ++++++++++++++++++ extensions/msteams/src/errors.test.ts | 40 ++++++++++- .../msteams/src/graph.user-agent.test.ts | 52 -------------- .../msteams/src/revoked-context.test.ts | 39 ----------- extensions/msteams/src/session-route.test.ts | 69 ------------------- extensions/msteams/src/setup-core.test.ts | 34 --------- extensions/msteams/src/setup-surface.test.ts | 31 +++++++++ extensions/msteams/src/token-response.test.ts | 23 ------- extensions/msteams/src/token.test.ts | 16 +++++ extensions/msteams/src/user-agent.test.ts | 33 +++++++++ 10 files changed, 187 insertions(+), 218 deletions(-) delete mode 100644 extensions/msteams/src/graph.user-agent.test.ts delete mode 100644 extensions/msteams/src/revoked-context.test.ts delete mode 100644 extensions/msteams/src/session-route.test.ts delete mode 100644 extensions/msteams/src/setup-core.test.ts delete mode 100644 extensions/msteams/src/token-response.test.ts diff --git a/extensions/msteams/src/channel.directory.test.ts b/extensions/msteams/src/channel.directory.test.ts index 095ff0cb0ea..160d9d3dc99 100644 --- a/extensions/msteams/src/channel.directory.test.ts +++ b/extensions/msteams/src/channel.directory.test.ts @@ -5,6 +5,7 @@ import { } from "../../../test/helpers/extensions/directory.js"; import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js"; import { msteamsPlugin } from "./channel.js"; +import { resolveMSTeamsOutboundSessionRoute } from "./session-route.js"; function requireDirectorySelf( directory: typeof msteamsPlugin.directory | null | undefined, @@ -129,3 +130,70 @@ describe("msteams directory", () => { ); }); }); + +describe("msteams session route", () => { + it("builds direct routes for explicit user targets", () => { + const route = resolveMSTeamsOutboundSessionRoute({ + cfg: {}, + agentId: "main", + accountId: "default", + target: "msteams:user:alice-id", + }); + + expect(route).toMatchObject({ + peer: { + kind: "direct", + id: "alice-id", + }, + from: "msteams:alice-id", + to: "user:alice-id", + }); + }); + + it("builds channel routes for thread conversations and strips suffix metadata", () => { + const route = resolveMSTeamsOutboundSessionRoute({ + cfg: {}, + agentId: "main", + accountId: "default", + target: "teams:19:abc123@thread.tacv2;messageid=42", + }); + + expect(route).toMatchObject({ + peer: { + kind: "channel", + id: "19:abc123@thread.tacv2", + }, + from: "msteams:channel:19:abc123@thread.tacv2", + to: "conversation:19:abc123@thread.tacv2", + }); + }); + + it("returns group routes for non-user, non-channel conversations", () => { + const route = resolveMSTeamsOutboundSessionRoute({ + cfg: {}, + agentId: "main", + accountId: "default", + target: "msteams:conversation:19:groupchat", + }); + + expect(route).toMatchObject({ + peer: { + kind: "group", + id: "19:groupchat", + }, + from: "msteams:group:19:groupchat", + to: "conversation:19:groupchat", + }); + }); + + it("returns null when the target cannot be normalized", () => { + expect( + resolveMSTeamsOutboundSessionRoute({ + cfg: {}, + agentId: "main", + accountId: "default", + target: "msteams:", + }), + ).toBeNull(); + }); +}); diff --git a/extensions/msteams/src/errors.test.ts b/extensions/msteams/src/errors.test.ts index d539d3c6830..dd8a4f3fdab 100644 --- a/extensions/msteams/src/errors.test.ts +++ b/extensions/msteams/src/errors.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { classifyMSTeamsSendError, formatMSTeamsSendErrorHint, formatUnknownError, isRevokedProxyError, } from "./errors.js"; +import { withRevokedProxyFallback } from "./revoked-context.js"; describe("msteams errors", () => { it("formats unknown errors", () => { @@ -67,4 +68,41 @@ describe("msteams errors", () => { expect(isRevokedProxyError("proxy that has been revoked")).toBe(false); }); }); + + describe("withRevokedProxyFallback", () => { + it("returns primary result when no error occurs", async () => { + await expect( + withRevokedProxyFallback({ + run: async () => "ok", + onRevoked: async () => "fallback", + }), + ).resolves.toBe("ok"); + }); + + it("uses fallback when proxy-revoked TypeError is thrown", async () => { + const onRevokedLog = vi.fn(); + await expect( + withRevokedProxyFallback({ + run: async () => { + throw new TypeError("Cannot perform 'get' on a proxy that has been revoked"); + }, + onRevoked: async () => "fallback", + onRevokedLog, + }), + ).resolves.toBe("fallback"); + expect(onRevokedLog).toHaveBeenCalledOnce(); + }); + + it("rethrows non-revoked errors", async () => { + const err = Object.assign(new Error("boom"), { statusCode: 500 }); + await expect( + withRevokedProxyFallback({ + run: async () => { + throw err; + }, + onRevoked: async () => "fallback", + }), + ).rejects.toBe(err); + }); + }); }); diff --git a/extensions/msteams/src/graph.user-agent.test.ts b/extensions/msteams/src/graph.user-agent.test.ts deleted file mode 100644 index bc1503d8b8b..00000000000 --- a/extensions/msteams/src/graph.user-agent.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -vi.mock("./runtime.js", () => ({ - getMSTeamsRuntime: vi.fn(() => ({ version: "2026.3.19" })), -})); - -import { fetchGraphJson } from "./graph.js"; -import { resetUserAgentCache } from "./user-agent.js"; - -describe("fetchGraphJson User-Agent", () => { - afterEach(() => { - resetUserAgentCache(); - vi.restoreAllMocks(); - }); - - it("sends User-Agent header with OpenClaw version", async () => { - const mockFetch = vi.fn().mockResolvedValueOnce({ - ok: true, - json: async () => ({ value: [] }), - }); - vi.stubGlobal("fetch", mockFetch); - - await fetchGraphJson({ token: "test-token", path: "/groups" }); - - expect(mockFetch).toHaveBeenCalledOnce(); - const [, init] = mockFetch.mock.calls[0]; - expect(init.headers["User-Agent"]).toMatch(/^teams\.ts\[apps\]\/.+ OpenClaw\/2026\.3\.19$/); - expect(init.headers).toHaveProperty("Authorization", "Bearer test-token"); - - vi.unstubAllGlobals(); - }); - - it("allows caller headers to override User-Agent", async () => { - const mockFetch = vi.fn().mockResolvedValueOnce({ - ok: true, - json: async () => ({ value: [] }), - }); - vi.stubGlobal("fetch", mockFetch); - - await fetchGraphJson({ - token: "test-token", - path: "/groups", - headers: { "User-Agent": "custom-agent/1.0" }, - }); - - const [, init] = mockFetch.mock.calls[0]; - // Caller headers spread after, so they override - expect(init.headers["User-Agent"]).toBe("custom-agent/1.0"); - - vi.unstubAllGlobals(); - }); -}); diff --git a/extensions/msteams/src/revoked-context.test.ts b/extensions/msteams/src/revoked-context.test.ts deleted file mode 100644 index 20c339d9434..00000000000 --- a/extensions/msteams/src/revoked-context.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { withRevokedProxyFallback } from "./revoked-context.js"; - -describe("msteams revoked context helper", () => { - it("returns primary result when no error occurs", async () => { - await expect( - withRevokedProxyFallback({ - run: async () => "ok", - onRevoked: async () => "fallback", - }), - ).resolves.toBe("ok"); - }); - - it("uses fallback when proxy-revoked TypeError is thrown", async () => { - const onRevokedLog = vi.fn(); - await expect( - withRevokedProxyFallback({ - run: async () => { - throw new TypeError("Cannot perform 'get' on a proxy that has been revoked"); - }, - onRevoked: async () => "fallback", - onRevokedLog, - }), - ).resolves.toBe("fallback"); - expect(onRevokedLog).toHaveBeenCalledOnce(); - }); - - it("rethrows non-revoked errors", async () => { - const err = Object.assign(new Error("boom"), { statusCode: 500 }); - await expect( - withRevokedProxyFallback({ - run: async () => { - throw err; - }, - onRevoked: async () => "fallback", - }), - ).rejects.toBe(err); - }); -}); diff --git a/extensions/msteams/src/session-route.test.ts b/extensions/msteams/src/session-route.test.ts deleted file mode 100644 index 93394722946..00000000000 --- a/extensions/msteams/src/session-route.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveMSTeamsOutboundSessionRoute } from "./session-route.js"; - -describe("msteams session route", () => { - it("builds direct routes for explicit user targets", () => { - const route = resolveMSTeamsOutboundSessionRoute({ - cfg: {}, - agentId: "main", - accountId: "default", - target: "msteams:user:alice-id", - }); - - expect(route).toMatchObject({ - peer: { - kind: "direct", - id: "alice-id", - }, - from: "msteams:alice-id", - to: "user:alice-id", - }); - }); - - it("builds channel routes for thread conversations and strips suffix metadata", () => { - const route = resolveMSTeamsOutboundSessionRoute({ - cfg: {}, - agentId: "main", - accountId: "default", - target: "teams:19:abc123@thread.tacv2;messageid=42", - }); - - expect(route).toMatchObject({ - peer: { - kind: "channel", - id: "19:abc123@thread.tacv2", - }, - from: "msteams:channel:19:abc123@thread.tacv2", - to: "conversation:19:abc123@thread.tacv2", - }); - }); - - it("returns group routes for non-user, non-channel conversations", () => { - const route = resolveMSTeamsOutboundSessionRoute({ - cfg: {}, - agentId: "main", - accountId: "default", - target: "msteams:conversation:19:groupchat", - }); - - expect(route).toMatchObject({ - peer: { - kind: "group", - id: "19:groupchat", - }, - from: "msteams:group:19:groupchat", - to: "conversation:19:groupchat", - }); - }); - - it("returns null when the target cannot be normalized", () => { - expect( - resolveMSTeamsOutboundSessionRoute({ - cfg: {}, - agentId: "main", - accountId: "default", - target: "msteams:", - }), - ).toBeNull(); - }); -}); diff --git a/extensions/msteams/src/setup-core.test.ts b/extensions/msteams/src/setup-core.test.ts deleted file mode 100644 index 832d7293aed..00000000000 --- a/extensions/msteams/src/setup-core.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; -import { describe, expect, it } from "vitest"; -import { msteamsSetupAdapter } from "./setup-core.js"; - -describe("msteams setup core", () => { - it("always resolves to the default account", () => { - expect(msteamsSetupAdapter.resolveAccountId?.({ accountId: "work" } as never)).toBe( - DEFAULT_ACCOUNT_ID, - ); - }); - - it("enables the msteams channel without dropping existing config", () => { - expect( - msteamsSetupAdapter.applyAccountConfig?.({ - cfg: { - channels: { - msteams: { - appId: "existing-app", - }, - }, - }, - accountId: DEFAULT_ACCOUNT_ID, - input: {}, - } as never), - ).toEqual({ - channels: { - msteams: { - appId: "existing-app", - enabled: true, - }, - }, - }); - }); -}); diff --git a/extensions/msteams/src/setup-surface.test.ts b/extensions/msteams/src/setup-surface.test.ts index 2ecfbc15df7..a0bc16a42a0 100644 --- a/extensions/msteams/src/setup-surface.test.ts +++ b/extensions/msteams/src/setup-surface.test.ts @@ -1,4 +1,6 @@ +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { msteamsSetupAdapter } from "./setup-core.js"; const resolveMSTeamsUserAllowlist = vi.hoisted(() => vi.fn()); const resolveMSTeamsChannelAllowlist = vi.hoisted(() => vi.fn()); @@ -37,6 +39,35 @@ describe("msteams setup surface", () => { vi.resetModules(); }); + it("always resolves to the default account", () => { + expect(msteamsSetupAdapter.resolveAccountId?.({ accountId: "work" } as never)).toBe( + DEFAULT_ACCOUNT_ID, + ); + }); + + it("enables the msteams channel without dropping existing config", () => { + expect( + msteamsSetupAdapter.applyAccountConfig?.({ + cfg: { + channels: { + msteams: { + appId: "existing-app", + }, + }, + }, + accountId: DEFAULT_ACCOUNT_ID, + input: {}, + } as never), + ).toEqual({ + channels: { + msteams: { + appId: "existing-app", + enabled: true, + }, + }, + }); + }); + it("reports configured status from resolved credentials", async () => { resolveMSTeamsCredentials.mockReturnValue({ appId: "app", diff --git a/extensions/msteams/src/token-response.test.ts b/extensions/msteams/src/token-response.test.ts deleted file mode 100644 index 2deddfbc736..00000000000 --- a/extensions/msteams/src/token-response.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { readAccessToken } from "./token-response.js"; - -describe("readAccessToken", () => { - it("returns raw string token values", () => { - expect(readAccessToken("abc")).toBe("abc"); - }); - - it("returns accessToken from object value", () => { - expect(readAccessToken({ accessToken: "access-token" })).toBe("access-token"); - }); - - it("returns token fallback from object value", () => { - expect(readAccessToken({ token: "fallback-token" })).toBe("fallback-token"); - }); - - it("returns null for unsupported values", () => { - expect(readAccessToken({ accessToken: 123 })).toBeNull(); - expect(readAccessToken({ token: false })).toBeNull(); - expect(readAccessToken(null)).toBeNull(); - expect(readAccessToken(undefined)).toBeNull(); - }); -}); diff --git a/extensions/msteams/src/token.test.ts b/extensions/msteams/src/token.test.ts index 732b561a2b0..de375edaf00 100644 --- a/extensions/msteams/src/token.test.ts +++ b/extensions/msteams/src/token.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; +import { readAccessToken } from "./token-response.js"; import { hasConfiguredMSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js"; const ORIGINAL_ENV = { @@ -70,3 +71,18 @@ describe("hasConfiguredMSTeamsCredentials", () => { expect(configured).toBe(true); }); }); + +describe("readAccessToken", () => { + it("reads string and object token forms", () => { + expect(readAccessToken("abc")).toBe("abc"); + expect(readAccessToken({ accessToken: "access-token" })).toBe("access-token"); + expect(readAccessToken({ token: "fallback-token" })).toBe("fallback-token"); + }); + + it("returns null for unsupported token payloads", () => { + expect(readAccessToken({ accessToken: 123 })).toBeNull(); + expect(readAccessToken({ token: false })).toBeNull(); + expect(readAccessToken(null)).toBeNull(); + expect(readAccessToken(undefined)).toBeNull(); + }); +}); diff --git a/extensions/msteams/src/user-agent.test.ts b/extensions/msteams/src/user-agent.test.ts index 2bfb60feebb..a99f3a3c4bc 100644 --- a/extensions/msteams/src/user-agent.test.ts +++ b/extensions/msteams/src/user-agent.test.ts @@ -9,6 +9,7 @@ vi.mock("./runtime.js", () => ({ getMSTeamsRuntime: vi.fn(() => mockRuntime), })); +import { fetchGraphJson } from "./graph.js"; import { getMSTeamsRuntime } from "./runtime.js"; import { buildUserAgent, resetUserAgentCache } from "./user-agent.js"; @@ -42,4 +43,36 @@ describe("buildUserAgent", () => { // SDK version should still be present expect(ua).toMatch(/^teams\.ts\[apps\]\//); }); + + it("sends the generated User-Agent in Graph requests by default", async () => { + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ value: [] }), + }); + vi.stubGlobal("fetch", mockFetch); + + await fetchGraphJson({ token: "test-token", path: "/groups" }); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [, init] = mockFetch.mock.calls[0]; + expect(init.headers["User-Agent"]).toMatch(/^teams\.ts\[apps\]\/.+ OpenClaw\/2026\.3\.19$/); + expect(init.headers).toHaveProperty("Authorization", "Bearer test-token"); + }); + + it("lets caller headers override the default Graph User-Agent", async () => { + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ value: [] }), + }); + vi.stubGlobal("fetch", mockFetch); + + await fetchGraphJson({ + token: "test-token", + path: "/groups", + headers: { "User-Agent": "custom-agent/1.0" }, + }); + + const [, init] = mockFetch.mock.calls[0]; + expect(init.headers["User-Agent"]).toBe("custom-agent/1.0"); + }); });