diff --git a/jest.setup.ts b/jest.setup.ts index 0c126b9..2f31941 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -2,6 +2,10 @@ import "@testing-library/jest-dom"; import { createElement } from "react"; import type { ImageProps } from "next/image"; +process.env.SMTP_USER = "mockUser"; +process.env.SMTP_PASSWORD = "mockPassword"; +process.env.SMTP_RECEIVER = "mockUser@example.com"; + function MockImage({ priority, ...props }: ImageProps) { return createElement("img", { ...props, diff --git a/package.json b/package.json index 7ff230d..15064c5 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@testing-library/react": "14.0.0", "@types/jest": "29.5.5", "@types/node": "20.4.5", + "@types/nodemailer": "6.4.13", "@types/react": "18.2.17", "@types/react-dom": "18.2.7", "@typescript-eslint/eslint-plugin": "5.62.0", @@ -35,8 +36,10 @@ "husky": "8.0.3", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", + "jest-environment-node": "29.7.0", "lint-staged": "14.0.1", "next": "13.5.6", + "nodemailer": "6.9.6", "postcss": "8.4.27", "prettier": "3.0.3", "react": "18.2.0", diff --git a/src/app/contact/components/ContactForm.test.tsx b/src/app/contact/components/ContactForm.test.tsx new file mode 100644 index 0000000..bdebca0 --- /dev/null +++ b/src/app/contact/components/ContactForm.test.tsx @@ -0,0 +1,146 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { Contact } from "../models"; +import { ContactForm } from "./ContactForm"; + +global.fetch = jest.fn(() => Promise.resolve()) as unknown as typeof fetch; + +const push = jest.fn(); + +jest.mock("next/navigation", () => ({ + useRouter: () => ({ + push, + }), +})); + +const getSubmitButton = () => screen.getByRole("button", { name: /submit/i }); + +const contact: Record< + keyof Contact, + { + name: string; + value: string; + } +> = { + name: { name: "Name", value: "John Doe" }, + email: { name: "Email", value: "johndoe@example.com" }, + subject: { name: "Subject", value: "Test Subject" }, + text: { name: "Text", value: "Test Message" }, +}; + +const getFormFields = () => + Object.values(contact).map(({ name }) => + screen.getByRole("textbox", { name }), + ); + +const getFormComponents = () => { + return [...getFormFields(), getSubmitButton()]; +}; + +const expectFormToBeEnabled = () => { + getFormComponents().forEach((field) => { + expect(field).toBeEnabled(); + }); +}; + +const expectFormToBeDisabled = () => { + getFormComponents().forEach((field) => { + expect(field).toBeDisabled(); + }); +}; + +describe("ContactForm", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should renders form fields and submit button correctly", () => { + render(); + getFormComponents().forEach((field) => { + expect(field).toBeInTheDocument(); + }); + }); + + it("should submit form data", async () => { + render(); + + Object.values(contact).forEach(({ name, value }) => + fireEvent.change(screen.getByRole("textbox", { name }), { + target: { value }, + }), + ); + + fireEvent.submit(getSubmitButton()); + + const body = new FormData(); + Object.entries(contact).forEach(([key, { value }]) => + body.append(key, value), + ); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith("/contact/submit", { + method: "POST", + body, + }); + }); + + expect(push).toHaveBeenCalledWith("/contact/success"); + }); + + it("should disable form fields while submitting", async () => { + render(); + + expectFormToBeEnabled(); + + fireEvent.submit(getSubmitButton()); + + await waitFor(() => { + expectFormToBeDisabled(); + }); + + await waitFor(() => { + expectFormToBeEnabled(); + }); + }); + + it("should show error message if submission fails", async () => { + (fetch as jest.Mock).mockRejectedValue(new Error()); + + render(); + + fireEvent.submit(getSubmitButton()); + + await waitFor(() => { + expectFormToBeDisabled(); + }); + + await waitFor(() => { + expect(screen.getByText(/error/i)).toBeInTheDocument(); + }); + + expect(push).not.toHaveBeenCalled(); + + await waitFor(() => { + expectFormToBeEnabled(); + }); + }); + + it("should hide error message when form is changed", async () => { + (fetch as jest.Mock).mockRejectedValue(new Error()); + + render(); + + fireEvent.submit(getSubmitButton()); + + await waitFor(() => { + expect(screen.getByText(/error/i)).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByRole("textbox", { name: "Name" }), { + target: { value: "John Doe" }, + }); + + await waitFor(() => { + expect(screen.queryByText(/error/i)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/app/contact/components/ContactForm.tsx b/src/app/contact/components/ContactForm.tsx new file mode 100644 index 0000000..a746306 --- /dev/null +++ b/src/app/contact/components/ContactForm.tsx @@ -0,0 +1,65 @@ +"use client"; +import { useState } from "react"; +import { BiMailSend } from "react-icons/bi"; +import { useRouter } from "next/navigation"; +import { Input } from "../../forms/components/Input"; +import { Submit } from "../../forms/components/Submit"; +import { Field } from "../../forms/models"; +import { Contact } from "../models"; +import { fields } from "../utils"; + +export function ContactForm() { + const router = useRouter(); + const [isSending, setIsSending] = useState(false); + const [isError, setIsError] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + try { + e.preventDefault(); + setIsSending(true); + const body = new FormData(e.currentTarget); + await fetch("/contact/submit", { method: "POST", body }); + router.push("/contact/success"); + } catch (error) { + setIsError(true); + } finally { + setIsSending(false); + } + } + + function getField(field: Field): Field { + return { + ...field, + attributes: { + ...field.attributes, + disabled: isSending, + }, + }; + } + + return ( +
setIsError(false)} + className="flex flex-col gap-6" + > +
+ + + + +
+
+ } + /> + {isError && ( +

+ There was an error sending your message. Please try again later. +

+ )} +
+
+ ); +} diff --git a/src/app/contact/layout.test.tsx b/src/app/contact/layout.test.tsx new file mode 100644 index 0000000..6b87001 --- /dev/null +++ b/src/app/contact/layout.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import ContactLayout, { metadata } from "./layout"; + +describe("ContactLayout", () => { + it("should contain metadata", () => { + expect(metadata.title).toBeTruthy(); + }); + + it("should render title", () => { + render( ); + + expect( + screen.getByRole("heading", { name: "Contact" }), + ).toBeInTheDocument(); + }); + + it("should render children", () => { + const testId = "test-children"; + render( + +
+
, + ); + + expect(screen.getByTestId(testId)).toBeInTheDocument(); + expect(screen.queryAllByTestId(testId)).toHaveLength(1); + }); +}); diff --git a/src/app/contact/layout.tsx b/src/app/contact/layout.tsx new file mode 100644 index 0000000..445161e --- /dev/null +++ b/src/app/contact/layout.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Contact", +}; + +export default function ContactLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+

Contact

+
{children}
+
+
+ ); +} diff --git a/src/app/contact/models.ts b/src/app/contact/models.ts new file mode 100644 index 0000000..4e41587 --- /dev/null +++ b/src/app/contact/models.ts @@ -0,0 +1,6 @@ +export interface Contact { + name: string; + email: string; + subject: string; + text: string; +} diff --git a/src/app/contact/page.test.tsx b/src/app/contact/page.test.tsx new file mode 100644 index 0000000..7d64f8c --- /dev/null +++ b/src/app/contact/page.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import ContactPage from "./page"; + +const ContactForm = "ContactForm"; + +jest.mock("./components/ContactForm", () => ({ + ContactForm: () =>
, +})); + +describe("Contact", () => { + it("should render ContactForm", () => { + render(); + + expect(screen.getByTestId(ContactForm)).toBeInTheDocument(); + expect(screen.queryAllByTestId(ContactForm)).toHaveLength(1); + }); +}); diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx new file mode 100644 index 0000000..41b5473 --- /dev/null +++ b/src/app/contact/page.tsx @@ -0,0 +1,5 @@ +import { ContactForm } from "./components/ContactForm"; + +export default function ContactPage() { + return ; +} diff --git a/src/app/contact/submit/mailer.test.ts b/src/app/contact/submit/mailer.test.ts new file mode 100644 index 0000000..3cbf8ef --- /dev/null +++ b/src/app/contact/submit/mailer.test.ts @@ -0,0 +1,38 @@ +import { createTransport } from "nodemailer"; +import { sendMail } from "./mailer"; + +jest.mock("nodemailer", () => ({ + createTransport: jest.fn(() => ({ + sendMail: jest.fn(), + })), +})); + +describe("sendMail", () => { + it("should create transporter correctly", async () => { + expect(createTransport).toHaveBeenCalledWith({ + service: "gmail", + auth: { + user: "mockUser", + pass: "mockPassword", + }, + }); + }); + + it("should send email correctly", async () => { + const mail = { + subject: "Test Subject", + html: "

Hello World

", + }; + + await sendMail(mail); + + const sendMailMocked = (createTransport as jest.Mock).mock.results[0].value + .sendMail; + + expect(sendMailMocked).toHaveBeenCalledWith({ + ...mail, + from: "Personal Portfolio ", + to: "mockUser@example.com", + }); + }); +}); diff --git a/src/app/contact/submit/mailer.ts b/src/app/contact/submit/mailer.ts new file mode 100644 index 0000000..6c0760d --- /dev/null +++ b/src/app/contact/submit/mailer.ts @@ -0,0 +1,24 @@ +import { SentMessageInfo, createTransport } from "nodemailer"; + +const { SMTP_USER: user, SMTP_PASSWORD: pass, SMTP_RECEIVER: to } = process.env; + +const transporter = createTransport({ + service: "gmail", + auth: { + user, + pass, + }, +}); + +interface Mail { + subject: string; + html: string; +} + +export function sendMail(mail: Mail): Promise { + return transporter.sendMail({ + ...mail, + from: `Personal Portfolio <${user}>`, + to, + }); +} diff --git a/src/app/contact/submit/route.test.ts b/src/app/contact/submit/route.test.ts new file mode 100644 index 0000000..7061271 --- /dev/null +++ b/src/app/contact/submit/route.test.ts @@ -0,0 +1,77 @@ +/** + * @jest-environment node + */ + +import { NextRequest } from "next/server"; +import { isContact } from "../utils"; +import { Contact } from "../models"; +import { sendMail } from "./mailer"; +import { POST } from "./route"; + +jest.mock("../utils"); +jest.mock("./mailer"); + +const contact: Contact = { + name: "John Doe", + email: "johndoe@example.com", + subject: "Test Subject", + text: "Test Message", +}; + +const { name, email, subject, text } = contact; + +describe("POST", () => { + let body: FormData; + let request: NextRequest; + + beforeEach(() => { + jest.clearAllMocks(); + + body = new FormData(); + Object.entries(contact).forEach(([key, value]) => body.append(key, value)); + + request = new NextRequest("https://example.com", { + method: "POST", + body, + }); + }); + + it("should send email and return 200 if form data is valid", async () => { + (isContact as unknown as jest.Mock).mockReturnValue(true); + + const response = await POST(request); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + message: `Successfully sent email to ${email}`, + }); + expect(sendMail).toHaveBeenCalledWith({ + subject, + html: `${name} <${email}>

${text}`, + }); + }); + + it("should return 400 if form data is invalid", async () => { + (isContact as unknown as jest.Mock).mockReturnValue(false); + + const response = await POST(request); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ message: "Invalid form data" }); + expect(sendMail).not.toHaveBeenCalled(); + }); + + it("should return 500 if sending email fails", async () => { + (isContact as unknown as jest.Mock).mockReturnValue(true); + (sendMail as jest.Mock).mockRejectedValue(new Error()); + + const response = await POST(request); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ message: "Failed to send email" }); + expect(sendMail).toHaveBeenCalledWith({ + subject, + html: `${name} <${email}>

${text}`, + }); + }); +}); diff --git a/src/app/contact/submit/route.ts b/src/app/contact/submit/route.ts new file mode 100644 index 0000000..d09c3a5 --- /dev/null +++ b/src/app/contact/submit/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sendMail } from "./mailer"; +import { isContact } from "../utils"; + +export async function POST(request: NextRequest): Promise< + NextResponse<{ + message: string; + }> +> { + const form = Object.fromEntries(await request.formData()); + + if (!isContact(form)) { + return NextResponse.json({ message: "Invalid form data" }, { status: 400 }); + } + + const { name, email, subject, text } = form; + + try { + await sendMail({ + subject, + html: `${name} <${email}>

${text}`, + }); + + return NextResponse.json({ + message: `Successfully sent email to ${form.email}`, + }); + } catch (error) { + return NextResponse.json( + { message: "Failed to send email" }, + { status: 500 }, + ); + } +} diff --git a/src/app/contact/success/page.test.tsx b/src/app/contact/success/page.test.tsx new file mode 100644 index 0000000..8e930ab --- /dev/null +++ b/src/app/contact/success/page.test.tsx @@ -0,0 +1,10 @@ +import { render, screen } from "@testing-library/react"; +import ContactSuccessPage from "./page"; + +describe("ContactSuccess", () => { + it("should render ContactForm", () => { + render(); + + expect(screen.getByText(/Thank you/i)).toBeInTheDocument(); + }); +}); diff --git a/src/app/contact/success/page.tsx b/src/app/contact/success/page.tsx new file mode 100644 index 0000000..8fb4806 --- /dev/null +++ b/src/app/contact/success/page.tsx @@ -0,0 +1,7 @@ +export default function ContactSuccessPage() { + return ( +

+ Thank you for your message! I will get back to you as soon as possible. 😀 +

+ ); +} diff --git a/src/app/contact/utils.test.ts b/src/app/contact/utils.test.ts new file mode 100644 index 0000000..4560742 --- /dev/null +++ b/src/app/contact/utils.test.ts @@ -0,0 +1,25 @@ +import { isValid } from "../forms/validator"; +import { Contact } from "./models"; +import { fields, isContact } from "./utils"; + +jest.mock("../forms/validator", () => ({ + isValid: jest.fn(), +})); + +describe("isContact", () => { + it("should call isValid", () => { + const contact: Contact = { + name: "name", + email: "email", + subject: "subject", + text: "text", + }; + + isContact(contact); + + expect(isValid).toHaveBeenCalledWith({ + obj: contact, + fields, + }); + }); +}); diff --git a/src/app/contact/utils.ts b/src/app/contact/utils.ts new file mode 100644 index 0000000..987960d --- /dev/null +++ b/src/app/contact/utils.ts @@ -0,0 +1,40 @@ +import { Fields } from "../forms/models"; +import { isValid } from "../forms/validator"; +import { Contact } from "./models"; + +export const fields: Fields = { + name: { + key: "name", + label: "Name", + type: "text", + attributes: { + autoComplete: "name", + }, + validators: { required: true, minLength: 2, maxLength: 30 }, + }, + email: { + key: "email", + label: "Email", + type: "email", + attributes: { + autoComplete: "email", + }, + validators: { required: true, minLength: 5, maxLength: 50 }, + }, + subject: { + key: "subject", + label: "Subject", + type: "text", + validators: { required: true, minLength: 2, maxLength: 60 }, + }, + text: { + key: "text", + label: "Text", + type: "textarea", + validators: { maxLength: 500 }, + }, +}; + +export function isContact(obj: unknown): obj is Contact { + return isValid({ obj, fields }); +} diff --git a/src/app/forms/components/Input.test.tsx b/src/app/forms/components/Input.test.tsx new file mode 100644 index 0000000..fe473a7 --- /dev/null +++ b/src/app/forms/components/Input.test.tsx @@ -0,0 +1,96 @@ +import { render, screen } from "@testing-library/react"; +import { Field, InputType } from "../models"; +import { Input } from "./Input"; + +describe("Input", () => { + const baseField: Field = { + key: "testKey", + label: "Test Label", + type: "text", + }; + + it("should be rendered correctly", () => { + render(); + + const input = screen.getByRole("textbox"); + expect(input).toHaveAttribute("id", "testKey"); + expect(input).toHaveAttribute("name", "testKey"); + + const label = screen.getByText("Test Label"); + expect(label).toHaveAttribute("for", "testKey"); + }); + + it.each([["text"], ["email"]] satisfies [InputType][])( + "should render when type is '%s'", + (type) => { + const field: Field = { + ...baseField, + type, + }; + render(); + + const input = screen.getByRole("textbox"); + + expect(input.tagName).toBe("INPUT"); + expect(input).toHaveAttribute("type", type); + }, + ); + + it("should render a texarea when type is 'textarea'", () => { + const field: Field = { + ...baseField, + type: "textarea", + }; + render(); + + const input = screen.getByRole("textbox"); + + expect(input.tagName).toBe("TEXTAREA"); + }); + + it("should apply attributes", () => { + const field: Field = { + ...baseField, + attributes: { + placeholder: "Enter something", + }, + }; + render(); + + const input = screen.getByRole("textbox"); + expect(input).toHaveAttribute("placeholder", "Enter something"); + }); + + it("should not have unexpected attributes", () => { + render(); + + const input = screen.getByRole("textbox"); + expect(input).not.toHaveAttribute("placeholder"); + }); + + it("should apply validators", () => { + const field: Field = { + ...baseField, + validators: { + required: true, + minLength: 5, + maxLength: 10, + }, + }; + render(); + + const input = screen.getByRole("textbox"); + expect(input).toHaveAttribute("required"); + expect(input).toHaveAttribute("minLength", "5"); + expect(input).toHaveAttribute("maxLength", "10"); + }); + + it("should not have unexpected validators", () => { + render(); + + const input = screen.getByRole("textbox"); + expect(input).not.toHaveAttribute("required"); + expect(input).not.toHaveAttribute("minLength"); + expect(input).not.toHaveAttribute("maxLength"); + }); +}); diff --git a/src/app/forms/components/Input.tsx b/src/app/forms/components/Input.tsx new file mode 100644 index 0000000..e1c37f5 --- /dev/null +++ b/src/app/forms/components/Input.tsx @@ -0,0 +1,44 @@ +import { InputHTMLAttributes, TextareaHTMLAttributes } from "react"; +import { Field } from "../models"; + +export interface InputProps { + field: Field; +} + +const className = "px-5 py-2 border bg-input-bg rounded-md shadow-sm"; + +export function Input({ + field: { key, label, type, validators, attributes }, +}: InputProps) { + const commonProps: InputHTMLAttributes & + TextareaHTMLAttributes = { + ...attributes, + ...validators, + id: key, + name: key, + className, + }; + + const inputProps: InputHTMLAttributes = { + ...commonProps, + type, + }; + + const textareaProps: TextareaHTMLAttributes = { + ...commonProps, + rows: 5, + }; + + return ( +
+ + {type === "textarea" ? ( +