From 5ebccf5e30c5af454380fe5e44c6e3fa07b9d808 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 29 Mar 2026 00:47:43 +0000 Subject: [PATCH] test: harden zalo webhook lifecycle tests --- .../src/monitor.pairing.lifecycle.test.ts | 152 ++++++++--------- .../src/monitor.reply-once.lifecycle.test.ts | 158 +++++++++--------- test/helpers/extensions/zalo-lifecycle.ts | 41 ++++- 3 files changed, 189 insertions(+), 162 deletions(-) diff --git a/extensions/zalo/src/monitor.pairing.lifecycle.test.ts b/extensions/zalo/src/monitor.pairing.lifecycle.test.ts index 4d55519a886..8a82e232687 100644 --- a/extensions/zalo/src/monitor.pairing.lifecycle.test.ts +++ b/extensions/zalo/src/monitor.pairing.lifecycle.test.ts @@ -42,92 +42,92 @@ describe("Zalo pairing lifecycle", () => { } it("emits one pairing reply across duplicate webhook replay and scopes reads and writes to accountId", async () => { - const { abort, route, run } = await startWebhookLifecycleMonitor(createPairingMonitorSetup()); + const monitor = await startWebhookLifecycleMonitor(createPairingMonitorSetup()); - await withServer( - (req, res) => route.handler(req, res), - async (baseUrl) => { - const { first, replay } = await postWebhookReplay({ - baseUrl, - path: "/hooks/zalo", - secret: "supersecret", - payload: createTextUpdate({ - messageId: `zalo-pairing-${Date.now()}`, - userId: "user-unauthorized", - userName: "Unauthorized User", - chatId: "dm-pairing-1", - }), - }); + try { + await withServer( + (req, res) => monitor.route.handler(req, res), + async (baseUrl) => { + const { first, replay } = await postWebhookReplay({ + baseUrl, + path: "/hooks/zalo", + secret: "supersecret", + payload: createTextUpdate({ + messageId: `zalo-pairing-${Date.now()}`, + userId: "user-unauthorized", + userName: "Unauthorized User", + chatId: "dm-pairing-1", + }), + }); - expect(first.status).toBe(200); - expect(replay.status).toBe(200); - await settleAsyncWork(); - }, - ); + expect(first.status).toBe(200); + expect(replay.status).toBe(200); + await settleAsyncWork(); + }, + ); - expect(readAllowFromStoreMock).toHaveBeenCalledTimes(1); - expect(readAllowFromStoreMock).toHaveBeenCalledWith( - expect.objectContaining({ - channel: "zalo", - accountId: "acct-zalo-pairing", - }), - ); - expect(upsertPairingRequestMock).toHaveBeenCalledTimes(1); - expect(upsertPairingRequestMock).toHaveBeenCalledWith( - expect.objectContaining({ - channel: "zalo", - accountId: "acct-zalo-pairing", - id: "user-unauthorized", - }), - ); - expect(sendMessageMock).toHaveBeenCalledTimes(1); - expect(sendMessageMock).toHaveBeenCalledWith( - "zalo-token", - expect.objectContaining({ - chat_id: "dm-pairing-1", - text: expect.stringContaining("PAIRCODE"), - }), - undefined, - ); - - abort.abort(); - await run; + expect(readAllowFromStoreMock).toHaveBeenCalledTimes(1); + expect(readAllowFromStoreMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "zalo", + accountId: "acct-zalo-pairing", + }), + ); + expect(upsertPairingRequestMock).toHaveBeenCalledTimes(1); + expect(upsertPairingRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "zalo", + accountId: "acct-zalo-pairing", + id: "user-unauthorized", + }), + ); + expect(sendMessageMock).toHaveBeenCalledTimes(1); + expect(sendMessageMock).toHaveBeenCalledWith( + "zalo-token", + expect.objectContaining({ + chat_id: "dm-pairing-1", + text: expect.stringContaining("PAIRCODE"), + }), + undefined, + ); + } finally { + await monitor.stop(); + } }); it("does not emit a second pairing reply when replay arrives after the first send fails", async () => { sendMessageMock.mockRejectedValueOnce(new Error("pairing send failed")); - const { abort, route, run, runtime } = await startWebhookLifecycleMonitor( - createPairingMonitorSetup(), - ); + const monitor = await startWebhookLifecycleMonitor(createPairingMonitorSetup()); - await withServer( - (req, res) => route.handler(req, res), - async (baseUrl) => { - const { first, replay } = await postWebhookReplay({ - baseUrl, - path: "/hooks/zalo", - secret: "supersecret", - payload: createTextUpdate({ - messageId: `zalo-pairing-retry-${Date.now()}`, - userId: "user-unauthorized", - userName: "Unauthorized User", - chatId: "dm-pairing-1", - }), - settleBeforeReplay: true, - }); + try { + await withServer( + (req, res) => monitor.route.handler(req, res), + async (baseUrl) => { + const { first, replay } = await postWebhookReplay({ + baseUrl, + path: "/hooks/zalo", + secret: "supersecret", + payload: createTextUpdate({ + messageId: `zalo-pairing-retry-${Date.now()}`, + userId: "user-unauthorized", + userName: "Unauthorized User", + chatId: "dm-pairing-1", + }), + settleBeforeReplay: true, + }); - expect(first.status).toBe(200); - expect(replay.status).toBe(200); - await settleAsyncWork(); - }, - ); + expect(first.status).toBe(200); + expect(replay.status).toBe(200); + await settleAsyncWork(); + }, + ); - expect(upsertPairingRequestMock).toHaveBeenCalledTimes(1); - expect(sendMessageMock).toHaveBeenCalledTimes(1); - expect(runtime.error).not.toHaveBeenCalled(); - - abort.abort(); - await run; + expect(upsertPairingRequestMock).toHaveBeenCalledTimes(1); + expect(sendMessageMock).toHaveBeenCalledTimes(1); + expect(monitor.runtime.error).not.toHaveBeenCalled(); + } finally { + await monitor.stop(); + } }); }); diff --git a/extensions/zalo/src/monitor.reply-once.lifecycle.test.ts b/extensions/zalo/src/monitor.reply-once.lifecycle.test.ts index 3e95c65db53..ec59241f07c 100644 --- a/extensions/zalo/src/monitor.reply-once.lifecycle.test.ts +++ b/extensions/zalo/src/monitor.reply-once.lifecycle.test.ts @@ -63,57 +63,58 @@ describe("Zalo reply-once lifecycle", () => { }, ); - const { abort, route, run } = await startWebhookLifecycleMonitor(createReplyOnceMonitorSetup()); + const monitor = await startWebhookLifecycleMonitor(createReplyOnceMonitorSetup()); - await withServer( - (req, res) => route.handler(req, res), - async (baseUrl) => { - const { first, replay } = await postWebhookReplay({ - baseUrl, - path: "/hooks/zalo", - secret: "supersecret", - payload: createTextUpdate({ - messageId: `zalo-replay-${Date.now()}`, - userId: "user-1", - userName: "User One", - chatId: "dm-chat-1", - }), - }); + try { + await withServer( + (req, res) => monitor.route.handler(req, res), + async (baseUrl) => { + const { first, replay } = await postWebhookReplay({ + baseUrl, + path: "/hooks/zalo", + secret: "supersecret", + payload: createTextUpdate({ + messageId: `zalo-replay-${Date.now()}`, + userId: "user-1", + userName: "User One", + chatId: "dm-chat-1", + }), + }); - expect(first.status).toBe(200); - expect(replay.status).toBe(200); - await settleAsyncWork(); - }, - ); + expect(first.status).toBe(200); + expect(replay.status).toBe(200); + await settleAsyncWork(); + }, + ); - expect(finalizeInboundContextMock).toHaveBeenCalledTimes(1); - expect(finalizeInboundContextMock).toHaveBeenCalledWith( - expect.objectContaining({ - AccountId: "acct-zalo-lifecycle", - SessionKey: "agent:main:zalo:direct:dm-chat-1", - MessageSid: expect.stringContaining("zalo-replay-"), - From: "zalo:user-1", - To: "zalo:dm-chat-1", - }), - ); - expect(recordInboundSessionMock).toHaveBeenCalledTimes(1); - expect(recordInboundSessionMock).toHaveBeenCalledWith( - expect.objectContaining({ - sessionKey: "agent:main:zalo:direct:dm-chat-1", - }), - ); - expect(sendMessageMock).toHaveBeenCalledTimes(1); - expect(sendMessageMock).toHaveBeenCalledWith( - "zalo-token", - expect.objectContaining({ - chat_id: "dm-chat-1", - text: "zalo reply once", - }), - undefined, - ); - - abort.abort(); - await run; + expect(finalizeInboundContextMock).toHaveBeenCalledTimes(1); + expect(finalizeInboundContextMock).toHaveBeenCalledWith( + expect.objectContaining({ + AccountId: "acct-zalo-lifecycle", + SessionKey: "agent:main:zalo:direct:dm-chat-1", + MessageSid: expect.stringContaining("zalo-replay-"), + From: "zalo:user-1", + To: "zalo:dm-chat-1", + }), + ); + expect(recordInboundSessionMock).toHaveBeenCalledTimes(1); + expect(recordInboundSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:zalo:direct:dm-chat-1", + }), + ); + expect(sendMessageMock).toHaveBeenCalledTimes(1); + expect(sendMessageMock).toHaveBeenCalledWith( + "zalo-token", + expect.objectContaining({ + chat_id: "dm-chat-1", + text: "zalo reply once", + }), + undefined, + ); + } finally { + await monitor.stop(); + } }); it("does not emit a second visible reply when replay arrives after a post-send failure", async () => { @@ -128,39 +129,38 @@ describe("Zalo reply-once lifecycle", () => { }, ); - const { abort, route, run, runtime } = await startWebhookLifecycleMonitor( - createReplyOnceMonitorSetup(), - ); + const monitor = await startWebhookLifecycleMonitor(createReplyOnceMonitorSetup()); - await withServer( - (req, res) => route.handler(req, res), - async (baseUrl) => { - const { first, replay } = await postWebhookReplay({ - baseUrl, - path: "/hooks/zalo", - secret: "supersecret", - payload: createTextUpdate({ - messageId: `zalo-retry-${Date.now()}`, - userId: "user-1", - userName: "User One", - chatId: "dm-chat-1", - }), - settleBeforeReplay: true, - }); + try { + await withServer( + (req, res) => monitor.route.handler(req, res), + async (baseUrl) => { + const { first, replay } = await postWebhookReplay({ + baseUrl, + path: "/hooks/zalo", + secret: "supersecret", + payload: createTextUpdate({ + messageId: `zalo-retry-${Date.now()}`, + userId: "user-1", + userName: "User One", + chatId: "dm-chat-1", + }), + settleBeforeReplay: true, + }); - expect(first.status).toBe(200); - expect(replay.status).toBe(200); - await settleAsyncWork(); - }, - ); + expect(first.status).toBe(200); + expect(replay.status).toBe(200); + await settleAsyncWork(); + }, + ); - expect(dispatchReplyWithBufferedBlockDispatcherMock).toHaveBeenCalledTimes(1); - expect(sendMessageMock).toHaveBeenCalledTimes(1); - expect(runtime.error).toHaveBeenCalledWith( - expect.stringContaining("Zalo webhook failed: Error: post-send failure"), - ); - - abort.abort(); - await run; + expect(dispatchReplyWithBufferedBlockDispatcherMock).toHaveBeenCalledTimes(1); + expect(sendMessageMock).toHaveBeenCalledTimes(1); + expect(monitor.runtime.error).toHaveBeenCalledWith( + expect.stringContaining("Zalo webhook failed: Error: post-send failure"), + ); + } finally { + await monitor.stop(); + } }); }); diff --git a/test/helpers/extensions/zalo-lifecycle.ts b/test/helpers/extensions/zalo-lifecycle.ts index 7134febd129..31c132d0a32 100644 --- a/test/helpers/extensions/zalo-lifecycle.ts +++ b/test/helpers/extensions/zalo-lifecycle.ts @@ -1,3 +1,4 @@ +import { request as httpRequest } from "node:http"; import { expect, vi } from "vitest"; import type { ResolvedZaloAccount } from "../../../extensions/zalo/src/accounts.js"; import { @@ -282,13 +283,35 @@ export async function postWebhookUpdate(params: { secret: string; payload: Record; }) { - return await fetch(`${params.baseUrl}${params.path}`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-bot-api-secret-token": params.secret, - }, - body: JSON.stringify(params.payload), + const url = new URL(params.path, params.baseUrl); + const body = JSON.stringify(params.payload); + return await new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = httpRequest( + url, + { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": Buffer.byteLength(body), + "x-bot-api-secret-token": params.secret, + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + res.on("end", () => { + resolve({ + status: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString("utf8"), + }); + }); + }, + ); + req.on("error", reject); + req.write(body); + req.end(); }); } @@ -349,5 +372,9 @@ export async function startWebhookLifecycleMonitor(params: { route, run, runtime, + stop: async () => { + abort.abort(); + await run; + }, }; }