test: collapse msteams helper suites

This commit is contained in:
Peter Steinberger
2026-03-25 05:54:48 +00:00
parent f5408d82d2
commit b99b521a92
10 changed files with 187 additions and 218 deletions

View File

@@ -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();
});
});

View File

@@ -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);
});
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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,
},
},
});
});
});

View File

@@ -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",

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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");
});
});