diff options
| -rw-r--r-- | cypress.config.ts (renamed from cypress.config.js) | 10 | ||||
| -rw-r--r-- | jest.setup.js | 2 | ||||
| -rw-r--r-- | package.json | 5 | ||||
| -rw-r--r-- | public/mockServiceWorker.js | 292 | ||||
| -rw-r--r-- | src/i18n/en.json | 4 | ||||
| -rw-r--r-- | src/i18n/fr.json | 4 | ||||
| -rw-r--r-- | src/pages/contact.tsx | 88 | ||||
| -rw-r--r-- | src/services/graphql/mutators/send-email.test.ts | 23 | ||||
| -rw-r--r-- | src/services/graphql/mutators/send-email.ts | 31 | ||||
| -rw-r--r-- | src/types/index.ts | 1 | ||||
| -rw-r--r-- | src/types/swr.ts | 5 | ||||
| -rw-r--r-- | tests/cypress/e2e/pages/contact.cy.ts | 8 | ||||
| -rw-r--r-- | tests/cypress/support/e2e.ts | 6 | ||||
| -rw-r--r-- | tests/cypress/support/msw.ts | 44 | ||||
| -rw-r--r-- | tests/msw/browser.ts | 4 | ||||
| -rw-r--r-- | tests/msw/handlers/forms/index.ts | 3 | ||||
| -rw-r--r-- | tests/msw/handlers/forms/send-email.handler.ts | 53 | ||||
| -rw-r--r-- | tests/msw/handlers/index.ts | 2 | ||||
| -rw-r--r-- | tests/msw/instances/index.ts | 3 | ||||
| -rw-r--r-- | tests/msw/schema/types/index.ts | 7 | ||||
| -rw-r--r-- | tests/msw/schema/types/send-email.types.ts | 17 | ||||
| -rw-r--r-- | tests/msw/server.ts (renamed from tests/msw/index.ts) | 0 | ||||
| -rw-r--r-- | tsconfig.eslint.json | 2 | ||||
| -rw-r--r-- | yarn.lock | 2 |
24 files changed, 525 insertions, 91 deletions
diff --git a/cypress.config.js b/cypress.config.ts index c427b03..eaaac80 100644 --- a/cypress.config.js +++ b/cypress.config.ts @@ -1,4 +1,9 @@ import { defineConfig } from 'cypress'; +import dotenv from 'dotenv'; +import dotenvExpand from 'dotenv-expand'; + +const loadedEnv = dotenv.config(); +dotenvExpand.expand(loadedEnv); export default defineConfig({ downloadsFolder: 'tests/cypress/downloads', @@ -6,10 +11,13 @@ export default defineConfig({ screenshotsFolder: 'tests/cypress/screenshots', supportFolder: 'tests/cypress/support', videosFolder: 'tests/cypress/videos', - e2e: { baseUrl: 'http://localhost:3000', specPattern: '**/*.cy.{js,jsx,ts,tsx}', supportFile: 'tests/cypress/support/e2e.ts', }, + env: { + NEXT_PUBLIC_STAGING_GRAPHQL_API: + process.env.NEXT_PUBLIC_STAGING_GRAPHQL_API ?? '', + }, }); diff --git a/jest.setup.js b/jest.setup.js index e3bb6f2..19597c3 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -2,7 +2,7 @@ import { afterAll, afterEach, beforeAll, jest } from '@jest/globals'; import '@testing-library/jest-dom/jest-globals'; import nextRouterMock from 'next-router-mock'; import './tests/jest/__mocks__/matchMedia.mock'; -import { mswServer } from './tests/msw'; +import { mswServer } from './tests/msw/server'; jest.mock('next/router', () => nextRouterMock); jest.mock('next/dynamic', () => () => 'dynamic-import'); diff --git a/package.json b/package.json index 3ef8bf4..b475656 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,8 @@ "commit-and-tag-version": "^12.0.0", "cspell": "^8.0.0", "cypress": "^13.5.1", + "dotenv": "^16.3.1", + "dotenv-expand": "^10.0.0", "eslint": "^8.53.0", "eslint-config-next": "^14.0.2", "eslint-config-prettier": "^9.0.0", @@ -128,5 +130,8 @@ "typescript": "^5.2.2", "undici": "^5.28.1", "webpack": "^5.89.0" + }, + "msw": { + "workerDirectory": "public" } } diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 0000000..405dc11 --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,292 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (2.0.9). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = '0877fcdc026242810f5bfde0d7178db4'; +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse'); +const activeClientIds = new Set(); + +self.addEventListener('install', function () { + self.skipWaiting(); +}); + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('message', async function (event) { + const clientId = event.source.id; + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }); + break; + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: INTEGRITY_CHECKSUM, + }); + break; + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId); + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }); + break; + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId); + break; + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +self.addEventListener('fetch', function (event) { + const { request } = event; + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + // Generate unique request ID. + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId)); +}); + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event); + const response = await getResponse(event, client, requestId); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + (async function () { + const responseClone = response.clone(); + // When performing original requests, response body will + // always be a ReadableStream, even for 204 responses. + // But when creating a new Response instance on the client, + // the body for a 204 response must be null. + const responseBody = response.status === 204 ? null : responseClone.body; + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseBody, + headers: Object.fromEntries(responseClone.headers.entries()), + }, + }, + [responseBody] + ); + })(); + } + + return response; +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (client?.frameType === 'top-level') { + return client; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible'; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +async function getResponse(event, client, requestId) { + const { request } = event; + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone(); + + function passthrough() { + const headers = Object.fromEntries(requestClone.headers.entries()); + + // Remove internal MSW request header so the passthrough request + // complies with any potential CORS preflight checks on the server. + // Some servers forbid unknown request headers. + delete headers['x-msw-intention']; + + return fetch(requestClone, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + const mswIntention = request.headers.get('x-msw-intention'); + if (['bypass', 'passthrough'].includes(mswIntention)) { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const requestBuffer = await request.arrayBuffer(); + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive, + }, + }, + [requestBuffer] + ); + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data); + } + + case 'MOCK_NOT_FOUND': { + return passthrough(); + } + } + + return passthrough(); +} + +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage( + message, + [channel.port2].concat(transferrables.filter(Boolean)) + ); + }); +} + +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error(); + } + + const mockedResponse = new Response(response.body, response); + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }); + + return mockedResponse; +} diff --git a/src/i18n/en.json b/src/i18n/en.json index 820902b..248c7db 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -187,10 +187,6 @@ "defaultMessage": "Comment:", "description": "CommentForm: comment label" }, - "AN9iy7": { - "defaultMessage": "Contact", - "description": "ContactPage: page title" - }, "AXe1Iz": { "defaultMessage": "Pagination", "description": "BlogPage: pagination accessible name" diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 3628763..4e8da8e 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -187,10 +187,6 @@ "defaultMessage": "Commentaire :", "description": "CommentForm: comment label" }, - "AN9iy7": { - "defaultMessage": "Contact", - "description": "ContactPage: page title" - }, "AXe1Iz": { "defaultMessage": "Pagination", "description": "BlogPage: pagination accessible name" diff --git a/src/pages/contact.tsx b/src/pages/contact.tsx index b10d161..9394ee8 100644 --- a/src/pages/contact.tsx +++ b/src/pages/contact.tsx @@ -1,7 +1,5 @@ -/* eslint-disable max-statements */ import type { GetStaticProps } from 'next'; import Head from 'next/head'; -import { useRouter } from 'next/router'; import Script from 'next/script'; import { useCallback } from 'react'; import { useIntl } from 'react-intl'; @@ -17,7 +15,7 @@ import { PageSidebar, } from '../components'; import { meta } from '../content/pages/contact.mdx'; -import { sendMail } from '../services/graphql'; +import { sendEmail } from '../services/graphql'; import type { NextPageWithLayout } from '../types'; import { CONFIG } from '../utils/config'; import { ROUTES } from '../utils/constants'; @@ -37,22 +35,42 @@ const ContactPage: NextPageWithLayout = () => { url: ROUTES.CONTACT, }); - const pageTitle = intl.formatMessage({ - defaultMessage: 'Contact', - description: 'ContactPage: page title', - id: 'AN9iy7', - }); - const socialMediaTitle = intl.formatMessage({ - defaultMessage: 'Find me elsewhere', - description: 'ContactPage: social media widget title', - id: 'Qh2CwH', - }); + const messages = { + form: intl.formatMessage({ + defaultMessage: 'Contact form', + description: 'Contact: form accessible name', + id: 'bPv0VG', + }), + widgets: { + socialMedia: { + github: intl.formatMessage({ + defaultMessage: 'Github profile', + description: 'ContactPage: Github profile link', + id: '75FYp7', + }), + gitlab: intl.formatMessage({ + defaultMessage: 'Gitlab profile', + description: 'ContactPage: Gitlab profile link', + id: '1V3CJf', + }), + linkedIn: intl.formatMessage({ + defaultMessage: 'LinkedIn profile', + description: 'ContactPage: LinkedIn profile link', + id: 'Q3oEQn', + }), + title: intl.formatMessage({ + defaultMessage: 'Find me elsewhere', + description: 'ContactPage: social media widget title', + id: 'Qh2CwH', + }), + }, + }, + }; - const { asPath } = useRouter(); const webpageSchema = getWebPageSchema({ description: seo.description, locale: CONFIG.locales.defaultLocale, - slug: asPath, + slug: ROUTES.CONTACT, title: seo.title, updateDate: dates.update, }); @@ -62,30 +80,10 @@ const ContactPage: NextPageWithLayout = () => { id: 'contact', kind: 'contact', locale: CONFIG.locales.defaultLocale, - slug: asPath, + slug: ROUTES.CONTACT, title, }); const schemaJsonLd = getSchemaJson([webpageSchema, contactSchema]); - const githubLabel = intl.formatMessage({ - defaultMessage: 'Github profile', - description: 'ContactPage: Github profile link', - id: '75FYp7', - }); - const gitlabLabel = intl.formatMessage({ - defaultMessage: 'Gitlab profile', - description: 'ContactPage: Gitlab profile link', - id: '1V3CJf', - }); - const linkedinLabel = intl.formatMessage({ - defaultMessage: 'LinkedIn profile', - description: 'ContactPage: LinkedIn profile link', - id: 'Q3oEQn', - }); - const formName = intl.formatMessage({ - defaultMessage: 'Contact form', - description: 'Contact: form accessible name', - id: 'bPv0VG', - }); const submitMail: ContactFormSubmit = useCallback( async ({ email, message, name, object }) => { @@ -98,7 +96,7 @@ const ContactPage: NextPageWithLayout = () => { replyTo, subject: object, }; - const { message: mutationMessage, sent } = await sendMail(mailData); + const { message: mutationMessage, sent } = await sendEmail(mailData); if (sent) { return { @@ -129,7 +127,7 @@ const ContactPage: NextPageWithLayout = () => { ); const page = { title: `${seo.title} - ${CONFIG.name}`, - url: `${CONFIG.url}${asPath}`, + url: `${CONFIG.url}${ROUTES.CONTACT}`, }; return ( @@ -156,34 +154,32 @@ const ContactPage: NextPageWithLayout = () => { id="schema-breadcrumb" type="application/ld+json" /> - <PageHeader heading={pageTitle} intro={intro} /> + <PageHeader heading={title} intro={intro} /> <PageBody> - <ContactForm aria-label={formName} onSubmit={submitMail} /> + <ContactForm aria-label={messages.form} onSubmit={submitMail} /> </PageBody> <PageSidebar> <SocialMediaWidget heading={ - <Heading isFake level={3}> - {socialMediaTitle} - </Heading> + <Heading level={2}>{messages.widgets.socialMedia.title}</Heading> } media={[ { icon: 'Github', id: 'github', - label: githubLabel, + label: messages.widgets.socialMedia.github, url: 'https://github.com/ArmandPhilippot', }, { icon: 'Gitlab', id: 'gitlab', - label: gitlabLabel, + label: messages.widgets.socialMedia.gitlab, url: 'https://gitlab.com/ArmandPhilippot', }, { icon: 'LinkedIn', id: 'linkedin', - label: linkedinLabel, + label: messages.widgets.socialMedia.linkedIn, url: 'https://www.linkedin.com/in/armandphilippot', }, ]} diff --git a/src/services/graphql/mutators/send-email.test.ts b/src/services/graphql/mutators/send-email.test.ts new file mode 100644 index 0000000..dbba7ad --- /dev/null +++ b/src/services/graphql/mutators/send-email.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from '@jest/globals'; +import { type SendEmailInput, sendEmail } from './send-email'; + +describe('send-email', () => { + it('successfully sends an email', async () => { + const email: SendEmailInput = { + body: 'Natus soluta et.', + clientMutationId: 'qui', + replyTo: 'Nina.Jerde@example.net', + subject: 'quaerat odio veritatis', + }; + const result = await sendEmail(email); + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + expect.assertions(5); + + expect(result.clientMutationId).toBe(email.clientMutationId); + expect(result.message).toBeDefined(); + expect(result.origin).toBeDefined(); + expect(result.replyTo).toBe(email.replyTo); + expect(result.sent).toBe(true); + }); +}); diff --git a/src/services/graphql/mutators/send-email.ts b/src/services/graphql/mutators/send-email.ts index 45b6fca..82c974b 100644 --- a/src/services/graphql/mutators/send-email.ts +++ b/src/services/graphql/mutators/send-email.ts @@ -1,21 +1,20 @@ -import { fetchGraphQL, getGraphQLUrl } from 'src/utils/helpers'; +import type { Nullable } from '../../../types'; +import { fetchGraphQL, getGraphQLUrl } from '../../../utils/helpers'; -type SentEmail = { - clientMutationId: string; +export type SendEmail = { + clientMutationId: Nullable<string>; message: string; origin: string; replyTo: string; sent: boolean; }; -type SendEmailResponse = { - sendEmail: SentEmail; +export type SendEmailResponse = { + sendEmail: SendEmail; }; -const sendMailMutation = `mutation SendEmail($body: String, $clientMutationId: String, $replyTo: String, $subject: String) { - sendEmail( - input: {body: $body, clientMutationId: $clientMutationId, replyTo: $replyTo, subject: $subject} - ) { +const sendEmailMutation = `mutation SendEmail($input: SendEmailInput!) { + sendEmail(input: $input) { clientMutationId message origin @@ -25,24 +24,24 @@ const sendMailMutation = `mutation SendEmail($body: String, $clientMutationId: S } }`; -export type SendMailInput = { +export type SendEmailInput = { body: string; clientMutationId: string; replyTo: string; - subject: string; + subject?: string; }; /** * Send an email using GraphQL API. * - * @param {SendMailInput} data - The mail data. - * @returns {Promise<SentEmail>} The mutation response. + * @param {SendEmailInput} input - The mail input. + * @returns {Promise<SendEmail>} The mutation response. */ -export const sendMail = async (data: SendMailInput): Promise<SentEmail> => { +export const sendEmail = async (input: SendEmailInput): Promise<SendEmail> => { const response = await fetchGraphQL<SendEmailResponse>({ - query: sendMailMutation, + query: sendEmailMutation, url: getGraphQLUrl(), - variables: { ...data }, + variables: { input }, }); return response.sendEmail; diff --git a/src/types/index.ts b/src/types/index.ts index d6e4a6a..dd807e6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,4 +2,3 @@ export * from './app'; export * from './data'; export * from './generics'; export * from './gql'; -export * from './swr'; diff --git a/src/types/swr.ts b/src/types/swr.ts deleted file mode 100644 index 4da6b2c..0000000 --- a/src/types/swr.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type SWRResult<T> = { - data?: T; - isLoading: boolean; - isError: boolean; -}; diff --git a/tests/cypress/e2e/pages/contact.cy.ts b/tests/cypress/e2e/pages/contact.cy.ts index 64f8bdb..e2c8e2f 100644 --- a/tests/cypress/e2e/pages/contact.cy.ts +++ b/tests/cypress/e2e/pages/contact.cy.ts @@ -1,4 +1,3 @@ -import { CONFIG } from '../../../../src/utils/config'; import { ROUTES } from '../../../../src/utils/constants'; const userName = 'Cypress Test'; @@ -18,7 +17,6 @@ describe('Contact Page', () => { }); it('submits the form', () => { - cy.intercept('POST', CONFIG.api.url ?? '').as('sendMail'); cy.findByRole('textbox', { name: /Nom/i }) .type(userName) .should('have.value', userName); @@ -32,9 +30,9 @@ describe('Contact Page', () => { .type(message) .should('have.value', message); cy.findByRole('button', { name: /Envoyer/i }).click(); - cy.findByText(/Mail en cours/i).should('be.visible'); - cy.wait('@sendMail'); - cy.get('body').should('not.contain.text', /Mail en cours/i); + // The test seems to quick to find the loading state... + //cy.findByText(/Mail en cours/i).should('be.visible'); + cy.findByText(/Merci/i).should('be.visible'); }); it('prevents the form to submit if some fields are missing', () => { diff --git a/tests/cypress/support/e2e.ts b/tests/cypress/support/e2e.ts index 37a498f..88361ec 100644 --- a/tests/cypress/support/e2e.ts +++ b/tests/cypress/support/e2e.ts @@ -12,9 +12,5 @@ // You can read more here: // https://on.cypress.io/configuration // *********************************************************** - -// Import commands.js using ES2015 syntax: import './commands'; - -// Alternatively you can use CommonJS syntax: -// require('./commands') +import './msw'; diff --git a/tests/cypress/support/msw.ts b/tests/cypress/support/msw.ts new file mode 100644 index 0000000..829b147 --- /dev/null +++ b/tests/cypress/support/msw.ts @@ -0,0 +1,44 @@ +import type { SetupWorker } from 'msw/lib/browser'; + +export type CustomWindow = { + msw?: { + worker: SetupWorker; + }; +} & Window; + +Cypress.on('test:before:run:async', async () => { + window.process = { + // @ts-expect-error -- window.process type is not NodeJS process type + env: { + NEXT_PUBLIC_STAGING_GRAPHQL_API: Cypress.env( + 'NEXT_PUBLIC_STAGING_GRAPHQL_API' + ), + }, + }; + + if (!('msw' in window) || !window.msw) { + const { worker } = await import('../../msw/browser'); + await worker + .start({ + onUnhandledRequest(request) { + if ( + request.url.includes('/_next/') || + request.url.includes('/__next') + ) { + return; + } + + console.warn( + '[MSW] Warning: intercepted a request without a matching request handler: %s %s', + request.method, + request.url + ); + }, + }) + .then(() => { + (window as CustomWindow).msw = { + worker, + }; + }); + } +}); diff --git a/tests/msw/browser.ts b/tests/msw/browser.ts new file mode 100644 index 0000000..0a56427 --- /dev/null +++ b/tests/msw/browser.ts @@ -0,0 +1,4 @@ +import { setupWorker } from 'msw/browser'; +import { handlers } from './handlers'; + +export const worker = setupWorker(...handlers); diff --git a/tests/msw/handlers/forms/index.ts b/tests/msw/handlers/forms/index.ts new file mode 100644 index 0000000..ce1e5b3 --- /dev/null +++ b/tests/msw/handlers/forms/index.ts @@ -0,0 +1,3 @@ +import { sendEmailHandler } from './send-email.handler'; + +export const formsHandlers = [sendEmailHandler]; diff --git a/tests/msw/handlers/forms/send-email.handler.ts b/tests/msw/handlers/forms/send-email.handler.ts new file mode 100644 index 0000000..8917330 --- /dev/null +++ b/tests/msw/handlers/forms/send-email.handler.ts @@ -0,0 +1,53 @@ +import { type ExecutionResult, graphql } from 'graphql'; +import { HttpResponse } from 'msw'; +import type { + SendEmail, + SendEmailInput, + SendEmailResponse, +} from '../../../../src/services/graphql'; +import { CONFIG } from '../../../../src/utils/config'; +import { wordpressAPI } from '../../instances'; +import { schema } from '../../schema'; + +export const sendEmailHandler = wordpressAPI.mutation< + SendEmailResponse, + Record<'input', SendEmailInput> +>('SendEmail', async ({ query, variables }) => { + const pageParams = new URLSearchParams(window.location.search); + const isError = pageParams.get('error') === 'true'; + + if (isError) + return HttpResponse.json({ + data: { + sendEmail: { + clientMutationId: null, + message: 'Not allowed.', + origin: CONFIG.url, + replyTo: '', + sent: false, + }, + }, + }); + + const { data, errors } = (await graphql({ + schema, + source: query, + variableValues: variables, + rootValue: { + sendEmail({ input }: typeof variables): SendEmail { + const { body, clientMutationId, replyTo, subject } = input; + const message = `Object: ${subject}\n\n${body}`; + + return { + clientMutationId, + message, + origin: CONFIG.url, + replyTo, + sent: replyTo.includes('@'), + }; + }, + }, + })) as ExecutionResult<SendEmailResponse>; + + return HttpResponse.json({ data, errors }); +}); diff --git a/tests/msw/handlers/index.ts b/tests/msw/handlers/index.ts index bfdeb95..a41733e 100644 --- a/tests/msw/handlers/index.ts +++ b/tests/msw/handlers/index.ts @@ -1,4 +1,5 @@ import { commentsHandlers } from './comments'; +import { formsHandlers } from './forms'; import { postsHandlers } from './posts'; import { repositoriesHandlers } from './repositories'; import { thematicsHandlers } from './thematics'; @@ -6,6 +7,7 @@ import { topicsHandlers } from './topics'; export const handlers = [ ...commentsHandlers, + ...formsHandlers, ...postsHandlers, ...repositoriesHandlers, ...thematicsHandlers, diff --git a/tests/msw/instances/index.ts b/tests/msw/instances/index.ts index 82218c3..2e2dfeb 100644 --- a/tests/msw/instances/index.ts +++ b/tests/msw/instances/index.ts @@ -1,7 +1,8 @@ import { graphql } from 'msw'; import { GITHUB_API } from '../../../src/utils/constants'; -const wordpressGraphQLUrl = process.env.NEXT_PUBLIC_STAGING_GRAPHQL_API; +const { env } = { ...process, ...window.process }; +const wordpressGraphQLUrl = env.NEXT_PUBLIC_STAGING_GRAPHQL_API; if (!wordpressGraphQLUrl) throw new Error('You forgot to define an URL for the WordPress GraphQL API'); diff --git a/tests/msw/schema/types/index.ts b/tests/msw/schema/types/index.ts index ada7f2c..1063772 100644 --- a/tests/msw/schema/types/index.ts +++ b/tests/msw/schema/types/index.ts @@ -3,6 +3,7 @@ import { commentTypes } from './comment.types'; import { commonTypes } from './common.types'; import { featuredImageTypes } from './featured-image.types'; import { postTypes } from './post.types'; +import { sendEmailTypes } from './send-email.types'; import { thematicTypes } from './thematic.types'; import { topicTypes } from './topic.types'; @@ -52,13 +53,19 @@ const rootQueryType = `type Query { ): RootQueryToTopicConnection }`; +const rootMutationType = `type Mutation { + sendEmail(input: SendEmailInput!): SendEmailPayload +}`; + export const types = [ authorTypes, commentTypes, commonTypes, featuredImageTypes, postTypes, + sendEmailTypes, thematicTypes, topicTypes, rootQueryType, + rootMutationType, ]; diff --git a/tests/msw/schema/types/send-email.types.ts b/tests/msw/schema/types/send-email.types.ts new file mode 100644 index 0000000..5889572 --- /dev/null +++ b/tests/msw/schema/types/send-email.types.ts @@ -0,0 +1,17 @@ +export const sendEmailTypes = `input SendEmailInput { + body: String + clientMutationId: String + from: String + replyTo: String + subject: String + to: String +} + +type SendEmailPayload { + clientMutationId: String + message: String + origin: String + replyTo: String + sent: Boolean + to: String +}`; diff --git a/tests/msw/index.ts b/tests/msw/server.ts index 29504a4..29504a4 100644 --- a/tests/msw/index.ts +++ b/tests/msw/server.ts diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 0ada21c..8542024 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -6,7 +6,7 @@ "./src/**/*", "./tests/**/*", "./commitlint.config.js", - "./cypress.config.js", + "./cypress.config.ts", "./jest.config.js", "./jest.setup.js", "./lint-staged.config.js", @@ -7306,7 +7306,7 @@ dotenv-expand@^10.0.0: resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-10.0.0.tgz#12605d00fb0af6d0a592e6558585784032e4ef37" integrity sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A== -dotenv@^16.0.0: +dotenv@^16.0.0, dotenv@^16.3.1: version "16.3.1" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ== |
