aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-12-11 17:52:38 +0100
committerArmand Philippot <git@armandphilippot.com>2023-12-11 17:52:38 +0100
commit93db24b7f7650abac1bb7095026e3a1f367b0c0a (patch)
treec6efd8669d333941494e573d2468a4fb6603b134
parentcd2cb5748be9e9c479d9802dd3897de1cd1cbd9f (diff)
refactor(pages): refine Contact page
* remove next/router dependency * remove pageTitle since it is defined in MDX * reduce statements by grouping messages * mock response with MSW and add test for sendEmail
-rw-r--r--cypress.config.ts (renamed from cypress.config.js)10
-rw-r--r--jest.setup.js2
-rw-r--r--package.json5
-rw-r--r--public/mockServiceWorker.js292
-rw-r--r--src/i18n/en.json4
-rw-r--r--src/i18n/fr.json4
-rw-r--r--src/pages/contact.tsx88
-rw-r--r--src/services/graphql/mutators/send-email.test.ts23
-rw-r--r--src/services/graphql/mutators/send-email.ts31
-rw-r--r--src/types/index.ts1
-rw-r--r--src/types/swr.ts5
-rw-r--r--tests/cypress/e2e/pages/contact.cy.ts8
-rw-r--r--tests/cypress/support/e2e.ts6
-rw-r--r--tests/cypress/support/msw.ts44
-rw-r--r--tests/msw/browser.ts4
-rw-r--r--tests/msw/handlers/forms/index.ts3
-rw-r--r--tests/msw/handlers/forms/send-email.handler.ts53
-rw-r--r--tests/msw/handlers/index.ts2
-rw-r--r--tests/msw/instances/index.ts3
-rw-r--r--tests/msw/schema/types/index.ts7
-rw-r--r--tests/msw/schema/types/send-email.types.ts17
-rw-r--r--tests/msw/server.ts (renamed from tests/msw/index.ts)0
-rw-r--r--tsconfig.eslint.json2
-rw-r--r--yarn.lock2
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",
diff --git a/yarn.lock b/yarn.lock
index b29589f..3608ecd 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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==