You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
293 lines
8.7 KiB
TypeScript
293 lines
8.7 KiB
TypeScript
/**
|
|
* Coverage gap tests for src/server.ts functions flagged by fallow.
|
|
*
|
|
* These fill genuinely untested paths that fallow can't trace through
|
|
* the existing integration-level tests.
|
|
*/
|
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
import { Database } from "bun:sqlite";
|
|
import { WebhookServer } from "../../src/server";
|
|
import { getMessageText } from "../../src/http/webhook-handler";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Access private static methods for direct testing. */
|
|
const server = WebhookServer as any;
|
|
|
|
function createTestRequest(body: unknown): Request {
|
|
return new Request("http://localhost:3007", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
function createTestRequestRaw(body: string): Request {
|
|
return new Request("http://localhost:3007", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body,
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getMessageText — 5 branches, tested only indirectly
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("getMessageText — direct edge cases", () => {
|
|
test("returns empty for null / undefined", () => {
|
|
expect(getMessageText(null)).toBe("");
|
|
expect(getMessageText(undefined)).toBe("");
|
|
});
|
|
|
|
test("returns empty for non-object input", () => {
|
|
expect(getMessageText("a string")).toBe("");
|
|
expect(getMessageText(42)).toBe("");
|
|
});
|
|
|
|
test("extracts conversation text", () => {
|
|
expect(getMessageText({ conversation: " hello world " })).toBe(
|
|
"hello world",
|
|
);
|
|
});
|
|
|
|
test("falls back when conversation is empty string", () => {
|
|
expect(
|
|
getMessageText({
|
|
conversation: "",
|
|
extendedTextMessage: { text: "fallback" },
|
|
}),
|
|
).toBe("fallback");
|
|
});
|
|
|
|
test("extracts extendedTextMessage.text", () => {
|
|
expect(
|
|
getMessageText({
|
|
extendedTextMessage: { text: " extended text " },
|
|
}),
|
|
).toBe("extended text");
|
|
});
|
|
|
|
test("extracts imageMessage.caption", () => {
|
|
expect(
|
|
getMessageText({
|
|
imageMessage: { caption: " image caption " },
|
|
}),
|
|
).toBe("image caption");
|
|
});
|
|
|
|
test("extracts videoMessage.caption", () => {
|
|
expect(
|
|
getMessageText({
|
|
videoMessage: { caption: " video caption " },
|
|
}),
|
|
).toBe("video caption");
|
|
});
|
|
|
|
test("returns empty when all fields are empty strings", () => {
|
|
expect(
|
|
getMessageText({
|
|
conversation: "",
|
|
extendedTextMessage: { text: "" },
|
|
imageMessage: { caption: "" },
|
|
}),
|
|
).toBe("");
|
|
});
|
|
|
|
test("returns empty when text is a non-string (edge case)", () => {
|
|
expect(
|
|
getMessageText({ conversation: 12345 }),
|
|
).toBe("");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// validateEnv — error paths never exercised
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("validateEnv — error paths", () => {
|
|
let exitCode: number | null = null;
|
|
let stderr: string[] = [];
|
|
const realExit = process.exit;
|
|
const realConsoleError = console.error;
|
|
|
|
beforeEach(() => {
|
|
exitCode = null;
|
|
stderr = [];
|
|
process.exit = ((code?: number) => {
|
|
exitCode = code ?? 1;
|
|
throw new Error(`process.exit(${code})`);
|
|
}) as any;
|
|
console.error = (...args: any[]) => {
|
|
stderr.push(args.map(String).join(" "));
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.exit = realExit;
|
|
console.error = realConsoleError;
|
|
});
|
|
|
|
test("exits when required env vars are missing", () => {
|
|
// Remove a required var
|
|
const saved = process.env.EVOLUTION_API_URL;
|
|
delete (process.env as any).EVOLUTION_API_URL;
|
|
|
|
try {
|
|
server.validateEnv();
|
|
} catch {
|
|
// Expected — process.exit throws
|
|
}
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr.some((l) => l.includes("EVOLUTION_API_URL"))).toBe(true);
|
|
|
|
process.env.EVOLUTION_API_URL = saved;
|
|
});
|
|
|
|
test("exits when CHATBOT_PHONE_NUMBER contains non-digits", () => {
|
|
// Set all required vars so we reach the phone check
|
|
const saved: Record<string, string | undefined> = {};
|
|
for (const k of ["EVOLUTION_API_URL", "EVOLUTION_API_KEY", "EVOLUTION_API_INSTANCE", "WEBHOOK_URL"]) {
|
|
saved[k] = process.env[k];
|
|
process.env[k] = "set";
|
|
}
|
|
process.env.CHATBOT_PHONE_NUMBER = "55-1234-5678";
|
|
|
|
try {
|
|
server.validateEnv();
|
|
} catch {
|
|
// Expected
|
|
}
|
|
expect(exitCode).toBe(1);
|
|
expect(stderr.some((l) => l.includes("digits"))).toBe(true);
|
|
|
|
for (const k of Object.keys(saved)) {
|
|
process.env[k] = saved[k];
|
|
}
|
|
});
|
|
|
|
test("succeeds when all vars are valid", () => {
|
|
// Ensure required vars are set
|
|
const prev = {
|
|
EVOLUTION_API_URL: process.env.EVOLUTION_API_URL,
|
|
EVOLUTION_API_KEY: process.env.EVOLUTION_API_KEY,
|
|
EVOLUTION_API_INSTANCE: process.env.EVOLUTION_API_INSTANCE,
|
|
CHATBOT_PHONE_NUMBER: process.env.CHATBOT_PHONE_NUMBER,
|
|
WEBHOOK_URL: process.env.WEBHOOK_URL,
|
|
};
|
|
process.env.EVOLUTION_API_URL = "http://localhost:8080";
|
|
process.env.EVOLUTION_API_KEY = "k";
|
|
process.env.EVOLUTION_API_INSTANCE = "i";
|
|
process.env.CHATBOT_PHONE_NUMBER = "1234567890";
|
|
process.env.WEBHOOK_URL = "http://localhost:3000";
|
|
|
|
try {
|
|
server.validateEnv();
|
|
// Should not throw
|
|
expect(exitCode).toBeNull();
|
|
} finally {
|
|
Object.assign(process.env, prev);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// contacts.update / chats.update events — never tested
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("routeWebhookEvent — contacts.update / chats.update", () => {
|
|
test("handles contacts.update event without crash", async () => {
|
|
const res = await WebhookServer.handleRequest(
|
|
createTestRequest({
|
|
event: "contacts.update",
|
|
instance: "test-instance",
|
|
data: {
|
|
contacts: [
|
|
{ id: "1234567890@s.whatsapp.net", name: "Test User" },
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
test("handles chats.update event without crash", async () => {
|
|
const res = await WebhookServer.handleRequest(
|
|
createTestRequest({
|
|
event: "chats.update",
|
|
instance: "test-instance",
|
|
data: {
|
|
chats: [
|
|
{ id: "group-id@g.us", name: "Updated Chat" },
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
test("handles contacts.update with empty data gracefully", async () => {
|
|
const res = await WebhookServer.handleRequest(
|
|
createTestRequest({
|
|
event: "contacts.update",
|
|
instance: "test-instance",
|
|
data: {},
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Error path: malformed JSON body
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("handleRequest — error paths", () => {
|
|
test("returns 400 for malformed JSON body", async () => {
|
|
const req = createTestRequestRaw("{not valid json");
|
|
const res = await WebhookServer.handleRequest(req);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
test("returns 400 for empty body", async () => {
|
|
const req = new Request("http://localhost:3007", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
const res = await WebhookServer.handleRequest(req);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getBaseUrl — never tested
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("getBaseUrl", () => {
|
|
test("uses x-forwarded-proto and x-forwarded-host when present", () => {
|
|
const req = new Request("http://localhost:3007", {
|
|
headers: {
|
|
"x-forwarded-proto": "https",
|
|
"x-forwarded-host": "example.com",
|
|
},
|
|
});
|
|
expect(server.getBaseUrl(req)).toBe("https://example.com");
|
|
});
|
|
|
|
test("falls back to host header when no forwarded headers", () => {
|
|
const req = new Request("http://myhost.local:3007", {
|
|
headers: { host: "myhost.local:3007" },
|
|
});
|
|
expect(server.getBaseUrl(req)).toBe("http://myhost.local:3007");
|
|
});
|
|
|
|
test("uses http when no proto and no forwarded proto", () => {
|
|
const req = new Request("http://10.0.0.1:3007", {
|
|
headers: { host: "10.0.0.1:3007" },
|
|
});
|
|
expect(server.getBaseUrl(req)).toBe("http://10.0.0.1:3007");
|
|
});
|
|
});
|