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 diff --git a/apps/contact/app/api/contact/route.tsx b/apps/contact/app/api/contact/route.tsx index 024cb6c2..3b87a468 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, notifyContactError } from "@/helpers/slack"; import { nanoid } from "nanoid"; import { NextRequest } from "next/server"; import z from "zod"; @@ -16,120 +17,120 @@ const bodyValidationSchema = z.object({ type RequestBody = z.infer; -const { NOTION_DATABASE_ID } = process.env; +const { NOTION_DATABASE_ID, VERCEL_ENV } = 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" + : "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-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", + "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, - }, - }, - ); - } + if (request.headers.get("Content-Type") !== "application/json") { + return new Response(null, { status: 415, 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 { success, limit, reset, remaining } = await allowRequest(request); - - if (!success) { - return new Response( - JSON.stringify({ - message: "Too many requests. Please try again in a minute", - }), - { - status: 429, - headers: { - ...corsHeaders, - }, - }, - ); - } - - await processContact({ - id: nanoid(), - email, - name, - message, - databaseID: NOTION_DATABASE_ID || "", - source: request.nextUrl.searchParams.get("source") || "Unknown", - }); - - /* 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, - "X-RateLimit-Limit": limit.toString(), - "X-RateLimit-Remaining": remaining.toString(), - "X-RateLimit-Reset": reset.toString(), }, }, ); - } 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: 400, headers: corsHeaders }); + const referer = request.headers.get("referer"); + const origin = request.headers.get("origin"); + let source = "Unknown"; + + if (referer && origin) { + source = referer.slice(origin.length); + } + + const response = await createContact( + `Message from ${name} (${nanoid()})`, + email, + name, + message, + NOTION_DATABASE_ID || "", + source, + ); + + if ("error" in response) { + await notifyContactError(name, email, message); + + 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!" }, + }); */ + + return new Response( + JSON.stringify({ + message: "Success", + }), + { + status: 200, + headers: { + ...corsHeaders, + }, + }, + ); + } catch (error) { + console.error("Error - api/contacts", error); + + const statusCode = 500; + const message = "Issue while processing request"; + + return new Response(JSON.stringify({ message }), { + status: statusCode, + headers: { + ...corsHeaders, + }, + }); + } } 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({ 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/email.test.tsx b/apps/contact/helpers/email.test.tsx new file mode 100644 index 00000000..a8e14cb3 --- /dev/null +++ b/apps/contact/helpers/email.test.tsx @@ -0,0 +1,58 @@ +import { expect, describe, it, mock, beforeEach, afterEach } from "bun:test"; + +const mockSendMail = mock(() => { + 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/helpers/notion.test.ts b/apps/contact/helpers/notion.test.ts new file mode 100644 index 00000000..258f106a --- /dev/null +++ b/apps/contact/helpers/notion.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "bun:test"; +import { createContact, notion } from "./notion"; + +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(() => { + 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; + vi.clearAllMocks(); +}); + +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("createContact", () => { + it("should validate required fields", () => { + const requiredFields = ["id", "email", "name", "databaseID"]; + requiredFields.forEach((field) => { + expect(validContactData).toHaveProperty(field); + expect((validContactData as any)[field]).toBeTruthy(); + }); + }); + + it("should create Notion page and return valid url", async () => { + const response = await createContact( + validContactData.id, + validContactData.email, + validContactData.name, + validContactData.content, + validContactData.databaseID, + validContactData.source, + ); + + 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(mock).toHaveBeenCalledTimes(0); + expect(response).not.toHaveProperty("url"); + expect(response).toHaveProperty("error"); + }); + + 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(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 51fa3074..ce8d0e89 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 }) => [ { @@ -108,59 +107,48 @@ const createContactObject = ( ], }); -const createContact = async ( +export const createContact = async ( id: string, email: string, name: string, content: string, databaseID: string, source: string, -) => { - const response = await notion.pages.create( - createContactObject(id, email, name, content, databaseID, source), - ); - - if (response.id && isFullPage(response)) { +): Promise<{ url: string } | { error: string }> => { + if (!id || !email || !name || !databaseID) { return { - id: response.id, - url: response.url, + error: "Missing data in process contact event", }; } - throw { - body: { - message: "Failed to create notion page", - }, - }; -}; -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; + try { + const response = await notion.pages.create( + createContactObject(id, email, name, content, databaseID, source), + ); - if (!id || !email || !name || !databaseID) { - console.log({ event }); - throw { - body: { - message: "Missing data in process contact event", - }, + // isFullPage checks if the response is type PageObjectResponse => contains url + if (response.id && isFullPage(response)) { + return { + url: response.url, + }; + } + 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 { + url: pageUrl, + }; + } + console.error("Notion hepler => Failed to create notion page"); + return { + error: "Failed to create notion page", + }; + } catch (error) { + console.error("Notion hepler", error); + return { + error: "Failed to create notion page", }; } - - 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 new file mode 100644 index 00000000..ae1ba7b0 --- /dev/null +++ b/apps/contact/helpers/slack.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { notifyContactCreated, notifyContactError } from "./slack"; + +const originalEnv = { ...process.env }; +const originalFetch = globalThis.fetch; +let fetchCalls: { + url: string; + init: RequestInit; +}[] = []; + +const mockSlackResponse = { + ok: true, + channel: "#test-channel", + message: { + text: "Test", + type: "message", + }, +}; + +beforeEach(() => { + process.env = { + ...originalEnv, + SLACK_CHANNEL: "#test-channel", + SLACK_BOT_TOKEN: "xoxb-test-token", + }; + + fetchCalls = []; + + globalThis.fetch = (async (url: string, init: RequestInit) => { + fetchCalls.push({ url, init }); + return { status: 200, json: async () => mockSlackResponse } as Response; + }) as typeof fetch; +}); + +afterEach(() => { + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; +}); + +describe("Slack Helper", () => { + const validContactData = { + name: "John Doe", + email: "john@example.com", + url: "https://notion.so/test", + }; + const userMessage = "Test message"; + describe("notifyContactCreated", async () => { + it("should send message on slack", async () => { + await notifyContactCreated( + validContactData.name, + validContactData.email, + validContactData.url, + ); + + expect(fetchCalls.length).toBe(1); + 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(userMessage); + }); + }); + + describe("notifyContactError", async () => { + it("should send error message on slack", async () => { + await notifyContactError( + validContactData.name, + validContactData.email, + userMessage, + ); + + expect(fetchCalls.length).toBe(1); + 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(userMessage); + }); + }); +}); diff --git a/apps/contact/helpers/slack.ts b/apps/contact/helpers/slack.ts index 8114d6d9..91c8c82c 100644 --- a/apps/contact/helpers/slack.ts +++ b/apps/contact/helpers/slack.ts @@ -1,6 +1,6 @@ -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) => ({ +const createPayload = (name: string, email: string, url: string) => ({ channel: SLACK_CHANNEL, blocks: [ { @@ -42,6 +42,31 @@ export const createPayload = (name: string, email: string, url: string) => ({ ], }); +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, @@ -50,9 +75,35 @@ export const notifyContactCreated = async ( const payload = createPayload(name, email, url); const payloadStringify = JSON.stringify(payload); - if (IS_OFFLINE) { - console.log(payload); - } else { + 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); + } +}; + +export const notifyContactError = async ( + name: string, + email: string, + content: string, +) => { + const payload = createErrorPayload(name, email, content); + const payloadStringify = JSON.stringify(payload); + + try { const result = await fetch("https://slack.com/api/chat.postMessage", { method: "POST", body: payloadStringify, @@ -63,11 +114,11 @@ export const notifyContactCreated = async ( Accept: "application/json", }, }); - if (result.status !== 200) { - throw { - body: "Could not send notification message to Slack", - statusCode: result.status, - }; + 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); } }; diff --git a/apps/contact/package.json b/apps/contact/package.json index c68fa790..5f43a1af 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 helpers/ && bun test tests/", "emails": "email dev --dir email-templates" }, "dependencies": { @@ -22,10 +23,12 @@ }, "devDependencies": { "@react-email/preview-server": "4.2.11", + "@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..cbbe13c6 --- /dev/null +++ b/apps/contact/tests/api-route.test.tsx @@ -0,0 +1,233 @@ +import { describe, it, expect, beforeEach, mock } from "bun:test"; +import { POST, OPTIONS } from "../app/api/contact/route"; + +const mockCreateContact = mock(() => + Promise.resolve({ + object: "page", + id: "mock-notion-id", + 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", () => ({ + createContact: mockCreateContact, +})); + +mock.module("@/helpers/slack", () => ({ + notifyContactCreated: mockNotifyContactCreated, + notifyContactError: mockNotifyContactError, +})); + +mock.module("nanoid", () => ({ + nanoid: mockNanoid, +})); + +const originalEnv = process.env; +beforeEach(() => { + process.env = { + ...originalEnv, + NOTION_DATABASE_ID: "mock-database-id", + }; + mockCreateContact.mockClear(); + mockNotifyContactCreated.mockClear(); + mockNotifyContactError.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( + "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", + ); + }); + }); + + 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, + }); + + 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); + + 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-Methods")).toBe( + "POST, OPTIONS", + ); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe( + "Content-Type", + ); + }); + + 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); + + 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-Methods")).toBe( + "POST, OPTIONS", + ); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe( + "Content-Type", + ); + }); + + 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); + 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-Methods")).toBe( + "POST, OPTIONS", + ); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe( + "Content-Type", + ); + }); + + 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); + + 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-Methods")).toBe( + "POST, OPTIONS", + ); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe( + "Content-Type", + ); + }); + + it("should return 500 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(500); + 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", + ); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe( + "Content-Type", + ); + }); + + 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(415); + 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-Methods")).toBe( + "POST, OPTIONS", + ); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe( + "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) { diff --git a/bun.lock b/bun.lock index 2b0db3bf..559db794 100644 --- a/bun.lock +++ b/bun.lock @@ -27,10 +27,12 @@ }, "devDependencies": { "@react-email/preview-server": "4.2.11", + "@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", }, @@ -662,6 +664,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 +838,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=="], 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" diff --git a/turbo.json b/turbo.json index 85bfc80c..0c7445af 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": [ @@ -25,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" ] }