diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..212eb27 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +REACT_APP_DEFAULT_LANGUAGE=en +REACT_APP_API_ENDPOINT= +REACT_APP_API_CLIENT_ID= +REACT_APP_API_CLIENT_SECRET= diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 824e125..93d1992 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,6 +2,11 @@ name: Test on: pull_request +env: + REACT_APP_API_ENDPOINT: ${{ secrets.API_ENDPOINT }} + REACT_APP_API_CLIENT_ID: ${{ secrets.API_CLIENT_ID }} + REACT_APP_API_CLIENT_SECRET: ${{ secrets.API_CLIENT_SECRET }} + jobs: test: name: Run linters and tests @@ -33,6 +38,14 @@ jobs: - name: Run integration tests run: npm run cypress + - name: Upload any cypress failures + uses: actions/upload-artifact@v3 + if: failure() + with: + name: cypress-output + path: cypress/videos + retention-days: 5 + - name: Merge code coverage reports run: npm run test:merge-coverage diff --git a/.gitignore b/.gitignore index e223f6b..d28129b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ # misc .DS_Store +.env .env.local .env.development.local .env.test.local diff --git a/cypress/fixtures/Authentication/invalid-credentials.json b/cypress/fixtures/Authentication/invalid-credentials.json new file mode 100644 index 0000000..c174597 --- /dev/null +++ b/cypress/fixtures/Authentication/invalid-credentials.json @@ -0,0 +1 @@ +{ "errors": [{ "detail": "Your email or password is incorrect. Please try again.", "code": "invalid_email_or_password" }] } diff --git a/cypress/fixtures/Authentication/valid-credentials.json b/cypress/fixtures/Authentication/valid-credentials.json new file mode 100644 index 0000000..b7ce2bf --- /dev/null +++ b/cypress/fixtures/Authentication/valid-credentials.json @@ -0,0 +1,13 @@ +{ + "data": { + "id": "18422", + "type": "token", + "attributes": { + "access_token": "3XwBqiXrM-xhHCc-0QCKsOuE0Xp9AYdYTiZwTSqo4M4", + "token_type": "Bearer", + "expires_in": 7200, + "refresh_token": "QOiwCsTV7Wn4mO56Kz-4rj8nGCWfVMcIwSbKo4yicEk", + "created_at": 1677124678 + } + } +} diff --git a/cypress/integration/Authentication/login.spec.ts b/cypress/integration/Authentication/login.spec.ts new file mode 100644 index 0000000..f6e3ba1 --- /dev/null +++ b/cypress/integration/Authentication/login.spec.ts @@ -0,0 +1,72 @@ +describe('User Authentication', () => { + context('upon navigation to /login', () => { + it('displays login page', () => { + cy.visit('/login'); + + cy.findByTestId('login-header').should('be.visible'); + }); + }); + + // TODO: The below test fail on CI/CD with the use of Intercept + + context('given valid credentials', () => { + it('redirects to the home page', () => { + cy.intercept('POST', '/oauth/token/', { + statusCode: 200, + fixture: 'Authentication/valid-credentials.json', + }); + + cy.visit('/login'); + + cy.get('input[name=email]').type('liam@nimblehq.co'); + cy.get('input[name=password]').type('12345678'); + cy.get('button[type="submit"]').click(); + + cy.location().should((location) => { + expect(location.pathname).to.eq('/'); + }); + }); + }); + + context('given NO credentials entered', () => { + it('shows field validation errors', () => { + cy.visit('/login'); + + cy.get('button[type="submit"]').click(); + + cy.get('.errors').should('be.visible'); + + cy.get('.errors').within(() => { + cy.contains('Email has invalid format'); + cy.contains('Password should be at least'); + }); + }); + }); + + // TODO: The below test fail on CI/CD with the use of Intercept + + context('given INVALID credentials', () => { + it('shows login error', () => { + cy.intercept('POST', '/oauth/token/', { + statusCode: 400, + fixture: 'Authentication/invalid-credentials.json', + }); + + cy.visit('/login'); + + cy.get('input[name=email]').type('testemail@gmail.com'); + cy.get('input[name=password]').type('password123'); + cy.get('button[type="submit"]').click(); + + cy.location().should((location) => { + expect(location.pathname).to.eq('/login'); + }); + + cy.get('.errors').should('be.visible'); + + cy.get('.errors').within(() => { + cy.findByText('Your email or password is incorrect. Please try again.').should('exist'); + }); + }); + }); +}); diff --git a/cypress/integration/UserAuthentication/login.spec.ts b/cypress/integration/UserAuthentication/login.spec.ts deleted file mode 100644 index 651d91d..0000000 --- a/cypress/integration/UserAuthentication/login.spec.ts +++ /dev/null @@ -1,9 +0,0 @@ -describe('User authentication', () => { - context('upon naivation to /login', () => { - it('displays login page', () => { - cy.visit('/login'); - - cy.findByTestId('login-header').should('be.visible'); - }); - }); -}); diff --git a/package-lock.json b/package-lock.json index 57a326b..3a221b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,9 +38,12 @@ "danger": "10.9.0", "danger-plugin-istanbul-coverage": "1.6.2", "eslint": "8.11.0", + "jest-localstorage-mock": "^2.4.26", + "nock": "13.3.0", "postcss": "8.4.21", "postcss-import": "14.1.0", "prettier": "2.6.0", + "prettier-plugin-tailwindcss": "0.2.3", "start-server-and-test": "1.14.0", "stylelint": "14.6.0", "tailwindcss": "3.2.6", @@ -14215,6 +14218,15 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/jest-localstorage-mock": { + "version": "2.4.26", + "resolved": "https://registry.npmjs.org/jest-localstorage-mock/-/jest-localstorage-mock-2.4.26.tgz", + "integrity": "sha512-owAJrYnjulVlMIXOYQIPRCCn3MmqI3GzgfZCXdD3/pmwrIvFMXcKVWZ+aMc44IzaASapg0Z4SEFxR+v5qxDA2w==", + "dev": true, + "engines": { + "node": ">=6.16.0" + } + }, "node_modules/jest-matcher-utils": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", @@ -16752,6 +16764,21 @@ "tslib": "^2.0.3" } }, + "node_modules/nock": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.3.0.tgz", + "integrity": "sha512-HHqYQ6mBeiMc+N038w8LkMpDCRquCHWeNmN3v6645P3NhN2+qXOBqvPqo7Rt1VyCMzKhJ733wZqw5B7cQVFNPg==", + "dev": true, + "dependencies": { + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "lodash": "^4.17.21", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">= 10.13" + } + }, "node_modules/node-cleanup": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/node-cleanup/-/node-cleanup-2.1.2.tgz", @@ -19146,6 +19173,80 @@ "node": ">=6.0.0" } }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.2.3.tgz", + "integrity": "sha512-s2N5Dh7Ao5KTV1mao5ZBnn8EKtUcDPJEkGViZIjI0Ij9TTI5zgTz4IHOxW33jOdjHKa8CSjM88scelUiC5TNRQ==", + "dev": true, + "engines": { + "node": ">=12.17.0" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-php": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@shufo/prettier-plugin-blade": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "prettier": ">=2.2.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*", + "prettier-plugin-twig-melody": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-php": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@shufo/prettier-plugin-blade": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + }, + "prettier-plugin-twig-melody": { + "optional": true + } + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -19264,6 +19365,15 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -34696,6 +34806,12 @@ "pretty-format": "^27.5.1" } }, + "jest-localstorage-mock": { + "version": "2.4.26", + "resolved": "https://registry.npmjs.org/jest-localstorage-mock/-/jest-localstorage-mock-2.4.26.tgz", + "integrity": "sha512-owAJrYnjulVlMIXOYQIPRCCn3MmqI3GzgfZCXdD3/pmwrIvFMXcKVWZ+aMc44IzaASapg0Z4SEFxR+v5qxDA2w==", + "dev": true + }, "jest-matcher-utils": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", @@ -36633,6 +36749,18 @@ "tslib": "^2.0.3" } }, + "nock": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.3.0.tgz", + "integrity": "sha512-HHqYQ6mBeiMc+N038w8LkMpDCRquCHWeNmN3v6645P3NhN2+qXOBqvPqo7Rt1VyCMzKhJ733wZqw5B7cQVFNPg==", + "dev": true, + "requires": { + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "lodash": "^4.17.21", + "propagate": "^2.0.0" + } + }, "node-cleanup": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/node-cleanup/-/node-cleanup-2.1.2.tgz", @@ -38197,6 +38325,13 @@ "fast-diff": "^1.1.2" } }, + "prettier-plugin-tailwindcss": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.2.3.tgz", + "integrity": "sha512-s2N5Dh7Ao5KTV1mao5ZBnn8EKtUcDPJEkGViZIjI0Ij9TTI5zgTz4IHOxW33jOdjHKa8CSjM88scelUiC5TNRQ==", + "dev": true, + "requires": {} + }, "pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -38292,6 +38427,12 @@ } } }, + "propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true + }, "proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/package.json b/package.json index a90b3d3..847d51b 100644 --- a/package.json +++ b/package.json @@ -65,15 +65,19 @@ "danger": "10.9.0", "danger-plugin-istanbul-coverage": "1.6.2", "eslint": "8.11.0", + "jest-localstorage-mock": "2.4.26", + "nock": "13.3.0", "postcss": "8.4.21", "postcss-import": "14.1.0", "prettier": "2.6.0", + "prettier-plugin-tailwindcss": "0.2.3", "start-server-and-test": "1.14.0", "stylelint": "14.6.0", "tailwindcss": "3.2.6", "typescript": "4.6.2" }, "jest": { + "resetMocks": false, "collectCoverageFrom": [ "src/**/*.{js,jsx,ts,tsx}", "!src/**/*.d.ts" diff --git a/public/_redirects b/public/_redirects new file mode 100644 index 0000000..4aa7faf --- /dev/null +++ b/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 8f819dd..332ce1f 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -1,8 +1,11 @@ { "login": { - "sign_in": "Sign in", + "sign-in": "Sign in", "email": "Email", "password": "Password", - "forgot_password": "Forgot?" + "forgot-password": "Forgot?", + "invalid-email": "Email has invalid format", + "invalid-password": "Password should be at least {{passwordMinLength}}", + "generic-server-error": "There was a problem receiving a response from the server" } } diff --git a/src/App.tsx b/src/App.tsx index 0586ea2..2859b18 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,7 +10,7 @@ const App = (): JSX.Element => { return (
-
{appRoutes}
+
{appRoutes}
); }; diff --git a/src/adapters/authAdapter.test.ts b/src/adapters/authAdapter.test.ts new file mode 100644 index 0000000..a863c21 --- /dev/null +++ b/src/adapters/authAdapter.test.ts @@ -0,0 +1,42 @@ +import nock from 'nock'; + +import AuthAdapter from './authAdapter'; + +/* eslint-disable camelcase */ +const testCredentials = { + email: 'testemail@gmail.com', + password: 'password123', +}; + +const commonLoginParams = { + grant_type: 'password', + client_id: process.env.REACT_APP_API_CLIENT_ID, + client_secret: process.env.REACT_APP_API_CLIENT_SECRET, +}; +/* eslint-enable camelcase */ + +describe('AuthAdapter', () => { + afterAll(() => { + nock.cleanAll(); + nock.restore(); + }); + + describe('login', () => { + test('The login endpoint is called with credientials from the request', async () => { + const scope = nock(`${process.env.REACT_APP_API_ENDPOINT}`) + .defaultReplyHeaders({ + 'access-control-allow-origin': '*', + 'access-control-allow-credentials': 'true', + }) + .post('/oauth/token', { + ...testCredentials, + ...commonLoginParams, + }) + .reply(200); + + expect(scope.isDone()).toBe(false); + await AuthAdapter.login({ ...testCredentials }); + expect(scope.isDone()).toBe(true); + }); + }); +}); diff --git a/src/adapters/authAdapter.ts b/src/adapters/authAdapter.ts new file mode 100644 index 0000000..cee734a --- /dev/null +++ b/src/adapters/authAdapter.ts @@ -0,0 +1,23 @@ +import BaseAdapter from './baseAdapter.'; + +type LoginAuthType = { + email: string; + password: string; +}; + +class AuthAdapter extends BaseAdapter { + static login(params: LoginAuthType) { + /* eslint-disable camelcase */ + const requestParams = { + ...params, + grant_type: 'password', + client_id: process.env.REACT_APP_API_CLIENT_ID, + client_secret: process.env.REACT_APP_API_CLIENT_SECRET, + }; + /* eslint-enable camelcase */ + + return this.prototype.postRequest('oauth/token', { data: requestParams }); + } +} + +export default AuthAdapter; diff --git a/src/adapters/baseAdapter..ts b/src/adapters/baseAdapter..ts new file mode 100644 index 0000000..2cf9ff3 --- /dev/null +++ b/src/adapters/baseAdapter..ts @@ -0,0 +1,15 @@ +import requestManager, { RequestParamsType } from 'lib/requestManager'; + +class BaseAdapter { + static baseUrl = process.env.REACT_APP_API_ENDPOINT; + + getRequest(endpoint: string, requestOption: RequestParamsType) { + return requestManager('GET', `${BaseAdapter.baseUrl}/${endpoint}`, requestOption); + } + + postRequest(endpoint: string, requestOption: RequestParamsType) { + return requestManager('POST', `${BaseAdapter.baseUrl}/${endpoint}`, requestOption); + } +} + +export default BaseAdapter; diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index c1722dd..75ff688 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -2,14 +2,20 @@ import styles from './Button.module.scss'; type ButtonProps = { text: string; - type: 'button' | 'submit' | 'reset'; + type?: 'button' | 'submit' | 'reset'; className?: string; - onButtonClick: () => void; + disabled?: boolean; + onButtonClick?: () => void; }; -function Button({ text, type, className, onButtonClick }: ButtonProps) { +function Button({ text, type, className, disabled, onButtonClick }: ButtonProps) { return ( - ); diff --git a/src/components/Input/Input.module.scss b/src/components/Input/Input.module.scss index 41f6d52..e127d8c 100644 --- a/src/components/Input/Input.module.scss +++ b/src/components/Input/Input.module.scss @@ -1,4 +1,6 @@ .input { + padding-left: 0.5rem; + border-radius: 12px; background: rgba(255, 255, 255, 0.18); diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx index 99ee71a..a217769 100644 --- a/src/components/Input/index.tsx +++ b/src/components/Input/index.tsx @@ -6,16 +6,17 @@ type InputProps = { name: string; label: string; type?: HTMLInputTypeAttribute; + value?: string; className?: string; - onInputChange: (e: React.ChangeEvent | React.ChangeEvent) => void; + onInputChange: (e: React.ChangeEvent) => void; }; -function Input({ name, label, type, className, onInputChange }: InputProps) { +function Input({ name, label, type, value, className, onInputChange }: InputProps) { return ( <> -