test: harden zalo webhook lifecycle tests

This commit is contained in:
Peter Steinberger 2026-03-29 00:47:43 +00:00
parent 9e1b524a00
commit 5ebccf5e30
3 changed files with 189 additions and 162 deletions

View File

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

View File

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

View File

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