242 lines
8.2 KiB
TypeScript
242 lines
8.2 KiB
TypeScript
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<string, any>).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<string, any>).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();
|
|
});
|
|
});
|