From 12f35151bd1a8f43f8baf1df6d3e828fdfda7fa1 Mon Sep 17 00:00:00 2001 From: Paul Shryock Date: Thu, 18 Jul 2024 11:20:05 -0400 Subject: [PATCH] Add POST /api/contact endpoint --- src/routes/api/contact.ts | 93 +++++++++++ tests/unit/routes/api/contact.test.ts | 213 ++++++++++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 src/routes/api/contact.ts create mode 100644 tests/unit/routes/api/contact.test.ts diff --git a/src/routes/api/contact.ts b/src/routes/api/contact.ts new file mode 100644 index 0000000..73202a8 --- /dev/null +++ b/src/routes/api/contact.ts @@ -0,0 +1,93 @@ +import type { Context } from '@netlify/functions' +import site from '../../../src/data/site.js' + +/* eslint-disable */ + +/** + * Creates a message from an HTTP request. + * + * @param {Request} request Http request. + * @param {Context} _context Netlify function context. + * @return {Promise} HTTP response. + * + * @since unreleased + */ +export default async function createMessage( + request: Request, + _context: Context, +): Promise { + const defaultHeaders = { + 'Access-Control-Allow-Methods': 'POST', + 'Access-Control-Allow-Origin': site.origin, + } + + if (request.method !== 'POST') + return new Response('Method Not Allowed', { + headers: new Headers({ ...defaultHeaders, Allow: 'POST' }), + status: 405, + }) + + if (request.headers.get('Origin') !== site.origin) + return new Response('Bad Request', { + headers: new Headers(defaultHeaders), + status: 400, + }) + + const contentType = request.headers.get('Content-Type') + + if (contentType !== 'application/json') + return new Response( + JSON.stringify({ + allowedContentType: 'application/json', + contentType, + error: 'Invalid Content-Type header.', + status: 400, + statusText: 'Bad Request', + }), + { + headers: new Headers(defaultHeaders), + status: 400, + }, + ) + + const fields = await request.json() + const requiredFields = ['email', 'message', 'name'] + + for (const field of requiredFields) { + if (!(field in fields)) + return new Response( + JSON.stringify({ + field, + error: 'Missing or invalid field.', + status: 400, + statusText: 'Bad Request', + }), + { + headers: new Headers(defaultHeaders), + status: 400, + }, + ) + } + + // todo: create message, add try/catch + const messageWasCreated = true // todo: createMessage(), returns boolean + + /* istanbul ignore next */ + if (!messageWasCreated) + return new Response('Internal Server Error', { + headers: new Headers(defaultHeaders), + status: 500, + }) + + return new Response( + JSON.stringify({ + message: 'Message received.', + status: 200, + statusText: 'OK', + }), + { + headers: new Headers(defaultHeaders), + status: 200, + }, + ) +} diff --git a/tests/unit/routes/api/contact.test.ts b/tests/unit/routes/api/contact.test.ts new file mode 100644 index 0000000..b8e863e --- /dev/null +++ b/tests/unit/routes/api/contact.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from '@jest/globals' +import type { Context } from '@netlify/functions' +import createMessage from '../../../../src/routes/api/contact.ts' +import site from '../../../../src/data/site.js' + +const PATH = '/api/contact' +const ROUTE = `${site.origin}${PATH}/` + +const httpMethods = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT'] +const defaultHeaderFields = [ + 'Access-Control-Allow-Methods', + 'Access-Control-Allow-Origin', +] + +describe(PATH, () => { + describe.each(httpMethods.filter((method) => method !== 'POST'))( + 'when http method is %s', + (method) => { + const request = new Request(ROUTE, { method }) + + it('should return a 405 response status', async () => + expect((await createMessage(request, {} as Context)).status).toBe(405)) + + it.each([...defaultHeaderFields, 'Allow'])( + 'should include an %s header', + async (header) => + expect( + (await createMessage(request, {} as Context)).headers.get(header), + ).not.toBeNull(), + ) + }, + ) + + describe('when http method is POST', () => { + const method = 'POST' + + describe('when an Origin header is not included', () => { + const request = new Request(ROUTE, { method }) + + it('should return a 400 response status', async () => + expect((await createMessage(request, {} as Context)).status).toBe(400)) + + it.each(defaultHeaderFields)( + 'should include an %s header', + async (header) => + expect( + (await createMessage(request, {} as Context)).headers.get(header), + ).not.toBeNull(), + ) + }) + + describe('when an invalid Origin header is included', () => { + const headers = new Headers({ Origin: 'https://www.invalid-origin.com' }) + const request = new Request(ROUTE, { headers, method }) + + it('should return a 400 response status', async () => + expect((await createMessage(request, {} as Context)).status).toBe(400)) + + it.each(defaultHeaderFields)( + 'should include an %s header', + async (header) => + expect( + (await createMessage(request, {} as Context)).headers.get(header), + ).not.toBeNull(), + ) + }) + + describe('when a valid Origin header is included', () => { + const headers = new Headers({ Origin: site.origin }) + + describe.each([ + 'application/x-www-form-urlencoded', + 'multipart/form-data', + 'text/html', + 'text/plain', + ])('when Content-Type header is %s', (contentType) => { + headers.set('Content-Type', contentType) + + const request = new Request(ROUTE, { headers, method }) + + it('should return a 400 response status', async () => + expect((await createMessage(request, {} as Context)).status).toBe( + 400, + )) + + it.each(defaultHeaderFields)( + 'should include an %s header', + async (header) => + expect( + (await createMessage(request, {} as Context)).headers.get(header), + ).not.toBeNull(), + ) + + it('should return an error message in the body', async () => + expect( + await (await createMessage(request, {} as Context)).json(), + ).toHaveProperty('error')) + + it.each(['allowedContentType', 'contentType'])( + 'should return a(n) %s in the body', + async (property) => + expect( + await (await createMessage(request, {} as Context)).json(), + ).toHaveProperty(property), + ) + }) + + describe('when Content-Type header is application/json', () => { + headers.set('Content-Type', 'application/json') + + describe('when a required field is missing or invalid', () => { + const body = JSON.stringify({}) + + it('should return a 400 response status', async () => { + const request = new Request(ROUTE, { body, headers, method }) + + expect((await createMessage(request, {} as Context)).status).toBe( + 400, + ) + }) + + it.each(defaultHeaderFields)( + 'should include an %s header', + async (header) => { + const request = new Request(ROUTE, { body, headers, method }) + + expect( + (await createMessage(request, {} as Context)).headers.get( + header, + ), + ).not.toBeNull() + }, + ) + + it.each([ + ['an error message', 'error'], + ['the missing or invalid field', 'field'], + ])('should return %s in the body', async (_, property) => { + const request = new Request(ROUTE, { body, headers, method }) + + expect( + await (await createMessage(request, {} as Context)).json(), + ).toHaveProperty(property) + }) + }) + + describe('when all required fields are valid', () => { + const body = JSON.stringify({ + email: 'example@example.com', + message: 'This is an example message.', + name: 'Example', + }) + + // eslint-disable-next-line no-warning-comments -- Temporary. + // todo: unskip when this is complete. + describe.skip('when a message is not saved to storage', () => { + it('should return a 500 response status', async () => { + const request = new Request(ROUTE, { body, headers, method }) + + expect((await createMessage(request, {} as Context)).status).toBe( + 500, + ) + }) + + it.each(defaultHeaderFields)( + 'should include an %s header', + async (header) => { + const request = new Request(ROUTE, { body, headers, method }) + + expect( + (await createMessage(request, {} as Context)).headers.get( + header, + ), + ).not.toBeNull() + }, + ) + }) + + describe('when a message is saved to storage', () => { + it('should return a 200 response status', async () => { + const request = new Request(ROUTE, { body, headers, method }) + + expect((await createMessage(request, {} as Context)).status).toBe( + 200, + ) + }) + + it.each(defaultHeaderFields)( + 'should include an %s header', + async (header) => { + const request = new Request(ROUTE, { body, headers, method }) + + expect( + (await createMessage(request, {} as Context)).headers.get( + header, + ), + ).not.toBeNull() + }, + ) + + it('should return a message in the body', async () => { + const request = new Request(ROUTE, { body, headers, method }) + + expect( + await (await createMessage(request, {} as Context)).json(), + ).toHaveProperty('message') + }) + }) + }) + }) + }) + }) +})