| import { EventEmitter } from "node:events"; |
| import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/msteams"; |
| import { afterEach, describe, expect, it, vi } from "vitest"; |
| import type { MSTeamsConversationStore } from "./conversation-store.js"; |
| import type { MSTeamsPollStore } from "./polls.js"; |
|
|
| type FakeServer = EventEmitter & { |
| close: (callback?: (err?: Error | null) => void) => void; |
| setTimeout: (msecs: number) => FakeServer; |
| requestTimeout: number; |
| headersTimeout: number; |
| }; |
|
|
| const expressControl = vi.hoisted(() => ({ |
| mode: { value: "listening" as "listening" | "error" }, |
| })); |
|
|
| vi.mock("openclaw/plugin-sdk/msteams", () => ({ |
| DEFAULT_WEBHOOK_MAX_BODY_BYTES: 1024 * 1024, |
| normalizeSecretInputString: (value: unknown) => |
| typeof value === "string" && value.trim() ? value.trim() : undefined, |
| hasConfiguredSecretInput: (value: unknown) => |
| typeof value === "string" && value.trim().length > 0, |
| normalizeResolvedSecretInputString: (params: { value?: unknown }) => |
| typeof params?.value === "string" && params.value.trim() ? params.value.trim() : undefined, |
| keepHttpServerTaskAlive: vi.fn( |
| async (params: { abortSignal?: AbortSignal; onAbort?: () => Promise<void> | void }) => { |
| await new Promise<void>((resolve) => { |
| if (params.abortSignal?.aborted) { |
| resolve(); |
| return; |
| } |
| params.abortSignal?.addEventListener("abort", () => resolve(), { once: true }); |
| }); |
| await params.onAbort?.(); |
| }, |
| ), |
| mergeAllowlist: (params: { existing?: string[]; additions?: string[] }) => |
| Array.from(new Set([...(params.existing ?? []), ...(params.additions ?? [])])), |
| summarizeMapping: vi.fn(), |
| })); |
|
|
| vi.mock("express", () => { |
| const json = vi.fn(() => { |
| return (_req: unknown, _res: unknown, next?: (err?: unknown) => void) => { |
| next?.(); |
| }; |
| }); |
|
|
| const factory = () => ({ |
| use: vi.fn(), |
| post: vi.fn(), |
| listen: vi.fn((_port: number) => { |
| const server = new EventEmitter() as FakeServer; |
| server.setTimeout = vi.fn((_msecs: number) => server); |
| server.requestTimeout = 0; |
| server.headersTimeout = 0; |
| server.close = (callback?: (err?: Error | null) => void) => { |
| queueMicrotask(() => { |
| server.emit("close"); |
| callback?.(null); |
| }); |
| }; |
| queueMicrotask(() => { |
| if (expressControl.mode.value === "error") { |
| server.emit("error", new Error("listen EADDRINUSE")); |
| return; |
| } |
| server.emit("listening"); |
| }); |
| return server; |
| }), |
| }); |
|
|
| return { |
| default: factory, |
| json, |
| }; |
| }); |
|
|
| const registerMSTeamsHandlers = vi.hoisted(() => |
| vi.fn(() => ({ |
| run: vi.fn(async () => {}), |
| })), |
| ); |
| const createMSTeamsAdapter = vi.hoisted(() => |
| vi.fn(() => ({ |
| process: vi.fn(async () => {}), |
| })), |
| ); |
| const loadMSTeamsSdkWithAuth = vi.hoisted(() => |
| vi.fn(async () => ({ |
| sdk: { |
| ActivityHandler: class {}, |
| MsalTokenProvider: class {}, |
| authorizeJWT: |
| () => (_req: unknown, _res: unknown, next: ((err?: unknown) => void) | undefined) => |
| next?.(), |
| }, |
| authConfig: {}, |
| })), |
| ); |
|
|
| vi.mock("./monitor-handler.js", () => ({ |
| registerMSTeamsHandlers: () => registerMSTeamsHandlers(), |
| })); |
|
|
| vi.mock("./resolve-allowlist.js", () => ({ |
| resolveMSTeamsChannelAllowlist: vi.fn(async () => []), |
| resolveMSTeamsUserAllowlist: vi.fn(async () => []), |
| })); |
|
|
| vi.mock("./sdk.js", () => ({ |
| createMSTeamsAdapter: () => createMSTeamsAdapter(), |
| loadMSTeamsSdkWithAuth: () => loadMSTeamsSdkWithAuth(), |
| })); |
|
|
| vi.mock("./runtime.js", () => ({ |
| getMSTeamsRuntime: () => ({ |
| logging: { |
| getChildLogger: () => ({ |
| info: vi.fn(), |
| error: vi.fn(), |
| debug: vi.fn(), |
| }), |
| }, |
| channel: { |
| text: { |
| resolveTextChunkLimit: () => 4000, |
| }, |
| }, |
| }), |
| })); |
|
|
| import { monitorMSTeamsProvider } from "./monitor.js"; |
|
|
| function createConfig(port: number): OpenClawConfig { |
| return { |
| channels: { |
| msteams: { |
| enabled: true, |
| appId: "app-id", |
| appPassword: "app-password", |
| tenantId: "tenant-id", |
| webhook: { |
| port, |
| path: "/api/messages", |
| }, |
| }, |
| }, |
| } as OpenClawConfig; |
| } |
|
|
| function createRuntime(): RuntimeEnv { |
| return { |
| log: vi.fn(), |
| error: vi.fn(), |
| exit: (code: number): never => { |
| throw new Error(`exit ${code}`); |
| }, |
| }; |
| } |
|
|
| function createStores() { |
| return { |
| conversationStore: {} as MSTeamsConversationStore, |
| pollStore: {} as MSTeamsPollStore, |
| }; |
| } |
|
|
| describe("monitorMSTeamsProvider lifecycle", () => { |
| afterEach(() => { |
| vi.clearAllMocks(); |
| expressControl.mode.value = "listening"; |
| }); |
|
|
| it("stays active until aborted", async () => { |
| const abort = new AbortController(); |
| const stores = createStores(); |
| const task = monitorMSTeamsProvider({ |
| cfg: createConfig(0), |
| runtime: createRuntime(), |
| abortSignal: abort.signal, |
| conversationStore: stores.conversationStore, |
| pollStore: stores.pollStore, |
| }); |
|
|
| const early = await Promise.race([ |
| task.then(() => "resolved"), |
| new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 50)), |
| ]); |
| expect(early).toBe("pending"); |
|
|
| abort.abort(); |
| await expect(task).resolves.toEqual( |
| expect.objectContaining({ |
| shutdown: expect.any(Function), |
| }), |
| ); |
| }); |
|
|
| it("rejects startup when webhook port is already in use", async () => { |
| expressControl.mode.value = "error"; |
| await expect( |
| monitorMSTeamsProvider({ |
| cfg: createConfig(3978), |
| runtime: createRuntime(), |
| abortSignal: new AbortController().signal, |
| conversationStore: createStores().conversationStore, |
| pollStore: createStores().pollStore, |
| }), |
| ).rejects.toThrow(/EADDRINUSE/); |
| }); |
| }); |
|
|