/** * 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 = {}; 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"); }); });