import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js"; import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; import type { ResolvedZaloAccount } from "./accounts.js"; const getWebhookInfoMock = vi.fn(async () => ({ ok: true, result: { url: "" } })); const deleteWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } })); const getUpdatesMock = vi.fn(() => new Promise(() => {})); const setWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } })); vi.mock("./api.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, deleteWebhook: deleteWebhookMock, getWebhookInfo: getWebhookInfoMock, getUpdates: getUpdatesMock, setWebhook: setWebhookMock, }; }); vi.mock("./runtime.js", () => ({ getZaloRuntime: () => ({ logging: { shouldLogVerbose: () => false, }, }), })); async function waitForPollingLoopStart(): Promise { await vi.waitFor(() => expect(getUpdatesMock).toHaveBeenCalledTimes(1)); } describe("monitorZaloProvider lifecycle", () => { afterEach(() => { vi.clearAllMocks(); setActivePluginRegistry(createEmptyPluginRegistry()); }); it("stays alive in polling mode until abort", async () => { const { monitorZaloProvider } = await import("./monitor.js"); const abort = new AbortController(); const runtime = { log: vi.fn<(message: string) => void>(), error: vi.fn<(message: string) => void>(), }; const account = { accountId: "default", config: {}, } as unknown as ResolvedZaloAccount; const config = {} as OpenClawConfig; let settled = false; const run = monitorZaloProvider({ token: "test-token", account, config, runtime, abortSignal: abort.signal, }).then(() => { settled = true; }); await waitForPollingLoopStart(); expect(getWebhookInfoMock).toHaveBeenCalledTimes(1); expect(deleteWebhookMock).not.toHaveBeenCalled(); expect(getUpdatesMock).toHaveBeenCalledTimes(1); expect(settled).toBe(false); abort.abort(); await run; expect(settled).toBe(true); expect(runtime.log).toHaveBeenCalledWith( expect.stringContaining("Zalo provider stopped mode=polling"), ); }); it("deletes an existing webhook before polling", async () => { getWebhookInfoMock.mockResolvedValueOnce({ ok: true, result: { url: "https://example.com/hooks/zalo" }, }); const { monitorZaloProvider } = await import("./monitor.js"); const abort = new AbortController(); const runtime = { log: vi.fn<(message: string) => void>(), error: vi.fn<(message: string) => void>(), }; const account = { accountId: "default", config: {}, } as unknown as ResolvedZaloAccount; const config = {} as OpenClawConfig; const run = monitorZaloProvider({ token: "test-token", account, config, runtime, abortSignal: abort.signal, }); await waitForPollingLoopStart(); expect(getWebhookInfoMock).toHaveBeenCalledTimes(1); expect(deleteWebhookMock).toHaveBeenCalledTimes(1); expect(runtime.log).toHaveBeenCalledWith( expect.stringContaining("Zalo polling mode ready (webhook disabled)"), ); abort.abort(); await run; }); it("continues polling when webhook inspection returns 404", async () => { const { ZaloApiError } = await import("./api.js"); getWebhookInfoMock.mockRejectedValueOnce(new ZaloApiError("Not Found", 404, "Not Found")); const { monitorZaloProvider } = await import("./monitor.js"); const abort = new AbortController(); const runtime = { log: vi.fn<(message: string) => void>(), error: vi.fn<(message: string) => void>(), }; const account = { accountId: "default", config: {}, } as unknown as ResolvedZaloAccount; const config = {} as OpenClawConfig; const run = monitorZaloProvider({ token: "test-token", account, config, runtime, abortSignal: abort.signal, }); await waitForPollingLoopStart(); expect(getWebhookInfoMock).toHaveBeenCalledTimes(1); expect(deleteWebhookMock).not.toHaveBeenCalled(); expect(runtime.log).toHaveBeenCalledWith( expect.stringContaining("webhook inspection unavailable; continuing without webhook cleanup"), ); expect(runtime.error).not.toHaveBeenCalled(); abort.abort(); await run; }); it("waits for webhook deletion before finishing webhook shutdown", async () => { const registry = createEmptyPluginRegistry(); setActivePluginRegistry(registry); let resolveDeleteWebhook: (() => void) | undefined; deleteWebhookMock.mockImplementationOnce( () => new Promise((resolve) => { resolveDeleteWebhook = () => resolve({ ok: true, result: { url: "" } }); }), ); const { monitorZaloProvider } = await import("./monitor.js"); const abort = new AbortController(); const runtime = { log: vi.fn<(message: string) => void>(), error: vi.fn<(message: string) => void>(), }; const account = { accountId: "default", config: {}, } as unknown as ResolvedZaloAccount; const config = {} as OpenClawConfig; let settled = false; const run = monitorZaloProvider({ token: "test-token", account, config, runtime, abortSignal: abort.signal, useWebhook: true, webhookUrl: "https://example.com/hooks/zalo", webhookSecret: "supersecret", // pragma: allowlist secret }).then(() => { settled = true; }); await vi.waitFor(() => expect(setWebhookMock).toHaveBeenCalledTimes(1)); expect(registry.httpRoutes).toHaveLength(1); abort.abort(); await vi.waitFor(() => expect(deleteWebhookMock).toHaveBeenCalledTimes(1)); expect(deleteWebhookMock).toHaveBeenCalledWith("test-token", undefined, 5000); expect(settled).toBe(false); expect(registry.httpRoutes).toHaveLength(1); resolveDeleteWebhook?.(); await run; expect(settled).toBe(true); expect(registry.httpRoutes).toHaveLength(0); expect(runtime.log).toHaveBeenCalledWith( expect.stringContaining("Zalo provider stopped mode=webhook"), ); }); });