From a519807b610931cadf11a04a9a7c1786678c316d Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 16 Sep 2025 18:55:53 +0200 Subject: [PATCH 01/18] init tests --- apps/contact/bunfig.toml | 2 + apps/contact/helpers/notion.test.ts | 44 +++++++ apps/contact/helpers/slack.test.ts | 81 ++++++++++++ apps/contact/package.json | 4 + apps/contact/tests/api-route.test.tsx | 176 ++++++++++++++++++++++++++ bun.lock | 23 ++++ turbo.json | 5 + 7 files changed, 335 insertions(+) create mode 100644 apps/contact/bunfig.toml create mode 100644 apps/contact/helpers/notion.test.ts create mode 100644 apps/contact/helpers/slack.test.ts create mode 100644 apps/contact/tests/api-route.test.tsx diff --git a/apps/contact/bunfig.toml b/apps/contact/bunfig.toml new file mode 100644 index 00000000..4c3672f4 --- /dev/null +++ b/apps/contact/bunfig.toml @@ -0,0 +1,2 @@ +[test] +# Test configuration \ No newline at end of file diff --git a/apps/contact/helpers/notion.test.ts b/apps/contact/helpers/notion.test.ts new file mode 100644 index 00000000..4952deb6 --- /dev/null +++ b/apps/contact/helpers/notion.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; + +const originalEnv = process.env; +beforeEach(() => { + process.env = { + ...originalEnv, + NOTION_TOKEN: "mock-notion-token", + MENTION_EMAILS: "user1@example.com,user2@example.com", + MENTION_IDS: "user-id-1,user-id-2", + }; +}); + +afterEach(() => { + process.env = originalEnv; +}); + +describe("Notion Helper", () => { + describe("processContact", () => { + const validContactData = { + id: "test-id-123", + email: "john@example.com", + name: "John Doe", + message: "Test message", + databaseID: "mock-database-id", + source: "website", + }; + + it("should process contact data structure correctly", () => { + expect(validContactData.id).toBe("test-id-123"); + expect(validContactData.email).toContain("@"); + expect(validContactData.name.length).toBeGreaterThan(0); + expect(validContactData.message.length).toBeGreaterThan(0); + expect(validContactData.databaseID.length).toBeGreaterThan(0); + }); + + it("should validate required fields", () => { + const requiredFields = ["id", "email", "name", "databaseID"]; + requiredFields.forEach((field) => { + expect(validContactData).toHaveProperty(field); + expect((validContactData as any)[field]).toBeTruthy(); + }); + }); + }); +}); diff --git a/apps/contact/helpers/slack.test.ts b/apps/contact/helpers/slack.test.ts new file mode 100644 index 00000000..525ed84e --- /dev/null +++ b/apps/contact/helpers/slack.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { createPayload } from "./slack"; + +const originalEnv = process.env; +beforeEach(() => { + process.env = { + ...originalEnv, + SLACK_CHANNEL: "#test-channel", + SLACK_BOT_TOKEN: "xoxb-test-token", + }; +}); + +afterEach(() => { + process.env = originalEnv; +}); + +describe("Slack Helper", () => { + describe("createPayload", () => { + it("should create correct Slack payload structure", () => { + const payload = createPayload( + "John Doe", + "john@example.com", + "https://notion.so/test", + ); + + expect(payload).toHaveProperty("channel"); + expect(payload).toHaveProperty("blocks"); + expect(payload.blocks).toHaveLength(4); + + expect(payload.blocks[0]).toEqual({ + type: "header", + text: { + type: "plain_text", + text: "We have 1 new message(s).", + emoji: true, + }, + }); + + expect(payload.blocks[1]).toEqual({ + type: "section", + text: { + type: "mrkdwn", + text: "We got a new message from _John Doe_ (_john@example.com_).", + }, + }); + + expect(payload.blocks[3]).toEqual({ + type: "section", + text: { + type: "mrkdwn", + text: " ", + }, + accessory: { + type: "button", + text: { + type: "plain_text", + text: "Show me the message", + emoji: true, + }, + value: "new_message_click", + url: "https://notion.so/test", + action_id: "button-action", + }, + }); + }); + + it("should handle different user names and emails", () => { + const payload = createPayload( + "Jane Smith", + "jane@test.com", + "https://notion.so/page123", + ); + + expect(payload.blocks[1]?.text?.text).toContain("Jane Smith"); + expect(payload.blocks[1]?.text?.text).toContain("jane@test.com"); + expect(payload.blocks[3]?.accessory?.url).toBe( + "https://notion.so/page123", + ); + }); + }); +}); diff --git a/apps/contact/package.json b/apps/contact/package.json index c68fa790..618699c0 100644 --- a/apps/contact/package.json +++ b/apps/contact/package.json @@ -7,6 +7,7 @@ "build": "next build", "start": "next start", "lint": "next lint", + "test": "bun test", "emails": "email dev --dir email-templates" }, "dependencies": { @@ -22,10 +23,13 @@ }, "devDependencies": { "@react-email/preview-server": "4.2.11", + "@testing-library/jest-dom": "^6.0.0", + "@types/bun": "^1.2.22", "@types/node": "^24", "@types/nodemailer": "^7.0.1", "@types/react": "^19", "@types/react-dom": "^19", + "bun-types": "^1.2.22", "react-email": "4.2.11", "typescript": "^5" } diff --git a/apps/contact/tests/api-route.test.tsx b/apps/contact/tests/api-route.test.tsx new file mode 100644 index 00000000..1b2f6a46 --- /dev/null +++ b/apps/contact/tests/api-route.test.tsx @@ -0,0 +1,176 @@ +import { describe, it, expect, beforeEach, mock } from "bun:test"; +import { POST, OPTIONS } from "../app/api/contact/route"; + +const mockProcessContact = mock(() => Promise.resolve("mock-notion-id")); +const mockNanoid = mock(() => "mock-id-12345"); + +mock.module("@/helpers/notion", () => ({ + processContact: mockProcessContact, +})); + +mock.module("nanoid", () => ({ + nanoid: mockNanoid, +})); + +const originalEnv = process.env; +beforeEach(() => { + process.env = { + ...originalEnv, + NOTION_DATABASE_ID: "mock-database-id", + }; + mockProcessContact.mockClear(); + mockNanoid.mockClear(); +}); + +describe("Contact API Route", () => { + describe("OPTIONS", () => { + it("should return CORS headers", async () => { + const response = await OPTIONS(); + + expect(response.status).toBe(200); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(response.headers.get("Access-Control-Allow-Methods")).toBe( + "OPTIONS, POST", + ); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe( + "Content-Type, Authorization", + ); + }); + }); + + describe("POST", () => { + const createMockRequest = ( + body: any, + contentType = "application/json", + url = "http://localhost/api/contact", + ) => { + const request = new Request(url, { + method: "POST", + headers: { + "Content-Type": contentType, + }, + body: contentType === "application/json" ? JSON.stringify(body) : body, + }); + + (request as any).nextUrl = { + searchParams: new URLSearchParams(new URL(url).search), + }; + + return request as any; + }; + + it("should return 200 for valid request with consent", async () => { + const validBody = { + name: "John Doe", + email: "john@example.com", + message: "Hello there", + hasConsent: true, + }; + + const request = createMockRequest(validBody); + const response = await POST(request); + const responseData = await response.json(); + + expect(response.status).toBe(200); + expect(responseData.message).toBe("Success"); + expect(mockProcessContact).toHaveBeenCalledTimes(1); + }); + + it("should return 400 for invalid email", async () => { + const invalidBody = { + name: "John Doe", + email: "invalid-email", + message: "Hello there", + hasConsent: true, + }; + + const request = createMockRequest(invalidBody); + const response = await POST(request); + const responseData = await response.json(); + + expect(response.status).toBe(400); + expect(responseData.message).toContain("Invalid email"); + }); + + it("should return 400 for missing required fields", async () => { + const invalidBody = { + name: "John Doe", + email: "john@example.com", + message: "", + hasConsent: true, + }; + + const request = createMockRequest(invalidBody); + const response = await POST(request); + + expect(response.status).toBe(400); + }); + + it("should return 403 for no consent", async () => { + const bodyWithoutConsent = { + name: "John Doe", + email: "john@example.com", + message: "Hello there", + hasConsent: false, + }; + + const request = createMockRequest(bodyWithoutConsent); + const response = await POST(request); + const responseData = await response.json(); + + expect(response.status).toBe(403); + expect(responseData.message).toBe("No consent by user"); + }); + + it("should return 400 for non-JSON content type", async () => { + const request = createMockRequest("plain text", "text/plain"); + const response = await POST(request); + + expect(response.status).toBe(400); + }); + + it("should include CORS headers in all responses", async () => { + const validBody = { + name: "John Doe", + email: "john@example.com", + message: "Hello there", + hasConsent: true, + }; + + const request = createMockRequest(validBody); + const response = await POST(request); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(response.headers.get("Access-Control-Allow-Credentials")).toBe( + "true", + ); + expect(response.headers.get("Access-Control-Allow-Methods")).toBe( + "POST, OPTIONS", + ); + }); + + it("should handle source parameter from URL", async () => { + const validBody = { + name: "John Doe", + email: "john@example.com", + message: "Hello there", + hasConsent: true, + }; + + const request = createMockRequest( + validBody, + "application/json", + "http://localhost/api/contact?source=website", + ); + + const response = await POST(request); + + expect(response.status).toBe(200); + expect(mockProcessContact).toHaveBeenCalledWith( + expect.objectContaining({ + source: "website", + }), + ); + }); + }); +}); diff --git a/bun.lock b/bun.lock index 2b0db3bf..a4636436 100644 --- a/bun.lock +++ b/bun.lock @@ -27,10 +27,13 @@ }, "devDependencies": { "@react-email/preview-server": "4.2.11", + "@testing-library/jest-dom": "^6.0.0", + "@types/bun": "^1.2.22", "@types/node": "^24", "@types/nodemailer": "^7.0.1", "@types/react": "^19", "@types/react-dom": "^19", + "bun-types": "^1.2.22", "react-email": "4.2.11", "typescript": "^5", }, @@ -72,6 +75,8 @@ }, }, "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], @@ -654,6 +659,8 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.13", "", { "dependencies": { "@tailwindcss/node": "4.1.13", "@tailwindcss/oxide": "4.1.13", "tailwindcss": "4.1.13" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-0PmqLQ010N58SbMTJ7BVJ4I2xopiQn/5i6nlb4JmxzQf8zcS5+m2Cv6tqh+sfDwtIdjoEnOvwsGQ1hkUi8QEHQ=="], + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.8.0", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-WgXcWzVM6idy5JaftTVC8Vs83NKRmGJz4Hqs4oyOuO2J4r/y79vvKZsb+CaGyCSEbUPI6OsewfPd0G1A0/TUZQ=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -662,6 +669,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="], + "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], @@ -834,6 +843,8 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="], + "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], @@ -916,6 +927,8 @@ "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], @@ -956,6 +969,8 @@ "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -1106,6 +1121,8 @@ "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], @@ -1308,6 +1325,8 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + "mini-svg-data-uri": ["mini-svg-data-uri@1.4.4", "", { "bin": { "mini-svg-data-uri": "cli.js" } }, "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg=="], "minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], @@ -1490,6 +1509,8 @@ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + "regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="], "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], @@ -1608,6 +1629,8 @@ "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + "strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], diff --git a/turbo.json b/turbo.json index 85bfc80c..8a9a8dab 100644 --- a/turbo.json +++ b/turbo.json @@ -14,6 +14,11 @@ "dev": { "cache": false, "persistent": true + }, + "test": { + "dependsOn": ["^test"], + "inputs": ["$TURBO_DEFAULT$", ".env*"], + "outputs": [] } }, "globalEnv": [ From a1c1fcfb9359289b79a90dfe04fa4094b1141bde Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 16 Sep 2025 19:19:56 +0200 Subject: [PATCH 02/18] test workflow --- .github/workflows/test.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..73d2f86d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,35 @@ +name: Test + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +permissions: + contents: read + +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + + steps: + - name: Harden Runner + uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Setup Bun + uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v1 + with: + bun-version: "1.2.21" + + - name: Install dependencies + run: bun install + + - name: Run tests + run: bun run test From 14cd0552b374ace342d1798dbf179dfb28a15667 Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 16 Sep 2025 19:22:39 +0200 Subject: [PATCH 03/18] remove unused packages --- apps/contact/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/contact/package.json b/apps/contact/package.json index 618699c0..f804b6ec 100644 --- a/apps/contact/package.json +++ b/apps/contact/package.json @@ -23,7 +23,6 @@ }, "devDependencies": { "@react-email/preview-server": "4.2.11", - "@testing-library/jest-dom": "^6.0.0", "@types/bun": "^1.2.22", "@types/node": "^24", "@types/nodemailer": "^7.0.1", From 3dcd2a397f51a24ed9498dd850a91ff25bbcace1 Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 16 Sep 2025 19:23:08 +0200 Subject: [PATCH 04/18] bun i --- bun.lock | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/bun.lock b/bun.lock index a4636436..559db794 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,6 @@ }, "devDependencies": { "@react-email/preview-server": "4.2.11", - "@testing-library/jest-dom": "^6.0.0", "@types/bun": "^1.2.22", "@types/node": "^24", "@types/nodemailer": "^7.0.1", @@ -75,8 +74,6 @@ }, }, "packages": { - "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], - "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], @@ -659,8 +656,6 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.13", "", { "dependencies": { "@tailwindcss/node": "4.1.13", "@tailwindcss/oxide": "4.1.13", "tailwindcss": "4.1.13" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-0PmqLQ010N58SbMTJ7BVJ4I2xopiQn/5i6nlb4JmxzQf8zcS5+m2Cv6tqh+sfDwtIdjoEnOvwsGQ1hkUi8QEHQ=="], - "@testing-library/jest-dom": ["@testing-library/jest-dom@6.8.0", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-WgXcWzVM6idy5JaftTVC8Vs83NKRmGJz4Hqs4oyOuO2J4r/y79vvKZsb+CaGyCSEbUPI6OsewfPd0G1A0/TUZQ=="], - "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -927,8 +922,6 @@ "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], - "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], - "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], @@ -969,8 +962,6 @@ "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], - "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], - "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -1121,8 +1112,6 @@ "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], - "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], @@ -1325,8 +1314,6 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], - "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], - "mini-svg-data-uri": ["mini-svg-data-uri@1.4.4", "", { "bin": { "mini-svg-data-uri": "cli.js" } }, "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg=="], "minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], @@ -1509,8 +1496,6 @@ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], - "regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="], "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], @@ -1629,8 +1614,6 @@ "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], - "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], - "strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], From c144e3bfa86f924cf99677d4f9cb464b180be515 Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 16 Sep 2025 19:30:53 +0200 Subject: [PATCH 05/18] fix workflow --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 73d2f86d..cdb497f8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,4 +32,4 @@ jobs: run: bun install - name: Run tests - run: bun run test + run: bun test From 604881b8dc3ce370520bb133d0c18e6637f6c80b Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 16 Sep 2025 19:36:40 +0200 Subject: [PATCH 06/18] turbo env vars --- turbo.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/turbo.json b/turbo.json index 8a9a8dab..0c7445af 100644 --- a/turbo.json +++ b/turbo.json @@ -30,6 +30,10 @@ "NOTION_TOKEN", "SLACK_BOT_TOKEN", "NOTION_DATABASE_ID", - "SITE_URL" + "SITE_URL", + "VITE_CONTACT_URL", + "NOTION_GET_PLAN_DATABASE_ID", + "EMAIL_AWS_ACCESS_KEY", + "EMAIL_AWS_SECRET_ACCESS_KEY" ] } From 2558e21b31186089af71c8f592a722dde1022bfa Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 16 Sep 2025 21:01:32 +0200 Subject: [PATCH 07/18] add comment --- apps/contact/tests/api-route.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/contact/tests/api-route.test.tsx b/apps/contact/tests/api-route.test.tsx index 1b2f6a46..c9871b3c 100644 --- a/apps/contact/tests/api-route.test.tsx +++ b/apps/contact/tests/api-route.test.tsx @@ -92,6 +92,7 @@ describe("Contact API Route", () => { expect(responseData.message).toContain("Invalid email"); }); + /** Should this be the case? **/ it("should return 400 for missing required fields", async () => { const invalidBody = { name: "John Doe", From 806a0bbec3199d607de0e954f09f885357164007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ante=20Kocei=C4=87?= Date: Thu, 25 Sep 2025 17:22:18 +0200 Subject: [PATCH 08/18] Updated tests changes to contact/route --- .github/workflows/test.yml | 2 +- apps/contact/app/api/contact/route.tsx | 63 ++++++------- apps/contact/helpers/notion.test.ts | 71 +++++++++++++-- apps/contact/helpers/notion.ts | 48 +++------- apps/contact/helpers/slack.test.ts | 91 +++++++++---------- apps/contact/helpers/slack.ts | 34 +++---- apps/contact/package.json | 2 +- apps/contact/tests/api-route.test.tsx | 77 ++++++++-------- .../src/components/BaseContactForm.astro | 1 + 9 files changed, 204 insertions(+), 185 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cdb497f8..73d2f86d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,4 +32,4 @@ jobs: run: bun install - name: Run tests - run: bun test + run: bun run test diff --git a/apps/contact/app/api/contact/route.tsx b/apps/contact/app/api/contact/route.tsx index 024cb6c2..241782b5 100644 --- a/apps/contact/app/api/contact/route.tsx +++ b/apps/contact/app/api/contact/route.tsx @@ -1,6 +1,7 @@ //import { ContactTemplate } from "@/email-templates/contact"; //import { sendEmail } from "@/helpers/email"; -import { processContact } from "@/helpers/notion"; +import { createContact } from "@/helpers/notion"; +import { notifyContactCreated } from "@/helpers/slack"; import { nanoid } from "nanoid"; import { NextRequest } from "next/server"; import z from "zod"; @@ -16,31 +17,34 @@ const bodyValidationSchema = z.object({ type RequestBody = z.infer; -const { NOTION_DATABASE_ID } = process.env; +const { NOTION_DATABASE_ID, VERCEL_ENV, VERCEL_URL } = process.env; -const allowRequest = async (request: Request & { ip?: string }) => { - return { success: true, limit: 1, reset: Date.now() + 30000, remaining: 1 }; -}; +const allowOrigin = !VERCEL_ENV + ? "http://localhost:4321" + : VERCEL_ENV === "previev" || VERCEL_ENV === "development" + ? VERCEL_URL || "" + : "https://crocoder.dev"; export async function OPTIONS() { return new Response(null, { status: 200, headers: { - "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Origin": allowOrigin, "Access-Control-Allow-Methods": "OPTIONS, POST", - "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Allow-Headers": "Content-Type", }, }); } export async function POST(request: NextRequest) { const corsHeaders = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Origin": allowOrigin, + "Access-Control-Allow-Credentials": "false", "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Allow-Headers": "Content-Type", }; - + console.log("ENV", VERCEL_ENV); + console.log("URL", VERCEL_URL); if (request.headers.get("Content-Type") === "application/json") { try { const body = (await request.json()) as RequestBody; @@ -71,30 +75,26 @@ export async function POST(request: NextRequest) { }); } - const { success, limit, reset, remaining } = await allowRequest(request); + const referer = request.headers.get("referer"); + const origin = request.headers.get("origin"); + let source = "Unknown"; - if (!success) { - return new Response( - JSON.stringify({ - message: "Too many requests. Please try again in a minute", - }), - { - status: 429, - headers: { - ...corsHeaders, - }, - }, - ); + if (referer && origin && referer.startsWith(origin)) { + source = referer.slice(origin.length); } - await processContact({ - id: nanoid(), + const { id: notionPageId, url } = await createContact( + `Message from ${name} (${nanoid()})`, email, name, message, - databaseID: NOTION_DATABASE_ID || "", - source: request.nextUrl.searchParams.get("source") || "Unknown", - }); + NOTION_DATABASE_ID || "", + source, + ); + + if (notionPageId && url) { + await notifyContactCreated(name, email, "url"); + } /* await sendEmail({ template: , @@ -109,9 +109,6 @@ export async function POST(request: NextRequest) { status: 200, headers: { ...corsHeaders, - "X-RateLimit-Limit": limit.toString(), - "X-RateLimit-Remaining": remaining.toString(), - "X-RateLimit-Reset": reset.toString(), }, }, ); @@ -131,5 +128,5 @@ export async function POST(request: NextRequest) { } } - return new Response(null, { status: 400, headers: corsHeaders }); + return new Response(null, { status: 415, headers: corsHeaders }); } diff --git a/apps/contact/helpers/notion.test.ts b/apps/contact/helpers/notion.test.ts index 4952deb6..1b0aa302 100644 --- a/apps/contact/helpers/notion.test.ts +++ b/apps/contact/helpers/notion.test.ts @@ -1,4 +1,16 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { createContactObject, createContact, notion } from "./notion"; + +let callCount = 0; + +notion.pages.create = async (args: any) => { + callCount++; + return { + object: "page", + id: "fake-page-id", + url: "https://www.notion.so/fakepageid", + }; +}; const originalEnv = process.env; beforeEach(() => { @@ -15,24 +27,48 @@ afterEach(() => { }); describe("Notion Helper", () => { - describe("processContact", () => { + describe("createContactObject", () => { + it("should create correct Notion payload structure", () => { + const payload = createContactObject( + "test-id-123", + "john@example.com", + "John Doe", + "Test message", + "mock-database-id", + "website", + ); + + expect(payload).toHaveProperty("parent"); + expect(payload).toHaveProperty("properties"); + expect(payload).toHaveProperty("children"); + + expect(payload.parent.database_id).toBe("mock-database-id"); + + expect(payload.properties.id.title[0].text.content).toBe("test-id-123"); + expect(payload.properties.email.email).toBe("john@example.com"); + expect(payload.properties.name.rich_text[0].text.content).toBe( + "John Doe", + ); + expect(payload.properties.source.rich_text[0].text.content).toBe( + "website", + ); + + expect(payload.children[0].paragraph.rich_text[0].text?.content).toBe( + "Test message", + ); + }); + }); + + describe("createContact", () => { const validContactData = { id: "test-id-123", email: "john@example.com", name: "John Doe", - message: "Test message", + content: "Test message", databaseID: "mock-database-id", source: "website", }; - it("should process contact data structure correctly", () => { - expect(validContactData.id).toBe("test-id-123"); - expect(validContactData.email).toContain("@"); - expect(validContactData.name.length).toBeGreaterThan(0); - expect(validContactData.message.length).toBeGreaterThan(0); - expect(validContactData.databaseID.length).toBeGreaterThan(0); - }); - it("should validate required fields", () => { const requiredFields = ["id", "email", "name", "databaseID"]; requiredFields.forEach((field) => { @@ -40,5 +76,20 @@ describe("Notion Helper", () => { expect((validContactData as any)[field]).toBeTruthy(); }); }); + + it("should process contact data structure correctly", async () => { + const response = await createContact( + "test-id-123", + "john@example.com", + "John Doe", + "Test message", + "mock-database-id", + "website", + ); + + expect(response.id).toBe("fake-page-id"); + expect(response.url).toContain("www.notion.so"); + expect(callCount).toBe(1); + }); }); }); diff --git a/apps/contact/helpers/notion.ts b/apps/contact/helpers/notion.ts index 51fa3074..cdb5af0c 100644 --- a/apps/contact/helpers/notion.ts +++ b/apps/contact/helpers/notion.ts @@ -1,9 +1,8 @@ import { Client, isFullPage } from "@notionhq/client"; -import { notifyContactCreated } from "./slack"; const { NOTION_TOKEN, MENTION_EMAILS, MENTION_IDS } = process.env; -const notion = new Client({ auth: NOTION_TOKEN }); +export const notion = new Client({ auth: NOTION_TOKEN }); const mentionPerson = ({ id }: { id: string }) => [ { @@ -40,7 +39,7 @@ const mentionPeople = () => { return getMentions().flatMap(mentionPerson); }; -const createContactObject = ( +export const createContactObject = ( id: string, email: string, name: string, @@ -108,7 +107,7 @@ const createContactObject = ( ], }); -const createContact = async ( +export const createContact = async ( id: string, email: string, name: string, @@ -116,6 +115,15 @@ const createContact = async ( databaseID: string, source: string, ) => { + if (!id || !email || !name || !databaseID) { + console.log(id, email, name, databaseID); + throw { + body: { + message: "Missing data in process contact event", + }, + }; + } + const response = await notion.pages.create( createContactObject(id, email, name, content, databaseID, source), ); @@ -132,35 +140,3 @@ const createContact = async ( }, }; }; - -export const processContact = async (event: { - id: string; - email: string; - name: string; - message: string; - databaseID: string; - source: string; -}) => { - const { id, email, name, message, databaseID, source } = event; - - if (!id || !email || !name || !databaseID) { - console.log({ event }); - throw { - body: { - message: "Missing data in process contact event", - }, - }; - } - - const { id: notionPageID, url } = await createContact( - `Message from ${name} (${id})`, - email, - name, - message, - databaseID, - source, - ); - - await notifyContactCreated(name, email, url); - return notionPageID; -}; diff --git a/apps/contact/helpers/slack.test.ts b/apps/contact/helpers/slack.test.ts index 525ed84e..9a0e0153 100644 --- a/apps/contact/helpers/slack.test.ts +++ b/apps/contact/helpers/slack.test.ts @@ -1,17 +1,37 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { createPayload } from "./slack"; +import { createPayload, notifyContactCreated } from "./slack"; + +const originalEnv = { ...process.env }; +const originalFetch = globalThis.fetch; +let fetchCalls: string[] = []; + +const mockSlackResponse = { + ok: true, + channel: "#test-channel", + message: { + text: "Test", + type: "message", + }, +}; -const originalEnv = process.env; beforeEach(() => { process.env = { ...originalEnv, SLACK_CHANNEL: "#test-channel", SLACK_BOT_TOKEN: "xoxb-test-token", }; + + fetchCalls = []; + + globalThis.fetch = (async (url: string) => { + fetchCalls.push(url); + return { status: 200, json: async () => mockSlackResponse } as Response; + }) as typeof fetch; }); afterEach(() => { - process.env = originalEnv; + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; }); describe("Slack Helper", () => { @@ -27,55 +47,34 @@ describe("Slack Helper", () => { expect(payload).toHaveProperty("blocks"); expect(payload.blocks).toHaveLength(4); - expect(payload.blocks[0]).toEqual({ - type: "header", - text: { - type: "plain_text", - text: "We have 1 new message(s).", - emoji: true, - }, - }); + // Checks that it is type header and contains text + expect(payload.blocks[0]).toHaveProperty("type", "header"); + expect(payload.blocks[0]).toHaveProperty("text"); + expect(payload.blocks[0].text?.text.length).toBeGreaterThan(0); - expect(payload.blocks[1]).toEqual({ - type: "section", - text: { - type: "mrkdwn", - text: "We got a new message from _John Doe_ (_john@example.com_).", - }, - }); + // Checks that it is type section and the text conatins name and email + expect(payload.blocks[1]).toHaveProperty("type", "section"); + expect(payload.blocks[1]).toHaveProperty("text"); + expect(payload.blocks[1].text?.text).toContain("John Doe"); + expect(payload.blocks[1].text?.text).toContain("john@example.com"); - expect(payload.blocks[3]).toEqual({ - type: "section", - text: { - type: "mrkdwn", - text: " ", - }, - accessory: { - type: "button", - text: { - type: "plain_text", - text: "Show me the message", - emoji: true, - }, - value: "new_message_click", - url: "https://notion.so/test", - action_id: "button-action", - }, - }); + // Checks that it is type section and the accessory conatins the url + expect(payload.blocks[3]).toHaveProperty("type", "section"); + expect(payload.blocks[3]).toHaveProperty("accessory"); + expect(payload.blocks[3].accessory?.url).toBe("https://notion.so/test"); }); + }); - it("should handle different user names and emails", () => { - const payload = createPayload( - "Jane Smith", - "jane@test.com", - "https://notion.so/page123", + describe("notifyContactCreated", async () => { + it("should send message on slack", async () => { + await notifyContactCreated( + "John Doe", + "john@example.com", + "https://notion.so/test", ); - expect(payload.blocks[1]?.text?.text).toContain("Jane Smith"); - expect(payload.blocks[1]?.text?.text).toContain("jane@test.com"); - expect(payload.blocks[3]?.accessory?.url).toBe( - "https://notion.so/page123", - ); + expect(fetchCalls.length).toBe(1); + expect(fetchCalls[0]).toBe("https://slack.com/api/chat.postMessage"); }); }); }); diff --git a/apps/contact/helpers/slack.ts b/apps/contact/helpers/slack.ts index 8114d6d9..91606dcd 100644 --- a/apps/contact/helpers/slack.ts +++ b/apps/contact/helpers/slack.ts @@ -1,4 +1,4 @@ -const { SLACK_CHANNEL, SLACK_BOT_TOKEN, IS_OFFLINE } = process.env; +const { SLACK_CHANNEL, SLACK_BOT_TOKEN } = process.env; export const createPayload = (name: string, email: string, url: string) => ({ channel: SLACK_CHANNEL, @@ -50,24 +50,18 @@ export const notifyContactCreated = async ( const payload = createPayload(name, email, url); const payloadStringify = JSON.stringify(payload); - if (IS_OFFLINE) { - console.log(payload); - } else { - const result = await fetch("https://slack.com/api/chat.postMessage", { - method: "POST", - body: payloadStringify, - headers: { - "Content-Type": "application/json; charset=utf-8", - "Content-Length": payloadStringify.length.toString(), - Authorization: `Bearer ${SLACK_BOT_TOKEN}`, - Accept: "application/json", - }, - }); - if (result.status !== 200) { - throw { - body: "Could not send notification message to Slack", - statusCode: result.status, - }; - } + const result = await fetch("https://slack.com/api/chat.postMessage", { + method: "POST", + body: payloadStringify, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": payloadStringify.length.toString(), + Authorization: `Bearer ${SLACK_BOT_TOKEN}`, + Accept: "application/json", + }, + }); + const realResponse = await result.json(); + if (result.status !== 200 || !realResponse.ok) { + console.error("Could not send notification message to Slack"); } }; diff --git a/apps/contact/package.json b/apps/contact/package.json index f804b6ec..5f43a1af 100644 --- a/apps/contact/package.json +++ b/apps/contact/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "next start", "lint": "next lint", - "test": "bun test", + "test": "bun test helpers/ && bun test tests/", "emails": "email dev --dir email-templates" }, "dependencies": { diff --git a/apps/contact/tests/api-route.test.tsx b/apps/contact/tests/api-route.test.tsx index c9871b3c..c178fa99 100644 --- a/apps/contact/tests/api-route.test.tsx +++ b/apps/contact/tests/api-route.test.tsx @@ -1,11 +1,22 @@ import { describe, it, expect, beforeEach, mock } from "bun:test"; import { POST, OPTIONS } from "../app/api/contact/route"; -const mockProcessContact = mock(() => Promise.resolve("mock-notion-id")); +const mockCreateContact = mock(() => + Promise.resolve({ + object: "page", + id: "mock-notion-id", + url: "https://www.notion.so/fakepageid", + }), +); +const mockNotifyContactCreated = mock(() => Promise.resolve({})); const mockNanoid = mock(() => "mock-id-12345"); mock.module("@/helpers/notion", () => ({ - processContact: mockProcessContact, + createContact: mockCreateContact, +})); + +mock.module("@/helpers/slack", () => ({ + notifyContactCreated: mockNotifyContactCreated, })); mock.module("nanoid", () => ({ @@ -18,7 +29,8 @@ beforeEach(() => { ...originalEnv, NOTION_DATABASE_ID: "mock-database-id", }; - mockProcessContact.mockClear(); + mockCreateContact.mockClear(); + mockNotifyContactCreated.mockClear(); mockNanoid.mockClear(); }); @@ -28,12 +40,15 @@ describe("Contact API Route", () => { const response = await OPTIONS(); expect(response.status).toBe(200); - expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + // Should * be alowed or should there be some restrictions + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "http://localhost:4321", + ); expect(response.headers.get("Access-Control-Allow-Methods")).toBe( "OPTIONS, POST", ); expect(response.headers.get("Access-Control-Allow-Headers")).toBe( - "Content-Type, Authorization", + "Content-Type", ); }); }); @@ -72,9 +87,10 @@ describe("Contact API Route", () => { const responseData = await response.json(); expect(response.status).toBe(200); - expect(responseData.message).toBe("Success"); - expect(mockProcessContact).toHaveBeenCalledTimes(1); + expect(mockCreateContact).toHaveBeenCalledTimes(1); + expect(mockNotifyContactCreated).toHaveBeenCalledTimes(1); }); + // Add new test that checks the contetnt type it("should return 400 for invalid email", async () => { const invalidBody = { @@ -89,10 +105,10 @@ describe("Contact API Route", () => { const responseData = await response.json(); expect(response.status).toBe(400); - expect(responseData.message).toContain("Invalid email"); + expect(mockCreateContact).toHaveBeenCalledTimes(0); + expect(mockNotifyContactCreated).toHaveBeenCalledTimes(0); }); - /** Should this be the case? **/ it("should return 400 for missing required fields", async () => { const invalidBody = { name: "John Doe", @@ -105,6 +121,8 @@ describe("Contact API Route", () => { const response = await POST(request); expect(response.status).toBe(400); + expect(mockCreateContact).toHaveBeenCalledTimes(0); + expect(mockNotifyContactCreated).toHaveBeenCalledTimes(0); }); it("should return 403 for no consent", async () => { @@ -120,14 +138,15 @@ describe("Contact API Route", () => { const responseData = await response.json(); expect(response.status).toBe(403); - expect(responseData.message).toBe("No consent by user"); + expect(mockCreateContact).toHaveBeenCalledTimes(0); + expect(mockNotifyContactCreated).toHaveBeenCalledTimes(0); }); - it("should return 400 for non-JSON content type", async () => { + it("should return 415 for non-JSON content type", async () => { const request = createMockRequest("plain text", "text/plain"); const response = await POST(request); - expect(response.status).toBe(400); + expect(response.status).toBe(415); }); it("should include CORS headers in all responses", async () => { @@ -141,37 +160,19 @@ describe("Contact API Route", () => { const request = createMockRequest(validBody); const response = await POST(request); - expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + // Should * be alowed or should there be some restrictions + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "http://localhost:4321", + ); expect(response.headers.get("Access-Control-Allow-Credentials")).toBe( - "true", + "false", ); expect(response.headers.get("Access-Control-Allow-Methods")).toBe( "POST, OPTIONS", ); }); - - it("should handle source parameter from URL", async () => { - const validBody = { - name: "John Doe", - email: "john@example.com", - message: "Hello there", - hasConsent: true, - }; - - const request = createMockRequest( - validBody, - "application/json", - "http://localhost/api/contact?source=website", - ); - - const response = await POST(request); - - expect(response.status).toBe(200); - expect(mockProcessContact).toHaveBeenCalledWith( - expect.objectContaining({ - source: "website", - }), - ); - }); + // Check if there is need for tetsing the headers for all casses + // If so should there be individual tests? + // Check Allow headers to only have content-type }); }); diff --git a/apps/website/src/components/BaseContactForm.astro b/apps/website/src/components/BaseContactForm.astro index e2beab75..75e6ddc2 100644 --- a/apps/website/src/components/BaseContactForm.astro +++ b/apps/website/src/components/BaseContactForm.astro @@ -329,6 +329,7 @@ const { onDark, classNames, formId, formClassNames } = Astro.props; message, hasConsent: consent ? true : false, }), + referrerPolicy: "no-referrer-when-downgrade", }); if (response.status !== 200 && notificationElem) { From 65476c8a7207f285021d6cc13439f81e6004c2ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ante=20Kocei=C4=87?= Date: Thu, 25 Sep 2025 17:32:39 +0200 Subject: [PATCH 09/18] Testing --- apps/contact/app/api/contact/route.tsx | 1 + package.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/contact/app/api/contact/route.tsx b/apps/contact/app/api/contact/route.tsx index 241782b5..a459ea16 100644 --- a/apps/contact/app/api/contact/route.tsx +++ b/apps/contact/app/api/contact/route.tsx @@ -45,6 +45,7 @@ export async function POST(request: NextRequest) { }; console.log("ENV", VERCEL_ENV); console.log("URL", VERCEL_URL); + return new Response(null, { status: 200 }); if (request.headers.get("Content-Type") === "application/json") { try { const body = (await request.json()) as RequestBody; diff --git a/package.json b/package.json index 538ed1f8..0bc6f4fb 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "build": "turbo build", "dev": "turbo dev", "lint": "turbo lint", - "format": "prettier --write \"**/*.{ts,tsx,astro}\"" + "format": "prettier --write \"**/*.{ts,tsx,astro}\"", + "test": "bun test apps/contact/helpers/ && bun test apps/contact/tests/" }, "devDependencies": { "turbo": "^2.5.6" From 626e54e94688f27bd4d450df243339317a981469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ante=20Kocei=C4=87?= Date: Thu, 25 Sep 2025 17:35:25 +0200 Subject: [PATCH 10/18] Testing 2 --- apps/contact/app/api/contact/route.tsx | 40 +++++++++++++------------- apps/contact/helpers/notion.ts | 1 - 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/apps/contact/app/api/contact/route.tsx b/apps/contact/app/api/contact/route.tsx index a459ea16..99f4c082 100644 --- a/apps/contact/app/api/contact/route.tsx +++ b/apps/contact/app/api/contact/route.tsx @@ -76,26 +76,26 @@ export async function POST(request: NextRequest) { }); } - const referer = request.headers.get("referer"); - const origin = request.headers.get("origin"); - let source = "Unknown"; - - if (referer && origin && referer.startsWith(origin)) { - source = referer.slice(origin.length); - } - - const { id: notionPageId, url } = await createContact( - `Message from ${name} (${nanoid()})`, - email, - name, - message, - NOTION_DATABASE_ID || "", - source, - ); - - if (notionPageId && url) { - await notifyContactCreated(name, email, "url"); - } + // const referer = request.headers.get("referer"); + // const origin = request.headers.get("origin"); + // let source = "Unknown"; + + // if (referer && origin && referer.startsWith(origin)) { + // source = referer.slice(origin.length); + // } + + // const { id: notionPageId, url } = await createContact( + // `Message from ${name} (${nanoid()})`, + // email, + // name, + // message, + // NOTION_DATABASE_ID || "", + // source, + // ); + + // if (notionPageId && url) { + // await notifyContactCreated(name, email, "url"); + // } /* await sendEmail({ template: , diff --git a/apps/contact/helpers/notion.ts b/apps/contact/helpers/notion.ts index cdb5af0c..97c5b118 100644 --- a/apps/contact/helpers/notion.ts +++ b/apps/contact/helpers/notion.ts @@ -116,7 +116,6 @@ export const createContact = async ( source: string, ) => { if (!id || !email || !name || !databaseID) { - console.log(id, email, name, databaseID); throw { body: { message: "Missing data in process contact event", From e741fe300d0c6bbd0d57a70285ee4ca4918e54da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ante=20Kocei=C4=87?= Date: Thu, 25 Sep 2025 17:45:32 +0200 Subject: [PATCH 11/18] Added logs --- apps/contact/app/api/contact/route.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/contact/app/api/contact/route.tsx b/apps/contact/app/api/contact/route.tsx index 99f4c082..be58e25c 100644 --- a/apps/contact/app/api/contact/route.tsx +++ b/apps/contact/app/api/contact/route.tsx @@ -45,6 +45,8 @@ export async function POST(request: NextRequest) { }; console.log("ENV", VERCEL_ENV); console.log("URL", VERCEL_URL); + console.log("URL", process.env.VERCEL_BRANCH_URL); + console.log("URL", process.env); return new Response(null, { status: 200 }); if (request.headers.get("Content-Type") === "application/json") { try { From 06f5fa8e514ddfff4265f60e51733c5f78e33d4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ante=20Kocei=C4=87?= Date: Thu, 25 Sep 2025 17:53:41 +0200 Subject: [PATCH 12/18] Removed logs --- apps/contact/app/api/contact/route.tsx | 45 ++++++++++++-------------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/apps/contact/app/api/contact/route.tsx b/apps/contact/app/api/contact/route.tsx index be58e25c..2482e021 100644 --- a/apps/contact/app/api/contact/route.tsx +++ b/apps/contact/app/api/contact/route.tsx @@ -43,11 +43,6 @@ export async function POST(request: NextRequest) { "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }; - console.log("ENV", VERCEL_ENV); - console.log("URL", VERCEL_URL); - console.log("URL", process.env.VERCEL_BRANCH_URL); - console.log("URL", process.env); - return new Response(null, { status: 200 }); if (request.headers.get("Content-Type") === "application/json") { try { const body = (await request.json()) as RequestBody; @@ -78,26 +73,26 @@ export async function POST(request: NextRequest) { }); } - // const referer = request.headers.get("referer"); - // const origin = request.headers.get("origin"); - // let source = "Unknown"; - - // if (referer && origin && referer.startsWith(origin)) { - // source = referer.slice(origin.length); - // } - - // const { id: notionPageId, url } = await createContact( - // `Message from ${name} (${nanoid()})`, - // email, - // name, - // message, - // NOTION_DATABASE_ID || "", - // source, - // ); - - // if (notionPageId && url) { - // await notifyContactCreated(name, email, "url"); - // } + const referer = request.headers.get("referer"); + const origin = request.headers.get("origin"); + let source = "Unknown"; + + if (referer && origin && referer.startsWith(origin)) { + source = referer.slice(origin.length); + } + + const { id: notionPageId, url } = await createContact( + `Message from ${name} (${nanoid()})`, + email, + name, + message, + NOTION_DATABASE_ID || "", + source, + ); + + if (notionPageId && url) { + await notifyContactCreated(name, email, "url"); + } /* await sendEmail({ template: , From 58a30871f3794f4ca305fdedb4801819e506d8b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ante=20Kocei=C4=87?= Date: Fri, 26 Sep 2025 12:33:55 +0200 Subject: [PATCH 13/18] Added tests for headers in response, fixed notion create page mock --- apps/contact/app/api/contact/route.tsx | 6 +- apps/contact/helpers/notion.test.ts | 61 ++++++++++----------- apps/contact/helpers/slack.test.ts | 17 ++++-- apps/contact/tests/api-route.test.tsx | 76 ++++++++++++++++++-------- 4 files changed, 96 insertions(+), 64 deletions(-) diff --git a/apps/contact/app/api/contact/route.tsx b/apps/contact/app/api/contact/route.tsx index 2482e021..c74092ef 100644 --- a/apps/contact/app/api/contact/route.tsx +++ b/apps/contact/app/api/contact/route.tsx @@ -17,13 +17,11 @@ const bodyValidationSchema = z.object({ type RequestBody = z.infer; -const { NOTION_DATABASE_ID, VERCEL_ENV, VERCEL_URL } = process.env; +const { NOTION_DATABASE_ID, VERCEL_ENV } = process.env; const allowOrigin = !VERCEL_ENV ? "http://localhost:4321" - : VERCEL_ENV === "previev" || VERCEL_ENV === "development" - ? VERCEL_URL || "" - : "https://crocoder.dev"; + : "https://crocoder.dev"; export async function OPTIONS() { return new Response(null, { diff --git a/apps/contact/helpers/notion.test.ts b/apps/contact/helpers/notion.test.ts index 1b0aa302..c58ce422 100644 --- a/apps/contact/helpers/notion.test.ts +++ b/apps/contact/helpers/notion.test.ts @@ -1,16 +1,13 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { describe, it, expect, beforeEach, afterEach, vi } from "bun:test"; import { createContactObject, createContact, notion } from "./notion"; let callCount = 0; -notion.pages.create = async (args: any) => { - callCount++; - return { - object: "page", - id: "fake-page-id", - url: "https://www.notion.so/fakepageid", - }; -}; +const mock = vi.spyOn(notion.pages, "create").mockResolvedValue({ + object: "page", + id: "fake-page-id", + url: "https://www.notion.so/fakepageid", +}); const originalEnv = process.env; beforeEach(() => { @@ -24,18 +21,27 @@ beforeEach(() => { afterEach(() => { process.env = originalEnv; + callCount = 0; }); describe("Notion Helper", () => { + const validContactData = { + id: "test-id-123", + email: "john@example.com", + name: "John Doe", + content: "Test message", + databaseID: "mock-database-id", + source: "website", + }; describe("createContactObject", () => { it("should create correct Notion payload structure", () => { const payload = createContactObject( - "test-id-123", - "john@example.com", - "John Doe", - "Test message", - "mock-database-id", - "website", + validContactData.id, + validContactData.email, + validContactData.name, + validContactData.content, + validContactData.databaseID, + validContactData.source, ); expect(payload).toHaveProperty("parent"); @@ -60,15 +66,6 @@ describe("Notion Helper", () => { }); describe("createContact", () => { - const validContactData = { - id: "test-id-123", - email: "john@example.com", - name: "John Doe", - content: "Test message", - databaseID: "mock-database-id", - source: "website", - }; - it("should validate required fields", () => { const requiredFields = ["id", "email", "name", "databaseID"]; requiredFields.forEach((field) => { @@ -77,19 +74,19 @@ describe("Notion Helper", () => { }); }); - it("should process contact data structure correctly", async () => { + it("should create Notion page", async () => { const response = await createContact( - "test-id-123", - "john@example.com", - "John Doe", - "Test message", - "mock-database-id", - "website", + validContactData.id, + validContactData.email, + validContactData.name, + validContactData.content, + validContactData.databaseID, + validContactData.source, ); expect(response.id).toBe("fake-page-id"); expect(response.url).toContain("www.notion.so"); - expect(callCount).toBe(1); + expect(mock).toHaveBeenCalledTimes(1); }); }); }); diff --git a/apps/contact/helpers/slack.test.ts b/apps/contact/helpers/slack.test.ts index 9a0e0153..c1020965 100644 --- a/apps/contact/helpers/slack.test.ts +++ b/apps/contact/helpers/slack.test.ts @@ -35,12 +35,17 @@ afterEach(() => { }); describe("Slack Helper", () => { + const validContactData = { + name: "John Doe", + email: "john@example.com", + url: "https://notion.so/test", + }; describe("createPayload", () => { it("should create correct Slack payload structure", () => { const payload = createPayload( - "John Doe", - "john@example.com", - "https://notion.so/test", + validContactData.name, + validContactData.email, + validContactData.url, ); expect(payload).toHaveProperty("channel"); @@ -68,9 +73,9 @@ describe("Slack Helper", () => { describe("notifyContactCreated", async () => { it("should send message on slack", async () => { await notifyContactCreated( - "John Doe", - "john@example.com", - "https://notion.so/test", + validContactData.name, + validContactData.email, + validContactData.url, ); expect(fetchCalls.length).toBe(1); diff --git a/apps/contact/tests/api-route.test.tsx b/apps/contact/tests/api-route.test.tsx index c178fa99..ac3c9485 100644 --- a/apps/contact/tests/api-route.test.tsx +++ b/apps/contact/tests/api-route.test.tsx @@ -5,7 +5,7 @@ const mockCreateContact = mock(() => Promise.resolve({ object: "page", id: "mock-notion-id", - url: "https://www.notion.so/fakepageid", + url: "https://www.notion.so/mocknotionid", }), ); const mockNotifyContactCreated = mock(() => Promise.resolve({})); @@ -40,7 +40,6 @@ describe("Contact API Route", () => { const response = await OPTIONS(); expect(response.status).toBe(200); - // Should * be alowed or should there be some restrictions expect(response.headers.get("Access-Control-Allow-Origin")).toBe( "http://localhost:4321", ); @@ -84,13 +83,23 @@ describe("Contact API Route", () => { const request = createMockRequest(validBody); const response = await POST(request); - const responseData = await response.json(); expect(response.status).toBe(200); expect(mockCreateContact).toHaveBeenCalledTimes(1); expect(mockNotifyContactCreated).toHaveBeenCalledTimes(1); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "http://localhost:4321", + ); + expect(response.headers.get("Access-Control-Allow-Credentials")).toBe( + "false", + ); + expect(response.headers.get("Access-Control-Allow-Methods")).toBe( + "POST, OPTIONS", + ); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe( + "Content-Type", + ); }); - // Add new test that checks the contetnt type it("should return 400 for invalid email", async () => { const invalidBody = { @@ -102,11 +111,22 @@ describe("Contact API Route", () => { const request = createMockRequest(invalidBody); const response = await POST(request); - const responseData = await response.json(); expect(response.status).toBe(400); expect(mockCreateContact).toHaveBeenCalledTimes(0); expect(mockNotifyContactCreated).toHaveBeenCalledTimes(0); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "http://localhost:4321", + ); + expect(response.headers.get("Access-Control-Allow-Credentials")).toBe( + "false", + ); + expect(response.headers.get("Access-Control-Allow-Methods")).toBe( + "POST, OPTIONS", + ); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe( + "Content-Type", + ); }); it("should return 400 for missing required fields", async () => { @@ -123,6 +143,18 @@ describe("Contact API Route", () => { expect(response.status).toBe(400); expect(mockCreateContact).toHaveBeenCalledTimes(0); expect(mockNotifyContactCreated).toHaveBeenCalledTimes(0); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "http://localhost:4321", + ); + expect(response.headers.get("Access-Control-Allow-Credentials")).toBe( + "false", + ); + expect(response.headers.get("Access-Control-Allow-Methods")).toBe( + "POST, OPTIONS", + ); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe( + "Content-Type", + ); }); it("should return 403 for no consent", async () => { @@ -135,11 +167,22 @@ describe("Contact API Route", () => { const request = createMockRequest(bodyWithoutConsent); const response = await POST(request); - const responseData = await response.json(); expect(response.status).toBe(403); expect(mockCreateContact).toHaveBeenCalledTimes(0); expect(mockNotifyContactCreated).toHaveBeenCalledTimes(0); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "http://localhost:4321", + ); + expect(response.headers.get("Access-Control-Allow-Credentials")).toBe( + "false", + ); + expect(response.headers.get("Access-Control-Allow-Methods")).toBe( + "POST, OPTIONS", + ); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe( + "Content-Type", + ); }); it("should return 415 for non-JSON content type", async () => { @@ -147,20 +190,8 @@ describe("Contact API Route", () => { const response = await POST(request); expect(response.status).toBe(415); - }); - - it("should include CORS headers in all responses", async () => { - const validBody = { - name: "John Doe", - email: "john@example.com", - message: "Hello there", - hasConsent: true, - }; - - const request = createMockRequest(validBody); - const response = await POST(request); - - // Should * be alowed or should there be some restrictions + expect(mockCreateContact).toHaveBeenCalledTimes(0); + expect(mockNotifyContactCreated).toHaveBeenCalledTimes(0); expect(response.headers.get("Access-Control-Allow-Origin")).toBe( "http://localhost:4321", ); @@ -170,9 +201,10 @@ describe("Contact API Route", () => { expect(response.headers.get("Access-Control-Allow-Methods")).toBe( "POST, OPTIONS", ); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe( + "Content-Type", + ); }); - // Check if there is need for tetsing the headers for all casses - // If so should there be individual tests? // Check Allow headers to only have content-type }); }); From abaa1f1afa90533fe3584767208e13e1cc264a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ante=20Kocei=C4=87?= Date: Fri, 26 Sep 2025 17:21:41 +0200 Subject: [PATCH 14/18] Added sending message on slack if Notion page was not created --- apps/contact/app/api/contact/route.tsx | 143 +++++++++++++------------ apps/contact/helpers/notion.test.ts | 37 ++++++- apps/contact/helpers/notion.ts | 43 +++++--- apps/contact/helpers/slack.test.ts | 19 +++- apps/contact/helpers/slack.ts | 53 +++++++++ apps/contact/tests/api-route.test.tsx | 58 +++++++--- 6 files changed, 252 insertions(+), 101 deletions(-) diff --git a/apps/contact/app/api/contact/route.tsx b/apps/contact/app/api/contact/route.tsx index c74092ef..27563163 100644 --- a/apps/contact/app/api/contact/route.tsx +++ b/apps/contact/app/api/contact/route.tsx @@ -1,7 +1,7 @@ //import { ContactTemplate } from "@/email-templates/contact"; //import { sendEmail } from "@/helpers/email"; import { createContact } from "@/helpers/notion"; -import { notifyContactCreated } from "@/helpers/slack"; +import { notifyContactCreated, notifyContactError } from "@/helpers/slack"; import { nanoid } from "nanoid"; import { NextRequest } from "next/server"; import z from "zod"; @@ -37,92 +37,103 @@ export async function OPTIONS() { export async function POST(request: NextRequest) { const corsHeaders = { "Access-Control-Allow-Origin": allowOrigin, - "Access-Control-Allow-Credentials": "false", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }; - if (request.headers.get("Content-Type") === "application/json") { - try { - const body = (await request.json()) as RequestBody; - const bodyValidationResult = bodyValidationSchema.safeParse(body); - - if (!body || bodyValidationResult.error) { - return new Response( - JSON.stringify({ - message: bodyValidationResult.error?.message || "No body was found", - }), - { - status: 400, - headers: { - ...corsHeaders, - }, - }, - ); - } - - const { name, email, message, hasConsent } = body; - - if (!hasConsent) { - return new Response(JSON.stringify({ message: "No consent by user" }), { - status: 403, - headers: { - ...corsHeaders, - }, - }); - } - - const referer = request.headers.get("referer"); - const origin = request.headers.get("origin"); - let source = "Unknown"; - - if (referer && origin && referer.startsWith(origin)) { - source = referer.slice(origin.length); - } - - const { id: notionPageId, url } = await createContact( - `Message from ${name} (${nanoid()})`, - email, - name, - message, - NOTION_DATABASE_ID || "", - source, - ); - if (notionPageId && url) { - await notifyContactCreated(name, email, "url"); - } + if (request.headers.get("Content-Type") !== "application/json") { + return new Response(null, { status: 415, headers: corsHeaders }); + } - /* await sendEmail({ - template: , - options: { to: email, subject: "Thank you for contacting us!" }, - }); */ + try { + const body = (await request.json()) as RequestBody; + const bodyValidationResult = bodyValidationSchema.safeParse(body); + if (!body || bodyValidationResult.error) { return new Response( JSON.stringify({ - message: "Success", + message: bodyValidationResult.error?.message || "No body was found", }), { - status: 200, + status: 400, headers: { ...corsHeaders, }, }, ); - } catch (error) { - console.error("Error - api/contacts", error); + } - const statusCode = (error as any).statusCode || 501; - const message = - (error as any)?.body?.message || "Issue while processing request"; + const { name, email, message, hasConsent } = body; - return new Response(JSON.stringify({ message }), { - status: statusCode, + if (!hasConsent) { + return new Response(JSON.stringify({ message: "No consent by user" }), { + status: 403, headers: { ...corsHeaders, }, }); } - } - return new Response(null, { status: 415, headers: corsHeaders }); + const referer = request.headers.get("referer"); + const origin = request.headers.get("origin"); + let source = "Unknown"; + + if (referer && origin && referer.startsWith(origin)) { + source = referer.slice(origin.length); + } + + const { + id: notionPageId, + url, + error: errorMessage, + } = await createContact( + `Message from ${name} (${nanoid()})`, + email, + name, + message, + NOTION_DATABASE_ID || "", + source, + ); + + if (notionPageId && url) { + await notifyContactCreated(name, email, url); + } else if (errorMessage) { + await notifyContactError(name, email, message); + throw { + body: { + message: errorMessage, + }, + }; + } + + /* await sendEmail({ + template: , + options: { to: email, subject: "Thank you for contacting us!" }, + }); */ + + return new Response( + JSON.stringify({ + message: "Success", + }), + { + status: 200, + headers: { + ...corsHeaders, + }, + }, + ); + } catch (error) { + console.error("Error - api/contacts", error); + + const statusCode = (error as any).statusCode || 501; + const message = + (error as any)?.body?.message || "Issue while processing request"; + + return new Response(JSON.stringify({ message }), { + status: statusCode, + headers: { + ...corsHeaders, + }, + }); + } } diff --git a/apps/contact/helpers/notion.test.ts b/apps/contact/helpers/notion.test.ts index c58ce422..98ac5b60 100644 --- a/apps/contact/helpers/notion.test.ts +++ b/apps/contact/helpers/notion.test.ts @@ -1,8 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "bun:test"; import { createContactObject, createContact, notion } from "./notion"; -let callCount = 0; - const mock = vi.spyOn(notion.pages, "create").mockResolvedValue({ object: "page", id: "fake-page-id", @@ -21,7 +19,7 @@ beforeEach(() => { afterEach(() => { process.env = originalEnv; - callCount = 0; + vi.clearAllMocks(); }); describe("Notion Helper", () => { @@ -74,7 +72,7 @@ describe("Notion Helper", () => { }); }); - it("should create Notion page", async () => { + it("should create Notion page and return id and url", async () => { const response = await createContact( validContactData.id, validContactData.email, @@ -86,6 +84,37 @@ describe("Notion Helper", () => { expect(response.id).toBe("fake-page-id"); expect(response.url).toContain("www.notion.so"); + expect(response.error).toBe(undefined); + expect(mock).toHaveBeenCalledTimes(1); + }); + + it("should return error message if data is missing", async () => { + const response = await createContact("", "", "", "", "", ""); + + expect(response.id).toBe(undefined); + expect(response.url).toBe(undefined); + expect(response.error?.length).toBeGreaterThan(0); + expect(mock).toHaveBeenCalledTimes(0); + }); + + it("should return error message if page was not created", async () => { + const mock = vi.spyOn(notion.pages, "create").mockResolvedValueOnce({ + object: "page", + id: "", + url: "", + }); + const response = await createContact( + validContactData.id, + validContactData.email, + validContactData.name, + validContactData.content, + validContactData.databaseID, + validContactData.source, + ); + + expect(response.id).toBe(undefined); + expect(response.url).toBe(undefined); + expect(response.error?.length).toBeGreaterThan(0); expect(mock).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/contact/helpers/notion.ts b/apps/contact/helpers/notion.ts index 97c5b118..0c95a322 100644 --- a/apps/contact/helpers/notion.ts +++ b/apps/contact/helpers/notion.ts @@ -116,26 +116,39 @@ export const createContact = async ( source: string, ) => { if (!id || !email || !name || !databaseID) { - throw { - body: { - message: "Missing data in process contact event", - }, + return { + error: "Missing data in process contact event", }; } - const response = await notion.pages.create( - createContactObject(id, email, name, content, databaseID, source), - ); + try { + const response = await notion.pages.create( + createContactObject(id, email, name, content, databaseID, source), + ); - if (response.id && isFullPage(response)) { + // isFullPage checks if the response is type PageObjectResponse => contains url + if (response.id && isFullPage(response)) { + return { + id: response.id, + url: response.url, + }; + } else if (response.id && !isFullPage(response)) { + // Notion allows navigation to the created page using only the id without '-' + // https://dev.to/adamcoster/change-a-url-without-breaking-existing-links-4m0d + const cleanId = response.id.replace(/-/g, ""); + const pageUrl = `https://www.notion.so/${cleanId}`; + return { + id: response.id, + url: pageUrl, + }; + } else { + return { + error: "Failed to create notion page", + }; + } + } catch (e) { return { - id: response.id, - url: response.url, + error: "Failed to create notion page", }; } - throw { - body: { - message: "Failed to create notion page", - }, - }; }; diff --git a/apps/contact/helpers/slack.test.ts b/apps/contact/helpers/slack.test.ts index c1020965..832ba560 100644 --- a/apps/contact/helpers/slack.test.ts +++ b/apps/contact/helpers/slack.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { createPayload, notifyContactCreated } from "./slack"; +import { + createPayload, + notifyContactCreated, + notifyContactError, +} from "./slack"; const originalEnv = { ...process.env }; const originalFetch = globalThis.fetch; @@ -82,4 +86,17 @@ describe("Slack Helper", () => { expect(fetchCalls[0]).toBe("https://slack.com/api/chat.postMessage"); }); }); + + describe("notifyContactError", async () => { + it("should send error message on slack", async () => { + await notifyContactError( + validContactData.name, + validContactData.email, + "Message", + ); + + expect(fetchCalls.length).toBe(1); + expect(fetchCalls[0]).toBe("https://slack.com/api/chat.postMessage"); + }); + }); }); diff --git a/apps/contact/helpers/slack.ts b/apps/contact/helpers/slack.ts index 91606dcd..c7f82198 100644 --- a/apps/contact/helpers/slack.ts +++ b/apps/contact/helpers/slack.ts @@ -42,6 +42,35 @@ export const createPayload = (name: string, email: string, url: string) => ({ ], }); +export const createErrorPayload = ( + name: string, + email: string, + content: string, +) => ({ + channel: SLACK_CHANNEL, + blocks: [ + { + type: "header", + text: { + type: "plain_text", + text: "An error ocured while creating a contact", + emoji: true, + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `There was an error trying to create a contact for _${name}_ (_${email}_).`, + }, + }, + { + type: "section", + text: content, + }, + ], +}); + export const notifyContactCreated = async ( name: string, email: string, @@ -65,3 +94,27 @@ export const notifyContactCreated = async ( console.error("Could not send notification message to Slack"); } }; + +export const notifyContactError = async ( + name: string, + email: string, + content: string, +) => { + const payload = createErrorPayload(name, email, content); + const payloadStringify = JSON.stringify(payload); + + const result = await fetch("https://slack.com/api/chat.postMessage", { + method: "POST", + body: payloadStringify, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": payloadStringify.length.toString(), + Authorization: `Bearer ${SLACK_BOT_TOKEN}`, + Accept: "application/json", + }, + }); + const realResponse = await result.json(); + if (result.status !== 200 || !realResponse.ok) { + console.error("Could not send notification message to Slack"); + } +}; diff --git a/apps/contact/tests/api-route.test.tsx b/apps/contact/tests/api-route.test.tsx index ac3c9485..29dc97e1 100644 --- a/apps/contact/tests/api-route.test.tsx +++ b/apps/contact/tests/api-route.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, mock } from "bun:test"; +import { describe, it, expect, beforeEach, mock, vi } from "bun:test"; import { POST, OPTIONS } from "../app/api/contact/route"; const mockCreateContact = mock(() => @@ -8,7 +8,9 @@ const mockCreateContact = mock(() => url: "https://www.notion.so/mocknotionid", }), ); + const mockNotifyContactCreated = mock(() => Promise.resolve({})); +const mockNotifyContactError = mock(() => Promise.resolve({})); const mockNanoid = mock(() => "mock-id-12345"); mock.module("@/helpers/notion", () => ({ @@ -17,6 +19,7 @@ mock.module("@/helpers/notion", () => ({ mock.module("@/helpers/slack", () => ({ notifyContactCreated: mockNotifyContactCreated, + notifyContactError: mockNotifyContactError, })); mock.module("nanoid", () => ({ @@ -31,6 +34,7 @@ beforeEach(() => { }; mockCreateContact.mockClear(); mockNotifyContactCreated.mockClear(); + mockNotifyContactError.mockClear(); mockNanoid.mockClear(); }); @@ -87,12 +91,10 @@ describe("Contact API Route", () => { expect(response.status).toBe(200); expect(mockCreateContact).toHaveBeenCalledTimes(1); expect(mockNotifyContactCreated).toHaveBeenCalledTimes(1); + expect(mockNotifyContactError).toHaveBeenCalledTimes(0); expect(response.headers.get("Access-Control-Allow-Origin")).toBe( "http://localhost:4321", ); - expect(response.headers.get("Access-Control-Allow-Credentials")).toBe( - "false", - ); expect(response.headers.get("Access-Control-Allow-Methods")).toBe( "POST, OPTIONS", ); @@ -115,12 +117,10 @@ describe("Contact API Route", () => { expect(response.status).toBe(400); expect(mockCreateContact).toHaveBeenCalledTimes(0); expect(mockNotifyContactCreated).toHaveBeenCalledTimes(0); + expect(mockNotifyContactError).toHaveBeenCalledTimes(0); expect(response.headers.get("Access-Control-Allow-Origin")).toBe( "http://localhost:4321", ); - expect(response.headers.get("Access-Control-Allow-Credentials")).toBe( - "false", - ); expect(response.headers.get("Access-Control-Allow-Methods")).toBe( "POST, OPTIONS", ); @@ -143,12 +143,10 @@ describe("Contact API Route", () => { expect(response.status).toBe(400); expect(mockCreateContact).toHaveBeenCalledTimes(0); expect(mockNotifyContactCreated).toHaveBeenCalledTimes(0); + expect(mockNotifyContactError).toHaveBeenCalledTimes(0); expect(response.headers.get("Access-Control-Allow-Origin")).toBe( "http://localhost:4321", ); - expect(response.headers.get("Access-Control-Allow-Credentials")).toBe( - "false", - ); expect(response.headers.get("Access-Control-Allow-Methods")).toBe( "POST, OPTIONS", ); @@ -171,11 +169,44 @@ describe("Contact API Route", () => { expect(response.status).toBe(403); expect(mockCreateContact).toHaveBeenCalledTimes(0); expect(mockNotifyContactCreated).toHaveBeenCalledTimes(0); + expect(mockNotifyContactError).toHaveBeenCalledTimes(0); expect(response.headers.get("Access-Control-Allow-Origin")).toBe( "http://localhost:4321", ); - expect(response.headers.get("Access-Control-Allow-Credentials")).toBe( - "false", + expect(response.headers.get("Access-Control-Allow-Methods")).toBe( + "POST, OPTIONS", + ); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe( + "Content-Type", + ); + }); + + it("should return 501 if Notion page creation failed", async () => { + mockCreateContact.mockImplementationOnce(() => + Promise.resolve({ + object: "page", + id: "", + url: "", + error: "Error message", + }), + ); + + const validBody = { + name: "John Doe", + email: "john@example.com", + message: "Hello there", + hasConsent: true, + }; + + const request = createMockRequest(validBody); + const response = await POST(request); + + expect(response.status).toBe(501); + expect(mockCreateContact).toHaveBeenCalledTimes(1); + expect(mockNotifyContactCreated).toHaveBeenCalledTimes(0); + expect(mockNotifyContactError).toHaveBeenCalledTimes(1); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "http://localhost:4321", ); expect(response.headers.get("Access-Control-Allow-Methods")).toBe( "POST, OPTIONS", @@ -195,9 +226,6 @@ describe("Contact API Route", () => { expect(response.headers.get("Access-Control-Allow-Origin")).toBe( "http://localhost:4321", ); - expect(response.headers.get("Access-Control-Allow-Credentials")).toBe( - "false", - ); expect(response.headers.get("Access-Control-Allow-Methods")).toBe( "POST, OPTIONS", ); From 2f909f9b0a6d79d727ff334bc27a936e11296d0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ante=20Kocei=C4=87?= Date: Mon, 29 Sep 2025 14:42:47 +0200 Subject: [PATCH 15/18] Added test for email helper --- apps/contact/helpers/email.test.tsx | 59 +++++++++++++++++++++++++++ apps/contact/tests/api-route.test.tsx | 6 +-- 2 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 apps/contact/helpers/email.test.tsx diff --git a/apps/contact/helpers/email.test.tsx b/apps/contact/helpers/email.test.tsx new file mode 100644 index 00000000..4ef41d8c --- /dev/null +++ b/apps/contact/helpers/email.test.tsx @@ -0,0 +1,59 @@ +import { expect, describe, it, mock, beforeEach, afterEach } from "bun:test"; + +const mockSendMail = mock(() => { + console.log("SENDING"); + return Promise.resolve({}); +}); + +mock.module("@aws-sdk/client-sesv2", () => { + class MockSESv2Client { + send = mockSendMail; + } + + class MockSendEmailCommand { + constructor(args: any) { + return args; + } + } + + return { + SESv2Client: MockSESv2Client, + SendEmailCommand: MockSendEmailCommand, + }; +}); + +const originalEnv = process.env; +beforeEach(() => { + process.env = { + ...originalEnv, + AWS_REGION: "us-east-1", + EMAIL_AWS_ACCESS_KEY: "mock-aws-access-key", + EMAIL_AWS_SECRET_ACCESS_KEY: "mock-aws-secret-access-key", + }; +}); + +afterEach(() => { + process.env = originalEnv; + mockSendMail.mockReset(); +}); + +const { sendEmail } = await import("./email"); + +describe("Email Helper", () => { + const MockEmailTemplate = () => <>Hello; + const options = { + from: "from@example.com", + to: "to@example.com", + subject: "Test", + }; + describe("sendEmail", () => { + it("should send email with correct arguments", async () => { + await sendEmail({ + template: , + options: options, + }); + + expect(mockSendMail).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/apps/contact/tests/api-route.test.tsx b/apps/contact/tests/api-route.test.tsx index 29dc97e1..a7598e70 100644 --- a/apps/contact/tests/api-route.test.tsx +++ b/apps/contact/tests/api-route.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, mock, vi } from "bun:test"; +import { describe, it, expect, beforeEach, mock } from "bun:test"; import { POST, OPTIONS } from "../app/api/contact/route"; const mockCreateContact = mock(() => @@ -70,10 +70,6 @@ describe("Contact API Route", () => { body: contentType === "application/json" ? JSON.stringify(body) : body, }); - (request as any).nextUrl = { - searchParams: new URLSearchParams(new URL(url).search), - }; - return request as any; }; From a9d29336f0d2fbdd17b72f10f08b1fd30ac7369e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ante=20Kocei=C4=87?= Date: Tue, 30 Sep 2025 15:33:02 +0200 Subject: [PATCH 16/18] Minor adjustments --- apps/contact/app/api/contact/route.tsx | 29 +++--- apps/contact/helpers/email.test.tsx | 1 - apps/contact/helpers/notion.test.ts | 117 ++++++++++++++++--------- apps/contact/helpers/notion.ts | 19 ++-- apps/contact/helpers/slack.test.ts | 60 ++++--------- apps/contact/helpers/slack.ts | 8 +- apps/contact/tests/api-route.test.tsx | 5 +- 7 files changed, 119 insertions(+), 120 deletions(-) diff --git a/apps/contact/app/api/contact/route.tsx b/apps/contact/app/api/contact/route.tsx index 27563163..3b87a468 100644 --- a/apps/contact/app/api/contact/route.tsx +++ b/apps/contact/app/api/contact/route.tsx @@ -78,15 +78,11 @@ export async function POST(request: NextRequest) { const origin = request.headers.get("origin"); let source = "Unknown"; - if (referer && origin && referer.startsWith(origin)) { + if (referer && origin) { source = referer.slice(origin.length); } - const { - id: notionPageId, - url, - error: errorMessage, - } = await createContact( + const response = await createContact( `Message from ${name} (${nanoid()})`, email, name, @@ -95,17 +91,19 @@ export async function POST(request: NextRequest) { source, ); - if (notionPageId && url) { - await notifyContactCreated(name, email, url); - } else if (errorMessage) { + if ("error" in response) { await notifyContactError(name, email, message); - throw { - body: { - message: errorMessage, + + return new Response(JSON.stringify({ message: response.error }), { + status: 500, + headers: { + ...corsHeaders, }, - }; + }); } + await notifyContactCreated(name, email, response.url || ""); + /* await sendEmail({ template: , options: { to: email, subject: "Thank you for contacting us!" }, @@ -125,9 +123,8 @@ export async function POST(request: NextRequest) { } catch (error) { console.error("Error - api/contacts", error); - const statusCode = (error as any).statusCode || 501; - const message = - (error as any)?.body?.message || "Issue while processing request"; + const statusCode = 500; + const message = "Issue while processing request"; return new Response(JSON.stringify({ message }), { status: statusCode, diff --git a/apps/contact/helpers/email.test.tsx b/apps/contact/helpers/email.test.tsx index 4ef41d8c..a8e14cb3 100644 --- a/apps/contact/helpers/email.test.tsx +++ b/apps/contact/helpers/email.test.tsx @@ -1,7 +1,6 @@ import { expect, describe, it, mock, beforeEach, afterEach } from "bun:test"; const mockSendMail = mock(() => { - console.log("SENDING"); return Promise.resolve({}); }); diff --git a/apps/contact/helpers/notion.test.ts b/apps/contact/helpers/notion.test.ts index 98ac5b60..258f106a 100644 --- a/apps/contact/helpers/notion.test.ts +++ b/apps/contact/helpers/notion.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "bun:test"; -import { createContactObject, createContact, notion } from "./notion"; +import { createContact, notion } from "./notion"; const mock = vi.spyOn(notion.pages, "create").mockResolvedValue({ object: "page", @@ -31,38 +31,6 @@ describe("Notion Helper", () => { databaseID: "mock-database-id", source: "website", }; - describe("createContactObject", () => { - it("should create correct Notion payload structure", () => { - const payload = createContactObject( - validContactData.id, - validContactData.email, - validContactData.name, - validContactData.content, - validContactData.databaseID, - validContactData.source, - ); - - expect(payload).toHaveProperty("parent"); - expect(payload).toHaveProperty("properties"); - expect(payload).toHaveProperty("children"); - - expect(payload.parent.database_id).toBe("mock-database-id"); - - expect(payload.properties.id.title[0].text.content).toBe("test-id-123"); - expect(payload.properties.email.email).toBe("john@example.com"); - expect(payload.properties.name.rich_text[0].text.content).toBe( - "John Doe", - ); - expect(payload.properties.source.rich_text[0].text.content).toBe( - "website", - ); - - expect(payload.children[0].paragraph.rich_text[0].text?.content).toBe( - "Test message", - ); - }); - }); - describe("createContact", () => { it("should validate required fields", () => { const requiredFields = ["id", "email", "name", "databaseID"]; @@ -72,7 +40,7 @@ describe("Notion Helper", () => { }); }); - it("should create Notion page and return id and url", async () => { + it("should create Notion page and return valid url", async () => { const response = await createContact( validContactData.id, validContactData.email, @@ -82,19 +50,83 @@ describe("Notion Helper", () => { validContactData.source, ); - expect(response.id).toBe("fake-page-id"); - expect(response.url).toContain("www.notion.so"); - expect(response.error).toBe(undefined); expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenCalledWith( + expect.objectContaining({ + parent: { + database_id: "mock-database-id", + }, + }), + ); + expect(mock).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + email: { + email: "john@example.com", + }, + }), + }), + ); + expect(mock).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + name: { + rich_text: [ + { + text: { + content: "John Doe", + }, + }, + ], + }, + }), + }), + ); + expect(mock).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + source: { + rich_text: [ + { + text: { + content: "website", + }, + }, + ], + }, + }), + }), + ); + expect(mock).toHaveBeenCalledWith( + expect.objectContaining({ + children: expect.arrayContaining([ + expect.objectContaining({ + paragraph: { + rich_text: [ + { + text: { + content: "Test message", + }, + }, + ], + }, + }), + ]), + }), + ); + expect(response).toHaveProperty( + "url", + "https://www.notion.so/fakepageid", + ); + expect(response).not.toHaveProperty("error"); }); it("should return error message if data is missing", async () => { const response = await createContact("", "", "", "", "", ""); - expect(response.id).toBe(undefined); - expect(response.url).toBe(undefined); - expect(response.error?.length).toBeGreaterThan(0); expect(mock).toHaveBeenCalledTimes(0); + expect(response).not.toHaveProperty("url"); + expect(response).toHaveProperty("error"); }); it("should return error message if page was not created", async () => { @@ -112,10 +144,9 @@ describe("Notion Helper", () => { validContactData.source, ); - expect(response.id).toBe(undefined); - expect(response.url).toBe(undefined); - expect(response.error?.length).toBeGreaterThan(0); expect(mock).toHaveBeenCalledTimes(1); + expect(response).not.toHaveProperty("url"); + expect(response).toHaveProperty("error"); }); }); }); diff --git a/apps/contact/helpers/notion.ts b/apps/contact/helpers/notion.ts index 0c95a322..c3b61eec 100644 --- a/apps/contact/helpers/notion.ts +++ b/apps/contact/helpers/notion.ts @@ -39,7 +39,7 @@ const mentionPeople = () => { return getMentions().flatMap(mentionPerson); }; -export const createContactObject = ( +const createContactObject = ( id: string, email: string, name: string, @@ -114,7 +114,7 @@ export const createContact = async ( content: string, databaseID: string, source: string, -) => { +): Promise<{ url: string } | { error: string }> => { if (!id || !email || !name || !databaseID) { return { error: "Missing data in process contact event", @@ -129,24 +129,23 @@ export const createContact = async ( // isFullPage checks if the response is type PageObjectResponse => contains url if (response.id && isFullPage(response)) { return { - id: response.id, url: response.url, }; - } else if (response.id && !isFullPage(response)) { + } + if (response.id && !isFullPage(response)) { // Notion allows navigation to the created page using only the id without '-' // https://dev.to/adamcoster/change-a-url-without-breaking-existing-links-4m0d const cleanId = response.id.replace(/-/g, ""); const pageUrl = `https://www.notion.so/${cleanId}`; return { - id: response.id, url: pageUrl, }; - } else { - return { - error: "Failed to create notion page", - }; } - } catch (e) { + return { + error: "Failed to create notion page", + }; + } catch (error) { + console.error("Notion hepler", error); return { error: "Failed to create notion page", }; diff --git a/apps/contact/helpers/slack.test.ts b/apps/contact/helpers/slack.test.ts index 832ba560..2b20ad64 100644 --- a/apps/contact/helpers/slack.test.ts +++ b/apps/contact/helpers/slack.test.ts @@ -1,13 +1,12 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { - createPayload, - notifyContactCreated, - notifyContactError, -} from "./slack"; +import { notifyContactCreated, notifyContactError } from "./slack"; const originalEnv = { ...process.env }; const originalFetch = globalThis.fetch; -let fetchCalls: string[] = []; +let fetchCalls: { + url: string; + init: RequestInit; +}[] = []; const mockSlackResponse = { ok: true, @@ -27,8 +26,8 @@ beforeEach(() => { fetchCalls = []; - globalThis.fetch = (async (url: string) => { - fetchCalls.push(url); + globalThis.fetch = (async (url: string, init: RequestInit) => { + fetchCalls.push({ url, init }); return { status: 200, json: async () => mockSlackResponse } as Response; }) as typeof fetch; }); @@ -44,36 +43,7 @@ describe("Slack Helper", () => { email: "john@example.com", url: "https://notion.so/test", }; - describe("createPayload", () => { - it("should create correct Slack payload structure", () => { - const payload = createPayload( - validContactData.name, - validContactData.email, - validContactData.url, - ); - - expect(payload).toHaveProperty("channel"); - expect(payload).toHaveProperty("blocks"); - expect(payload.blocks).toHaveLength(4); - - // Checks that it is type header and contains text - expect(payload.blocks[0]).toHaveProperty("type", "header"); - expect(payload.blocks[0]).toHaveProperty("text"); - expect(payload.blocks[0].text?.text.length).toBeGreaterThan(0); - - // Checks that it is type section and the text conatins name and email - expect(payload.blocks[1]).toHaveProperty("type", "section"); - expect(payload.blocks[1]).toHaveProperty("text"); - expect(payload.blocks[1].text?.text).toContain("John Doe"); - expect(payload.blocks[1].text?.text).toContain("john@example.com"); - - // Checks that it is type section and the accessory conatins the url - expect(payload.blocks[3]).toHaveProperty("type", "section"); - expect(payload.blocks[3]).toHaveProperty("accessory"); - expect(payload.blocks[3].accessory?.url).toBe("https://notion.so/test"); - }); - }); - + const errorMessage = "Error message"; describe("notifyContactCreated", async () => { it("should send message on slack", async () => { await notifyContactCreated( @@ -83,7 +53,11 @@ describe("Slack Helper", () => { ); expect(fetchCalls.length).toBe(1); - expect(fetchCalls[0]).toBe("https://slack.com/api/chat.postMessage"); + expect(fetchCalls[0].url).toBe("https://slack.com/api/chat.postMessage"); + expect(fetchCalls[0].init.body).toContain(validContactData.name); + expect(fetchCalls[0].init.body).toContain(validContactData.email); + expect(fetchCalls[0].init.body).toContain(validContactData.url); + expect(fetchCalls[0].init.body).not.toContain(errorMessage); }); }); @@ -92,11 +66,15 @@ describe("Slack Helper", () => { await notifyContactError( validContactData.name, validContactData.email, - "Message", + errorMessage, ); expect(fetchCalls.length).toBe(1); - expect(fetchCalls[0]).toBe("https://slack.com/api/chat.postMessage"); + expect(fetchCalls[0].url).toBe("https://slack.com/api/chat.postMessage"); + expect(fetchCalls[0].init.body).toContain(validContactData.name); + expect(fetchCalls[0].init.body).toContain(validContactData.email); + expect(fetchCalls[0].init.body).not.toContain(validContactData.url); + expect(fetchCalls[0].init.body).toContain(errorMessage); }); }); }); diff --git a/apps/contact/helpers/slack.ts b/apps/contact/helpers/slack.ts index c7f82198..8592d629 100644 --- a/apps/contact/helpers/slack.ts +++ b/apps/contact/helpers/slack.ts @@ -1,6 +1,6 @@ const { SLACK_CHANNEL, SLACK_BOT_TOKEN } = process.env; -export const createPayload = (name: string, email: string, url: string) => ({ +const createPayload = (name: string, email: string, url: string) => ({ channel: SLACK_CHANNEL, blocks: [ { @@ -42,11 +42,7 @@ export const createPayload = (name: string, email: string, url: string) => ({ ], }); -export const createErrorPayload = ( - name: string, - email: string, - content: string, -) => ({ +const createErrorPayload = (name: string, email: string, content: string) => ({ channel: SLACK_CHANNEL, blocks: [ { diff --git a/apps/contact/tests/api-route.test.tsx b/apps/contact/tests/api-route.test.tsx index a7598e70..cbbe13c6 100644 --- a/apps/contact/tests/api-route.test.tsx +++ b/apps/contact/tests/api-route.test.tsx @@ -177,7 +177,7 @@ describe("Contact API Route", () => { ); }); - it("should return 501 if Notion page creation failed", async () => { + it("should return 500 if Notion page creation failed", async () => { mockCreateContact.mockImplementationOnce(() => Promise.resolve({ object: "page", @@ -197,7 +197,7 @@ describe("Contact API Route", () => { const request = createMockRequest(validBody); const response = await POST(request); - expect(response.status).toBe(501); + expect(response.status).toBe(500); expect(mockCreateContact).toHaveBeenCalledTimes(1); expect(mockNotifyContactCreated).toHaveBeenCalledTimes(0); expect(mockNotifyContactError).toHaveBeenCalledTimes(1); @@ -229,6 +229,5 @@ describe("Contact API Route", () => { "Content-Type", ); }); - // Check Allow headers to only have content-type }); }); From 6e1f259667466b6719d98245a50b171ff3740ffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ante=20Kocei=C4=87?= Date: Wed, 8 Oct 2025 12:44:01 +0200 Subject: [PATCH 17/18] Added missing try-catch --- apps/contact/helpers/notion.ts | 1 + apps/contact/helpers/slack.test.ts | 8 ++-- apps/contact/helpers/slack.ts | 60 +++++++++++++++++------------- 3 files changed, 39 insertions(+), 30 deletions(-) diff --git a/apps/contact/helpers/notion.ts b/apps/contact/helpers/notion.ts index c3b61eec..ce8d0e89 100644 --- a/apps/contact/helpers/notion.ts +++ b/apps/contact/helpers/notion.ts @@ -141,6 +141,7 @@ export const createContact = async ( url: pageUrl, }; } + console.error("Notion hepler => Failed to create notion page"); return { error: "Failed to create notion page", }; diff --git a/apps/contact/helpers/slack.test.ts b/apps/contact/helpers/slack.test.ts index 2b20ad64..ae1ba7b0 100644 --- a/apps/contact/helpers/slack.test.ts +++ b/apps/contact/helpers/slack.test.ts @@ -43,7 +43,7 @@ describe("Slack Helper", () => { email: "john@example.com", url: "https://notion.so/test", }; - const errorMessage = "Error message"; + const userMessage = "Test message"; describe("notifyContactCreated", async () => { it("should send message on slack", async () => { await notifyContactCreated( @@ -57,7 +57,7 @@ describe("Slack Helper", () => { expect(fetchCalls[0].init.body).toContain(validContactData.name); expect(fetchCalls[0].init.body).toContain(validContactData.email); expect(fetchCalls[0].init.body).toContain(validContactData.url); - expect(fetchCalls[0].init.body).not.toContain(errorMessage); + expect(fetchCalls[0].init.body).not.toContain(userMessage); }); }); @@ -66,7 +66,7 @@ describe("Slack Helper", () => { await notifyContactError( validContactData.name, validContactData.email, - errorMessage, + userMessage, ); expect(fetchCalls.length).toBe(1); @@ -74,7 +74,7 @@ describe("Slack Helper", () => { expect(fetchCalls[0].init.body).toContain(validContactData.name); expect(fetchCalls[0].init.body).toContain(validContactData.email); expect(fetchCalls[0].init.body).not.toContain(validContactData.url); - expect(fetchCalls[0].init.body).toContain(errorMessage); + expect(fetchCalls[0].init.body).toContain(userMessage); }); }); }); diff --git a/apps/contact/helpers/slack.ts b/apps/contact/helpers/slack.ts index 8592d629..91c8c82c 100644 --- a/apps/contact/helpers/slack.ts +++ b/apps/contact/helpers/slack.ts @@ -75,19 +75,23 @@ export const notifyContactCreated = async ( const payload = createPayload(name, email, url); const payloadStringify = JSON.stringify(payload); - const result = await fetch("https://slack.com/api/chat.postMessage", { - method: "POST", - body: payloadStringify, - headers: { - "Content-Type": "application/json; charset=utf-8", - "Content-Length": payloadStringify.length.toString(), - Authorization: `Bearer ${SLACK_BOT_TOKEN}`, - Accept: "application/json", - }, - }); - const realResponse = await result.json(); - if (result.status !== 200 || !realResponse.ok) { - console.error("Could not send notification message to Slack"); + try { + const result = await fetch("https://slack.com/api/chat.postMessage", { + method: "POST", + body: payloadStringify, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": payloadStringify.length.toString(), + Authorization: `Bearer ${SLACK_BOT_TOKEN}`, + Accept: "application/json", + }, + }); + const realResponse = await result.json(); + if (result.status !== 200 || !realResponse.ok) { + console.error("Could not send notification message to Slack"); + } + } catch (error) { + console.error("Could not send notification message to Slack", error); } }; @@ -99,18 +103,22 @@ export const notifyContactError = async ( const payload = createErrorPayload(name, email, content); const payloadStringify = JSON.stringify(payload); - const result = await fetch("https://slack.com/api/chat.postMessage", { - method: "POST", - body: payloadStringify, - headers: { - "Content-Type": "application/json; charset=utf-8", - "Content-Length": payloadStringify.length.toString(), - Authorization: `Bearer ${SLACK_BOT_TOKEN}`, - Accept: "application/json", - }, - }); - const realResponse = await result.json(); - if (result.status !== 200 || !realResponse.ok) { - console.error("Could not send notification message to Slack"); + try { + const result = await fetch("https://slack.com/api/chat.postMessage", { + method: "POST", + body: payloadStringify, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": payloadStringify.length.toString(), + Authorization: `Bearer ${SLACK_BOT_TOKEN}`, + Accept: "application/json", + }, + }); + const realResponse = await result.json(); + if (result.status !== 200 || !realResponse.ok) { + console.error("Could not send notification message to Slack"); + } + } catch (error) { + console.error("Could not send notification message to Slack", error); } }; From 3c7e63c991e35783ea6c3bb19d0052082126fcf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ante=20Kocei=C4=87?= Date: Wed, 8 Oct 2025 14:22:29 +0200 Subject: [PATCH 18/18] Fixed incorect import --- apps/contact/app/api/get-plan/route.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/contact/app/api/get-plan/route.ts b/apps/contact/app/api/get-plan/route.ts index 5573f731..796517e2 100644 --- a/apps/contact/app/api/get-plan/route.ts +++ b/apps/contact/app/api/get-plan/route.ts @@ -1,4 +1,4 @@ -import { processContact } from "@/helpers/notion"; +import { createContact } from "@/helpers/notion"; import { nanoid } from "nanoid"; import { NextRequest } from "next/server"; import z from "zod"; @@ -99,14 +99,14 @@ export async function POST(request: NextRequest) { ); } - await processContact({ - id: nanoid(), + await createContact( + nanoid(), email, name, - message: message || "", - databaseID: NOTION_GET_PLAN_DATABASE_ID || "", - source: request.nextUrl.searchParams.get("source") || "Unknown", - }); + message || "", + NOTION_GET_PLAN_DATABASE_ID || "", + request.nextUrl.searchParams.get("source") || "Unknown", + ); return new Response( JSON.stringify({