Skip to content

Commit

Permalink
Add POST /api/contact endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
paulshryock committed Jul 18, 2024
1 parent 81ebbe3 commit 12f3515
Show file tree
Hide file tree
Showing 2 changed files with 306 additions and 0 deletions.
93 changes: 93 additions & 0 deletions src/routes/api/contact.ts
Original file line number Diff line number Diff line change
@@ -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<Response>} HTTP response.
*
* @since unreleased
*/
export default async function createMessage(
request: Request,
_context: Context,
): Promise<Response> {
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,
},
)
}
213 changes: 213 additions & 0 deletions tests/unit/routes/api/contact.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
})
})
})
})

0 comments on commit 12f3515

Please sign in to comment.