import { describe, test, expect, beforeEach } from "bun:test"; import { Hono } from "hono"; import { apiResponse } from "../../src/modules/api-utils/apiResponse"; import type { ContentfulStatusCode } from "hono/utils/http-status"; /** * Tests for the CW callback route handler. * * We import the route handler and mount it on a Hono app to test via * the app.request() convenience method. */ // We need to test the internal helper functions. Since they are not // exported, we test them through the route handler's observable behavior. import callbackRoute from "../../src/api/cw/callback"; describe("CW callback route handler", () => { let app: Hono; beforeEach(() => { app = new Hono(); // Replicate the error handling from server.ts app.onError((err, c) => { if ((err as any).status) { const body = apiResponse.error(err); return c.json(body, body.status as ContentfulStatusCode); } return c.json(apiResponse.internalError(), 500); }); app.route("/", callbackRoute); // Clear the env var before each test delete (process.env as Record).CW_CALLBACK_SECRET; }); // ------------------------------------------------------------------- // Secret validation // ------------------------------------------------------------------- test("rejects when secret does not match CW_CALLBACK_SECRET", async () => { process.env.CW_CALLBACK_SECRET = "correct-secret"; const res = await app.request("/callback/wrong-secret/opportunity", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); expect(res.status).toBe(401); const body = await res.json(); expect(body.message).toContain("Invalid callback secret"); }); test("accepts when secret matches CW_CALLBACK_SECRET", async () => { process.env.CW_CALLBACK_SECRET = "correct-secret"; const res = await app.request("/callback/correct-secret/opportunity", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ Action: "updated", ID: 123 }), }); expect(res.status).toBe(200); }); test("accepts any secret when CW_CALLBACK_SECRET is not configured", async () => { const res = await app.request("/callback/any-secret/opportunity", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ Action: "created" }), }); expect(res.status).toBe(200); }); // ------------------------------------------------------------------- // Resource validation // ------------------------------------------------------------------- test("accepts 'opportunity' resource", async () => { const res = await app.request("/callback/test/opportunity", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.resource).toBe("opportunity"); }); test("accepts 'ticket' resource", async () => { const res = await app.request("/callback/test/ticket", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.resource).toBe("ticket"); }); test("accepts 'company' resource", async () => { const res = await app.request("/callback/test/company", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.resource).toBe("company"); }); test("accepts 'activity' resource", async () => { const res = await app.request("/callback/test/activity", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.resource).toBe("activity"); }); test("rejects invalid resource type", async () => { const res = await app.request("/callback/test/invalidtype", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); // Zod validation should fail expect(res.status).toBeGreaterThanOrEqual(400); }); // ------------------------------------------------------------------- // Body parsing // ------------------------------------------------------------------- test("parses JSON body fields", async () => { const res = await app.request("/callback/test/opportunity", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ Action: "updated", Type: "opportunity", ID: 42, MemberId: "jroberts", MessageId: "msg-123", }), }); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.summary.action).toBe("updated"); expect(body.data.summary.type).toBe("opportunity"); expect(body.data.summary.id).toBe(42); expect(body.data.summary.memberId).toBe("jroberts"); expect(body.data.summary.messageId).toBe("msg-123"); }); test("parses Entity field from JSON string", async () => { const entity = { CompanyName: "Acme Corp", StatusName: "Active", UpdatedBy: "admin", }; const res = await app.request("/callback/test/company", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ Action: "updated", Entity: JSON.stringify(entity), }), }); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.summary.entitySummary).toBe("Acme Corp"); expect(body.data.summary.entityStatus).toBe("Active"); expect(body.data.summary.entityUpdatedBy).toBe("admin"); }); test("handles Entity as inline object", async () => { const res = await app.request("/callback/test/company", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ Action: "created", Entity: { CompanyName: "Direct Corp", Status: "New" }, }), }); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.summary.entitySummary).toBe("Direct Corp"); expect(body.data.summary.entityStatus).toBe("New"); }); test("returns secretValidated field based on env presence", async () => { delete (process.env as Record).CW_CALLBACK_SECRET; const res = await app.request("/callback/test/opportunity", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); const body = await res.json(); expect(body.data.secretValidated).toBe(false); process.env.CW_CALLBACK_SECRET = "secret"; const res2 = await app.request("/callback/secret/opportunity", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); const body2 = await res2.json(); expect(body2.data.secretValidated).toBe(true); }); test("returns receivedAt timestamp", async () => { const res = await app.request("/callback/test/opportunity", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); const body = await res.json(); expect(body.data.receivedAt).toBeDefined(); // Should be a valid ISO date string expect(new Date(body.data.receivedAt).toISOString()).toBe( body.data.receivedAt, ); }); test("handles non-JSON body gracefully", async () => { const res = await app.request("/callback/test/opportunity", { method: "POST", headers: { "Content-Type": "text/plain" }, body: "this is not json", }); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.summary).toBeNull(); }); test("handles empty body gracefully", async () => { const res = await app.request("/callback/test/opportunity", { method: "POST", headers: { "Content-Type": "application/json" }, body: "", }); expect(res.status).toBe(200); const body = await res.json(); expect(body.data.summary).toBeNull(); }); });