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.
taskbot/tests/unit/server.coverage.test.ts

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");
});
});