diff --git a/src/components/Objects/Input/Input.jsx b/src/components/Objects/Input/Input.jsx index adce56d..f7d7af2 100644 --- a/src/components/Objects/Input/Input.jsx +++ b/src/components/Objects/Input/Input.jsx @@ -1,21 +1,6 @@ -import React from "react"; - -export default function Input({ - className, - label, - onValueChange, - errorMessage, - type='text' -}) { - - /************************************ - * Helper Functions - ************************************/ - - function handleChange(event) { - onValueChange(event.target.value); - } +import React from 'react'; +export default function Input({ className, label, onValueChange, errorMessage, type = 'text', ...rest }) { /************************************ * Render ************************************/ @@ -23,12 +8,12 @@ export default function Input({ return ( <> ); -}; +} diff --git a/src/constants/authentication-constants.js b/src/constants/authentication-constants.js deleted file mode 100644 index 97c4f6f..0000000 --- a/src/constants/authentication-constants.js +++ /dev/null @@ -1,7 +0,0 @@ -export const SUCCESS = "SUCCESS"; - -export const ERROR_INVALID_EMAIL = "You have entered an invalid email address"; -export const ERROR_EMPTY_EMAIL = "You must enter an email address"; -export const ERROR_EMPTY_PASSWORD = "You must enter a password"; -export const ERROR_EMPTY_REENTERED_PASSWORD = "You must reenter the password"; -export const ERROR_PASSWORDS_DONT_MATCH = "Entered passwords are not the same"; diff --git a/src/constants/validation-constants.js b/src/constants/validation-constants.js new file mode 100644 index 0000000..9cb9e45 --- /dev/null +++ b/src/constants/validation-constants.js @@ -0,0 +1,29 @@ +import { Schema } from '../validation'; + +const EMAIL_PROPERTY = 'email'; +const PASSWORD_PROPERTY = 'password'; +const REQUIRED_PASSWORD_LENGTH = 8; +const CONFIRMED_PASSWORD_PROPERTY = 'confirmedPassword'; + +export const LOGIN_CONSTANTS = { + EMAIL_PROPERTY, + PASSWORD_PROPERTY, + EMAIL_SCHEMA: new Schema().isRequired(), + PASSWORD_SCHEMA: new Schema().isRequired() +}; + +export const REGISTER_CONSTANTS = { + EMAIL_PROPERTY, + PASSWORD_PROPERTY, + REQUIRED_PASSWORD_LENGTH, + CONFIRMED_PASSWORD_PROPERTY, + EMAIL_SCHEMA: new Schema().isEmail().isRequired(), + CONFIRMED_PASSWORD_SCHEMA: new Schema().isRequired().matches(PASSWORD_PROPERTY), + PASSWORD_SCHEMA: new Schema() + .hasDigit() + .hasSymbol() + .isRequired() + .hasLowercase() + .hasUppercase() + .min(REQUIRED_PASSWORD_LENGTH) +}; diff --git a/src/hooks/__tests__/useForm.test.js b/src/hooks/__tests__/useForm.test.js new file mode 100644 index 0000000..71f4912 --- /dev/null +++ b/src/hooks/__tests__/useForm.test.js @@ -0,0 +1,68 @@ +import { renderHook, act } from '@testing-library/react-hooks'; + +import useForm from '../useForm'; +import { Schema } from '../../validation'; + +const LAST_NAME = 'last'; +const FIRST_NAME = 'first'; +const EVENT = { + target: { name: FIRST_NAME, value: 'def' }, + preventDefault: jest.fn() +}; + +const VALID_FORM = { [FIRST_NAME]: 'abcd', [LAST_NAME]: 'abcd' }; +const DEFAULT_STATE = { [FIRST_NAME]: '', [LAST_NAME]: '' }; + +const DEFAULT_SCHEMA = { + [FIRST_NAME]: new Schema().min(4).isRequired(), + last: new Schema() +}; + +describe('useForm hook', () => { + function setup(schema = DEFAULT_SCHEMA, state = DEFAULT_STATE) { + const { result } = renderHook(() => useForm(schema, state)); + + return result; + } + + test('should set no errors by default and form data from state', () => { + const result = setup(); + + expect(result.current.errors).toStrictEqual({}); + expect(result.current.form).toStrictEqual(DEFAULT_STATE); + }); + + test('should set proper element value', () => { + const result = setup(); + + act(() => { + result.current.handleInputChange(EVENT); + }); + + expect(result.current.form[FIRST_NAME]).toBe(EVENT.target.value); + }); + + test('should call user submit callback when form is valid', () => { + const result = setup(undefined, VALID_FORM); + const submitCallback = jest.fn(); + + act(() => { + result.current.handleSubmit(submitCallback, EVENT); + }); + + expect(submitCallback).toHaveBeenCalled(); + expect(result.current.errors).toEqual({}); + expect(result.current.submitError).toBe(''); + }); + + test('should set submitErrorMessage when error occurs while submitting valid form', () => { + const result = setup(undefined, VALID_FORM); + const submitCallback = {}; // Will throw when called by handleSubmit + + act(() => { + result.current.handleSubmit(submitCallback, EVENT); + }); + + expect(result.current.submitError).not.toBeNull(); + }); +}); diff --git a/src/hooks/useForm.js b/src/hooks/useForm.js new file mode 100644 index 0000000..efaafa0 --- /dev/null +++ b/src/hooks/useForm.js @@ -0,0 +1,218 @@ +import { useState } from 'react'; + +import { validate } from '../validation'; +import { EMPTY_VALUE } from '../validation/constants'; + +/** + * The useForm hook return value. + * @typedef {object} UseFormReturnValue + * @property {Object.} form - The values of the properties in the form. + * @property {Object.} errors - The errors in the form. + * @property {string} submitError - The error message when the form fails to submit successfully. + * @property {Function} handleReset - Function to reset the form to its initial state. + * @property {Function} handleSubmit - Function to validate the form and call the user callback. + * @property {Function} handleInputChange - Function to validate changed input and set the state. + */ + +/** + * The useForm state. + * @typedef {object} UseFormState + * @property {Object.} form - The values of the properties in the form. + * @property {Object.} errors - The errors in the form. + * @property {string} submitError - The error message when the form fails to submit successfully. + */ + +/** + * Handle form functionality. + * @class + * @param {Function} schema - The schema of the form. + * @param {object} [initialFormState=null] - The default values for form elements. + * @returns {UseFormReturnValue} The form, errors and handlers. + * + * @example + * const { + * form, + * errors, + * submitError, + * handleReset, + * handleSubmit, + * handleInputChange + * } = useForm(formSchema); + */ +function useForm(schema, initialFormState = null) { + /************************************ + * State + ************************************/ + + const [state, setState] = useState(init(schema, initialFormState)); + + /************************************ + * Helper Functions + ************************************/ + + /** + * Handles validating form and calling a user provided function when form is valid. + * @async + * @param {Function} submitForm - The user function to call on submit. + * @param {Event} [event=null] - The form submit event. + * @returns {Promise} Nothing. + */ + async function handleSubmit(submitForm, event = null) { + if (event) event.preventDefault(); + + // Validate and return errors + const { isValid, errors: validationErrors } = validate(state.form, schema); + if (!isValid) { + setState({ + ...state, + errors: { + ...validationErrors + } + }); + return; + } + + try { + await submitForm(); + } catch (error) { + // Note that the state will NOT be set if the error is + // caught inside the callback (submitForm) and not re-thrown + setState({ + ...state, + submitError: error.message + }); + } + } + + /** + * Reset the form to its initial value. + * @returns {void} Nothing. + */ + function handleReset() { + setState(init(schema, initialFormState)); + } + + /** + * Set the new value onchange, and validate property or matching properties. + * @param {Event} event - The onChange event. + * @returns {void} Nothing. + */ + function handleInputChange(event) { + const { form } = state; + const { value, name } = event.target; + + // Ah, the good old days! + setState({ + ...state, + form: { + ...form, + [name]: value + }, + errors: { + ...validateProperty(name, value) + } + }); + } + + /** + * Validate one or pair of corresponding properties. + * @param {string} name - The property name. + * @param {string} value - The property value. + * @returns {object} All the errors in the entire form. + */ + function validateProperty(name, value) { + const { errors, form } = state; + let allErrors = { + ...errors + }; + const matchingProperty = getMatchingProperty(name, schema); + + // No matching property, just validate this one property + if (!matchingProperty) { + const { isValid, errors: propertyErrors } = validate(value, schema[name]); + + isValid + ? delete allErrors[name] + : (allErrors = { + ...allErrors, + [name]: propertyErrors + }); + + return { ...allErrors }; + } + + // Matching properties present. ex: password & confirm password + const matchingValues = { + [name]: value, + [matchingProperty]: form[matchingProperty] + }; + + const matchingValuesSchema = { + [name]: schema[name], + [matchingProperty]: schema[matchingProperty] + }; + + // Clear previous errors on matching properties before + // potentially re-setting them + delete allErrors[name]; + delete allErrors[matchingProperty]; + + const { errors: propertyErrors } = validate(matchingValues, matchingValuesSchema); + + return { + ...allErrors, + ...propertyErrors + }; + } + + return { + ...state, + handleReset, + handleSubmit, + handleInputChange + }; +} + +/** + * Derive state from the given schema. + * @param {object} schema - The given schema. + * @param {object} initialFormState - The initial values of the form properties. + * @returns {...UseFormState} The useForm initial state. + */ +function init(schema, initialFormState) { + let form = initialFormState; + + if (!form) { + form = {}; + for (const property in schema) { + if (schema.hasOwnProperty(property)) { + form[property] = EMPTY_VALUE; + } + } + } + return { form, errors: {}, submitError: EMPTY_VALUE }; +} + +/** + * Get the corresponding property that matches the + * current property being validated. + * + * @param {string} name - The property being validated. + * @param {object} schema - The schema of the entire form. + * @return {string} The name of the matching property. + */ +function getMatchingProperty(name, schema) { + const { matchingProperty } = schema[name]; + + if (matchingProperty) return matchingProperty; + + for (const property in schema) { + // Don't bother comparing if it's the current property's schema + if (property === name) continue; + + // Find and return the matching property + if (schema.hasOwnProperty(property) && schema[property].matchingProperty === name) return property; + } +} + +export default useForm; diff --git a/src/pages/LoginView.jsx b/src/pages/LoginView.jsx index e683389..0be4665 100644 --- a/src/pages/LoginView.jsx +++ b/src/pages/LoginView.jsx @@ -1,11 +1,23 @@ -import React, { useState } from 'react'; +import React from 'react'; import { Redirect, withRouter } from 'react-router-dom'; import Input from '../components/Objects/Input/Input'; +import useForm from '../hooks/useForm'; import homeLogo from '../images/homeLogo.png'; import { login } from '../services/authService'; import { useUser } from '../contexts/UserContext'; -import { ERROR_EMPTY_EMAIL, ERROR_EMPTY_PASSWORD } from '../constants/authentication-constants'; +import { LOGIN_CONSTANTS } from '../constants/validation-constants'; + +/************************************ + * Constants + ************************************/ + +const { EMAIL_PROPERTY, PASSWORD_PROPERTY, EMAIL_SCHEMA, PASSWORD_SCHEMA } = LOGIN_CONSTANTS; + +const FORM_SCHEMA = { + [EMAIL_PROPERTY]: EMAIL_SCHEMA, + [PASSWORD_PROPERTY]: PASSWORD_SCHEMA +}; function LoginView(props) { /************************************ @@ -13,72 +25,19 @@ function LoginView(props) { ************************************/ const [user, setUser] = useUser(); - const [errorMessage, setErrorMessage] = useState(null); - - const [email, setEmail] = useState(''); - const [emailErrorMessage, setEmailErrorMessage] = useState(null); - const [password, setPassword] = useState(''); - const [passwordErrorMessage, setPasswordErrorMessage] = useState(null); + const { form, errors, submitError, handleSubmit, handleInputChange } = useForm(FORM_SCHEMA); /************************************ * Helper Functions ************************************/ - /* Handler for log in button clicked, if valid inputs logs in user - */ - async function loginClicked() { - if (validateInput()) { - try { - // Ideally login should handle setting the user - const userData = await login(email, password); - setUser(userData); - - // Make sure that userData is safely stored since this - // does a *full* reload - window.location = props.location.state?.referer || '/'; - } catch (error) { - // This assumes that the server returns custom validation errors - // 500 errors should be handled and "prettied" in the httpService - setErrorMessage(error.message); - } - } - } - - /* Validates inputs, if invalid then it will display an error message - */ - function validateInput() { - handleEmailValidationResponse(email); - handlePasswordValidationResponse(password); - - return email !== '' && password !== ''; - } - - function emailValidationCheck(email) { - setEmail(email); - handleEmailValidationResponse(email); - } + async function submitForm() { + const userData = await login(form[EMAIL_PROPERTY], form[PASSWORD_PROPERTY]); + setUser(userData); - function passwordValidationCheck(password) { - setPassword(password); - handlePasswordValidationResponse(password); - } - - /* Takes in validation response of email and sets based on success or not - * - * @param response - */ - function handleEmailValidationResponse(input) { - const message = input === '' ? ERROR_EMPTY_EMAIL : null; - setEmailErrorMessage(message); - } - - /* Takes in validation response of password and sets based on success or not - * - * @param response - */ - function handlePasswordValidationResponse(input) { - const message = input === '' ? ERROR_EMPTY_PASSWORD : null; - setPasswordErrorMessage(message); + // Make sure that userData is safely stored since this + // does a *full* reload + window.location = props.location.state?.referer || '/'; } /************************************ @@ -90,26 +49,30 @@ function LoginView(props) {
oneleif logo -
+
handleSubmit(submitForm, event)} className='form-container'> emailValidationCheck(email)} - errorMessage={emailErrorMessage} + className='auth' + autoComplete='username' + errorMessage={errors[EMAIL_PROPERTY]} + onValueChange={handleInputChange} /> passwordValidationCheck(password)} - errorMessage={passwordErrorMessage} + name={PASSWORD_PROPERTY} + label='Password' + className='auth' + autoComplete='current-password' + errorMessage={errors[PASSWORD_PROPERTY]} + onValueChange={handleInputChange} />
Forgot your password? - +
-
- {errorMessage &&

{errorMessage}

} + + {submitError &&

{submitError}

}
); diff --git a/src/pages/RegisterView.jsx b/src/pages/RegisterView.jsx index fe07f43..659e77a 100644 --- a/src/pages/RegisterView.jsx +++ b/src/pages/RegisterView.jsx @@ -1,12 +1,30 @@ import React, { useState } from 'react'; import { Link, Redirect, withRouter } from 'react-router-dom'; -import { validateEmail, validatePassword, validateReenteredPassword } from '../utils/authentication-utils'; -import { SUCCESS } from '../constants/authentication-constants'; -import { register } from '../services/authService.js'; - import Input from '../components/Objects/Input/Input'; +import useForm from '../hooks/useForm'; import homeLogo from '../images/homeLogo.png'; +import { register } from '../services/authService.js'; +import { REGISTER_CONSTANTS } from '../constants/validation-constants'; + +/************************************ + * Constants + ************************************/ + +const { + EMAIL_SCHEMA, + EMAIL_PROPERTY, + PASSWORD_SCHEMA, + PASSWORD_PROPERTY, + CONFIRMED_PASSWORD_SCHEMA, + CONFIRMED_PASSWORD_PROPERTY +} = REGISTER_CONSTANTS; + +const FORM_SCHEMA = { + [EMAIL_PROPERTY]: EMAIL_SCHEMA, + [PASSWORD_PROPERTY]: PASSWORD_SCHEMA, + [CONFIRMED_PASSWORD_PROPERTY]: CONFIRMED_PASSWORD_SCHEMA +}; function RegisterView() { /************************************ @@ -14,89 +32,15 @@ function RegisterView() { ************************************/ const [isRegistered, setIsRegistered] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - - const [email, setEmail] = useState(''); - const [emailErrorMessage, setEmailErrorMessage] = useState(null); - const [password, setPassword] = useState(''); - const [passwordErrorMessage, setPasswordErrorMessage] = useState(null); - const [reenteredPassword, setReenteredPassword] = useState(''); - const [reenteredPasswordErrorMessage, setReenteredPasswordErrorMessage] = useState(null); + const { form, errors, handleSubmit, handleInputChange, submitError } = useForm(FORM_SCHEMA); /************************************ - * Private Functions + * Helper Functions ************************************/ - /* Handler for sign up button clicked, if valid inputs registers user - */ - async function registerClicked() { - if (validateInput()) { - try { - await register(email, password); - setIsRegistered(true); - } catch (error) { - setErrorMessage(error.message); - } - } - } - - /* Validates inputs, if invalid then it will display an error message - */ - function validateInput() { - const emailResponse = validateEmail(email); - const passwordResponse = validatePassword(password); - const reenteredPasswordResponse = validateReenteredPassword(password, reenteredPassword); - - handleEmailValidationResponse(emailResponse); - handlePasswordValidationResponse(passwordResponse); - handleReenteredPasswordValidationResponse(reenteredPasswordResponse); - - return emailResponse === SUCCESS && passwordResponse === SUCCESS && reenteredPasswordResponse === SUCCESS; - } - - function emailValidationCheck(email) { - setEmail(email); - const response = validateEmail(email); - handleEmailValidationResponse(response); - } - - function passwordValidationCheck(password) { - setPassword(password); - const response = validatePassword(password); - handlePasswordValidationResponse(validatePassword(response)); - } - - function reenteredPasswordValidationCheck(reenteredPassword) { - setReenteredPassword(reenteredPassword); - const response = validateReenteredPassword(password, reenteredPassword); - handleReenteredPasswordValidationResponse(response); - } - - /* Takes in validation response of email and sets based on success or not - * - * @param response - */ - function handleEmailValidationResponse(response) { - const message = response !== SUCCESS ? response : null; - setEmailErrorMessage(message); - } - - /* Takes in validation response of password and sets based on success or not - * - * @param response - */ - function handlePasswordValidationResponse(response) { - const message = response !== SUCCESS ? response : null; - setPasswordErrorMessage(message); - } - - /* Takes in validation response of reentered password and sets based on success or not - * - * @param response - */ - function handleReenteredPasswordValidationResponse(response) { - const message = response !== SUCCESS ? response : null; - setReenteredPasswordErrorMessage(message); + async function submitForm() { + await register(form[EMAIL_PROPERTY], form[PASSWORD_PROPERTY]); + setIsRegistered(true); } /************************************ @@ -107,33 +51,40 @@ function RegisterView() {
oneleif logo -
+
handleSubmit(submitForm, event)} className='form-container'> emailValidationCheck(email)} - errorMessage={emailErrorMessage} + className='auth' + autoComplete='email' + errorMessage={errors[EMAIL_PROPERTY]} + onValueChange={handleInputChange} /> passwordValidationCheck(password)} - errorMessage={passwordErrorMessage} + name={PASSWORD_PROPERTY} + label='Password' + className='auth' + autoComplete='new-password' + errorMessage={errors[PASSWORD_PROPERTY]?.[0]} // TODO: change display + onValueChange={handleInputChange} /> reenteredPasswordValidationCheck(password)} - errorMessage={reenteredPasswordErrorMessage} + name={CONFIRMED_PASSWORD_PROPERTY} + label='Reenter Password' + className='auth' + autoComplete='new-password' + errorMessage={errors[CONFIRMED_PASSWORD_PROPERTY]?.[0]} // TODO: change display + onValueChange={handleInputChange} />
Already have an account? - + {/* TODO: Disable button if there is an error in form */} +
-
- {errorMessage &&

{errorMessage}

} + + {submitError &&

{submitError}

}
{isRegistered && }
diff --git a/src/pages/__tests__/LoginView.test.jsx b/src/pages/__tests__/LoginView.test.jsx index d74914c..affb130 100644 --- a/src/pages/__tests__/LoginView.test.jsx +++ b/src/pages/__tests__/LoginView.test.jsx @@ -1,19 +1,8 @@ import React from 'react'; -import { - act, - clickEventByText, - fireEvent, - queryByLabelText, - queryByText, - renderWithRouter, -} from 'test-utils'; - import LoginView from '../LoginView'; -import { - ERROR_EMPTY_EMAIL, - ERROR_EMPTY_PASSWORD -} from '../../constants/authentication-constants'; +import { VALIDATION_ERROR_MESSAGES as Messages } from '../../validation/constants'; +import { act, fireChangeEvent, fireEvent, renderWithRouter } from 'test-utils'; /************************************ * Constants @@ -22,46 +11,58 @@ import { const VALID_EMAIL = 'test1@gmail.com'; const VALID_PASSWORD = 'Test123!'; -describe("Login View Component Tests", function() { - let renderedComponent; +describe('Login View Component Tests', function() { + function setup() { + return renderWithRouter(); + } - beforeEach(() => { - renderedComponent = renderWithRouter(); - }); + test('initial render, login inputs should be in view', () => { + const { getAllByLabelText } = setup(); + + const inputs = getAllByLabelText(/input/i); + expect(inputs.length).toBe(2); - test("initial render, registration inputs should be in view", () => { - const inputs = queryByLabelText(renderedComponent.container, 'Email'); - expect(inputs).toBeInTheDocument(); + inputs.forEach(element => { + expect(element).toBeInTheDocument(); }); + }); - test("Valid inputs entered, fetch should be called", async () => { - const emailInput = queryByLabelText(renderedComponent.container, 'Email-input'); - const passwordInput = queryByLabelText(renderedComponent.container, 'Password-input'); + test('Valid inputs entered, fetch should be called', async () => { + const { getByLabelText, getByText } = setup(); - fireEvent.change(emailInput, { target: { value: VALID_EMAIL} }) - fireEvent.change(passwordInput, { target: { value: VALID_PASSWORD } }) + const emailInput = getByLabelText(/email/i); + const passwordInput = getByLabelText(/^password$/i); - const mockSuccessResponse = {}; - const mockJsonPromise = Promise.resolve(mockSuccessResponse); - const mockFetchPromise = Promise.resolve({ - json: () => mockJsonPromise, - }); + fireChangeEvent(emailInput, VALID_EMAIL); + fireChangeEvent(passwordInput, VALID_PASSWORD); - jest.spyOn(global, 'fetch').mockImplementation(() => mockFetchPromise); + const mockSuccessResponse = {}; + const mockJsonPromise = Promise.resolve(mockSuccessResponse); + const mockFetchPromise = Promise.resolve({ + json: () => mockJsonPromise + }); - await act(async () => { - clickEventByText(renderedComponent.container,'Log in'); - }); + jest.spyOn(global, 'fetch').mockImplementation(() => mockFetchPromise); - expect(global.fetch).toHaveBeenCalledTimes(1); + await act(async () => { + fireEvent.click(getByText(/log in/i)); }); - test("Inputs not entered, error messages should be displayed", () => { - clickEventByText(renderedComponent.container,'Log in'); - const emailErrorMessage = queryByText(renderedComponent.container, ERROR_EMPTY_EMAIL); - const passwordErrorMessage = queryByText(renderedComponent.container, ERROR_EMPTY_PASSWORD); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + test('Inputs not entered, error messages should be displayed', () => { + const { getAllByText, getByText } = setup(); + + fireEvent.click(getByText(/log in/i)); - expect(emailErrorMessage).toBeInTheDocument(); - expect(passwordErrorMessage).toBeInTheDocument(); + // TODO: should setup actual messages to be used; + // every time the error messages change, the tests have + // to be modified. Flimsy + const errorMessages = getAllByText(Messages.REQUIRED); + expect(errorMessages.length).toBe(2); + errorMessages.forEach(message => { + expect(message).toBeInTheDocument(); }); + }); }); diff --git a/src/pages/__tests__/RegisterView.test.jsx b/src/pages/__tests__/RegisterView.test.jsx index 14604ec..ba3548c 100644 --- a/src/pages/__tests__/RegisterView.test.jsx +++ b/src/pages/__tests__/RegisterView.test.jsx @@ -1,21 +1,9 @@ import React from 'react'; -import { - act, - clickEventByText, - fireEvent, - queryByLabelText, - queryByText, - renderWithRouter, -} from 'test-utils'; +import { VALIDATION_ERROR_MESSAGES as Messages } from '../../validation/constants'; +import { act, fireEvent, fireChangeEvent, renderWithRouter } from 'test-utils'; import RegisterView from '../RegisterView'; -import { - ERROR_EMPTY_EMAIL, - ERROR_EMPTY_PASSWORD, - ERROR_EMPTY_REENTERED_PASSWORD, - ERROR_INVALID_EMAIL -} from '../../constants/authentication-constants'; /************************************ * Constants @@ -25,59 +13,70 @@ const VALID_EMAIL = 'test1@gmail.com'; const VALID_PASSWORD = 'Test123!'; const INVALID_EMAIL = 'test'; -describe("Register View Component Tests", function() { - let renderedComponent; +describe('Register View Component Tests', function() { + function setup() { + return renderWithRouter(); + } - beforeEach(() => { - renderedComponent = renderWithRouter(); - }); + test('initial render, registration inputs should be in view', () => { + const { getAllByLabelText } = setup(); + const inputs = getAllByLabelText(/input/i); + + expect(inputs.length).toBe(3); - test("initial render, registration inputs should be in view", () => { - const inputs = queryByLabelText(renderedComponent.container, 'Email'); - expect(inputs).toBeInTheDocument(); + inputs.forEach(element => { + expect(element).toBeInTheDocument(); }); + }); - test("Invalid email typed, error message should be shown", () => { - const emailInput = queryByLabelText(renderedComponent.container, 'Email-input'); + test('Invalid email typed, error message should be shown', () => { + const { getByLabelText, getByText } = setup(); + const emailInput = getByLabelText(/email/i); - fireEvent.change(emailInput, { target: { value: INVALID_EMAIL} }); + fireChangeEvent(emailInput, INVALID_EMAIL); - const emailErrorMessage = queryByText(renderedComponent.container, ERROR_INVALID_EMAIL); - expect(emailErrorMessage).toBeInTheDocument(); - }); + const emailErrorMessage = getByText(/email address/i); + expect(emailErrorMessage).toBeInTheDocument(); + }); - test("Valid inputs entered, fetch should be called", async () => { - const emailInput = queryByLabelText(renderedComponent.container, 'Email-input'); - const passwordInput = queryByLabelText(renderedComponent.container, 'Password-input'); - const reenteredPasswordInput = queryByLabelText(renderedComponent.container, 'Reenter Password-input'); + test('Valid inputs entered, fetch should be called', async () => { + const { getByLabelText, getByText } = setup(); - fireEvent.change(emailInput, { target: { value: VALID_EMAIL} }); - fireEvent.change(passwordInput, { target: { value: VALID_PASSWORD } }); - fireEvent.change(reenteredPasswordInput, { target: { value: VALID_PASSWORD } }); + const emailInput = getByLabelText(/email/i); + const passwordInput = getByLabelText(/^password$/i); + const reenteredPasswordInput = getByLabelText(/reenter/i); - const mockSuccessResponse = {}; - const mockJsonPromise = Promise.resolve(mockSuccessResponse); - const mockFetchPromise = Promise.resolve({ - json: () => mockJsonPromise, - }); + fireChangeEvent(emailInput, VALID_EMAIL); + fireChangeEvent(passwordInput, VALID_PASSWORD); + fireChangeEvent(reenteredPasswordInput, VALID_PASSWORD); - jest.spyOn(global, 'fetch').mockImplementation(() => mockFetchPromise); + const mockSuccessResponse = {}; + const mockJsonPromise = Promise.resolve(mockSuccessResponse); + const mockFetchPromise = Promise.resolve({ + json: () => mockJsonPromise + }); - await act(async () => { - clickEventByText(renderedComponent.container,'Sign up'); - }); + jest.spyOn(global, 'fetch').mockImplementation(() => mockFetchPromise); - expect(global.fetch).toHaveBeenCalledTimes(1); + await act(async () => { + fireEvent.click(getByText(/sign up/i)); }); - test("Inputs not entered, error messages should be displayed", () => { - clickEventByText(renderedComponent.container,'Sign up'); - const emailErrorMessage = queryByText(renderedComponent.container, ERROR_EMPTY_EMAIL); - const passwordErrorMessage = queryByText(renderedComponent.container, ERROR_EMPTY_PASSWORD); - const reenteredPasswordErrorMessage = queryByText(renderedComponent.container, ERROR_EMPTY_REENTERED_PASSWORD); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + test('Inputs not entered, error messages should be displayed', () => { + const { getAllByText, getByText } = setup(); + + fireEvent.click(getByText(/sign up/i)); - expect(emailErrorMessage).toBeInTheDocument(); - expect(passwordErrorMessage).toBeInTheDocument(); - expect(reenteredPasswordErrorMessage).toBeInTheDocument(); + // TODO: should setup actual messages to be used; + // every time the error messages change, the tests have + // to be modified. Flimsy + const errorMessages = getAllByText(Messages.REQUIRED); + expect(errorMessages.length).toBe(3); + errorMessages.forEach(message => { + expect(message).toBeInTheDocument(); }); + }); }); diff --git a/src/utils/__tests__/authentication-utils.test.js b/src/utils/__tests__/authentication-utils.test.js deleted file mode 100644 index 3de6ee1..0000000 --- a/src/utils/__tests__/authentication-utils.test.js +++ /dev/null @@ -1,75 +0,0 @@ -import { - validateEmail, - validatePassword, - validateReenteredPassword -} from "../authentication-utils"; - -import { - SUCCESS, - ERROR_EMPTY_EMAIL, - ERROR_INVALID_EMAIL, - ERROR_EMPTY_PASSWORD, - ERROR_EMPTY_REENTERED_PASSWORD, - ERROR_PASSWORDS_DONT_MATCH -} from "../../constants/authentication-constants"; - -/************************************ - * Constants - ************************************/ - -const EMPTY_VALUE = ""; -const INVALID_EMAIL = "test"; -const VALID_EMAIL = "test@mail.com"; -const VALID_PASSWORD = "Test123!"; -const DIFF_PASSWORD = "Test1234!"; - -describe("Authentication Utilities Tests", function() { - test("valid email entered, should return successful", () => { - const validationResponse = validateEmail(VALID_EMAIL); - expect(validationResponse).toEqual(SUCCESS); - }); - - test("empty email entered, should return empty email response", () => { - const validationResponse = validateEmail(EMPTY_VALUE); - expect(validationResponse).toEqual(ERROR_EMPTY_EMAIL); - }); - - test("invalid email entered, should return invalid email response", () => { - const validationResponse = validateEmail(INVALID_EMAIL); - expect(validationResponse).toEqual(ERROR_INVALID_EMAIL); - }); - - test("valid password entered, should return successful", () => { - const validationResponse = validatePassword(VALID_PASSWORD); - expect(validationResponse).toEqual(SUCCESS); - }); - - test("empty password entered, should return empty password response", () => { - const validationResponse = validatePassword(EMPTY_VALUE); - expect(validationResponse).toEqual(ERROR_EMPTY_PASSWORD); - }); - - test("valid password and reentered password entered, should return successful", () => { - const validationResponse = validateReenteredPassword( - VALID_PASSWORD, - VALID_PASSWORD - ); - expect(validationResponse).toEqual(SUCCESS); - }); - - test("empty reentered password entered, should return empty reentered password response", () => { - const validationResponse = validateReenteredPassword( - VALID_PASSWORD, - EMPTY_VALUE - ); - expect(validationResponse).toEqual(ERROR_EMPTY_REENTERED_PASSWORD); - }); - - test("different reentered password entered, should return passwords don't match response", () => { - const validationResponse = validateReenteredPassword( - VALID_PASSWORD, - DIFF_PASSWORD - ); - expect(validationResponse).toEqual(ERROR_PASSWORDS_DONT_MATCH); - }); -}); diff --git a/src/utils/authentication-utils.js b/src/utils/authentication-utils.js deleted file mode 100644 index f3baa4d..0000000 --- a/src/utils/authentication-utils.js +++ /dev/null @@ -1,54 +0,0 @@ -import { - SUCCESS, - ERROR_EMPTY_EMAIL, - ERROR_INVALID_EMAIL, - ERROR_EMPTY_PASSWORD, - ERROR_EMPTY_REENTERED_PASSWORD, - ERROR_PASSWORDS_DONT_MATCH -} from "../constants/authentication-constants"; - -/* Validates passed in email, makes sure input wasn't empty - * or the string matches an email format - * - * @param email - * @return {String} - SUCCESS or error message - */ -export function validateEmail(email) { - if (email === "") { - return ERROR_EMPTY_EMAIL; - } else if (/^\w+([-]?\w+)*@\w+([-]?\w+)*(\.\w{2,3})+$/.test(email)) { - return SUCCESS; - } else { - return ERROR_INVALID_EMAIL; - } -} - -/* Validates passed in password, makes sure input wasn't empty - * - * @param password - * @return {String} - SUCCESS or error message - */ -export function validatePassword(password) { - if (password === "") { - return ERROR_EMPTY_PASSWORD; - } else { - return SUCCESS; - } -} - -/* Validates passed in passwords, makes sure input wasn't empty - * and that the passwords match each other - * - * @param password - * @param reenteredPassword - * @return {String} - SUCCESS or error message - */ -export function validateReenteredPassword(password, reenteredPassword) { - if (reenteredPassword === "") { - return ERROR_EMPTY_REENTERED_PASSWORD; - } else if (password === reenteredPassword) { - return SUCCESS; - } else if (password !== "") { - return ERROR_PASSWORDS_DONT_MATCH; - } -} diff --git a/src/utils/test-utils.js b/src/utils/test-utils.js index 955ad8f..ab0ad9b 100644 --- a/src/utils/test-utils.js +++ b/src/utils/test-utils.js @@ -1,25 +1,17 @@ -import React from "react"; -import { Router } from "react-router-dom"; -import { createMemoryHistory } from "history"; -import { - render, - getByLabelText, - fireEvent, - getByText -} from "@testing-library/react"; +import React from 'react'; +import { Router } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; +import { render, getByLabelText, fireEvent, getByText } from '@testing-library/react'; // Including this to get extended Jest matchers for expect -import "@testing-library/jest-dom/extend-expect"; +import '@testing-library/jest-dom/extend-expect'; -const defaultRoute = "/"; +const defaultRoute = '/'; const defaultOptions = {}; export function renderWithRouter( ui, - { - route = defaultRoute, - history = createMemoryHistory({ initialEntries: [route] }) - } = defaultOptions + { route = defaultRoute, history = createMemoryHistory({ initialEntries: [route] }) } = defaultOptions ) { return { ...render({ui}), @@ -36,7 +28,7 @@ export function renderWithRouter( export function clickEventByLabelText(container, label) { fireEvent( getByLabelText(container, label), - new MouseEvent("click", { + new MouseEvent('click', { bubbles: true, cancelable: true }) @@ -52,11 +44,21 @@ export function clickEventByLabelText(container, label) { export function clickEventByText(container, label) { fireEvent( getByText(container, label), - new MouseEvent("click", { + new MouseEvent('click', { bubbles: true, cancelable: true }) ); } -export * from "@testing-library/react"; +/** + * Wrapper for triggering a change event + * @param {Element} element + * @param {*} value + */ + +export function fireChangeEvent(element, value) { + fireEvent.change(element, { target: { value } }); +} + +export * from '@testing-library/react'; diff --git a/src/validation/__tests__/rules.test.js b/src/validation/__tests__/rules.test.js new file mode 100644 index 0000000..2dc1b96 --- /dev/null +++ b/src/validation/__tests__/rules.test.js @@ -0,0 +1,56 @@ +import { EMAIL, DIGIT, SYMBOL, LOWERCASE, UPPERCASE, getMinLengthRule, getMaxLengthRule, getMatchesRule } from '../rules'; + +const DEFAULT_LENGTH = 2; +const Rules = { + EMAIL, + DIGIT, + SYMBOL, + LOWERCASE, + UPPERCASE, + getMaxLengthRule, + getMinLengthRule +}; + +describe('Validation Rules', () => { + const testParams = { + EMAIL: { validInput: 'abc@def.com', invalidInput: 'a' }, + DIGIT: { validInput: '1', invalidInput: 'a' }, + SYMBOL: { validInput: '$', invalidInput: 'a' }, + LOWERCASE: { validInput: 'a', invalidInput: 'A' }, + UPPERCASE: { validInput: 'A', invalidInput: 'a' }, + getMinLengthRule: { validInput: 'aa', invalidInput: 'a' }, + getMaxLengthRule: { validInput: 'aa', invalidInput: 'aaa' } + }; + + function setup(key) { + const pattern = Rules[key].pattern || Rules[key](DEFAULT_LENGTH).pattern; + + let title = key; + if (key === Rules.getMaxLengthRule.name) { + title = 'MAX LENGTH'; + } + + if (key === Rules.getMinLengthRule.name) { + title = 'MIN LENGTH'; + } + return { pattern, title }; + } + + for (const key in Rules) { + if (Rules.hasOwnProperty(key)) { + const { pattern, title } = setup(key); + + test(`${title} regex pattern matches correctly`, () => { + expect(pattern.test(testParams[key].invalidInput)).toBe(false); + expect(pattern.test(testParams[key].validInput)).toBe(true); + }); + } + } + + test('Matches regex pattern tests correctly', () => { + const matchingValue = 'abc'; + const { pattern } = getMatchesRule(matchingValue); + + expect(pattern.test(matchingValue)).toBe(true); + }); +}); diff --git a/src/validation/__tests__/schema.test.js b/src/validation/__tests__/schema.test.js new file mode 100644 index 0000000..7c261b7 --- /dev/null +++ b/src/validation/__tests__/schema.test.js @@ -0,0 +1,106 @@ +import Schema from '../schema'; +import { generateTypeError, capitalize } from '../utils'; +import { LABEL_TYPE, MIN_MAX_TYPE, ERROR_MESSAGES as Errors } from '../constants'; + +import * as Rules from '../rules'; + +const DEFAULT_MIN = 4; +const DEFAULT_MAX = 9; +const DEFAULT_LABEL = 'ABC'; + +describe('Schema', () => { + test('should set schema properties', () => { + const expectedSchema = { + label: DEFAULT_LABEL, + + rules: [ + Rules.getMinLengthRule(DEFAULT_MIN), + Rules.getMaxLengthRule(DEFAULT_MAX), + Rules.DIGIT, + Rules.SYMBOL, + Rules.UPPERCASE, + Rules.LOWERCASE + ] + }; + + expect( + new Schema() + .hasDigit() + .label(DEFAULT_LABEL) + .min(DEFAULT_MIN) + .max(DEFAULT_MAX) + .hasSymbol() + .hasUppercase() + .hasLowercase() + .validateSchema() + ).toEqual(expectedSchema); + }); + + test('should throw error when max length is less than the number of required characters', () => { + expect(() => + new Schema() + .hasLowercase() + .hasUppercase() + .hasSymbol() + .min(1) + .max(2) + .validateSchema() + ).toThrow(Errors.INVALID_MAX); + }); + describe('Min and max length', () => { + const tests = [ + { + max: 1, + min: -2, + expected: Errors.INVALID_NUMBER, + description: 'given length is negative' + }, + { + max: 1, + min: '2', + expected: generateTypeError(capitalize(MIN_MAX_TYPE)), + description: 'given length is not a number' + }, + { + max: 1, + min: 2, + expected: Errors.INVALID_MIN_MAX_MESSAGE, + description: 'min length is greater than max length' + } + ]; + + tests.map(fixture => { + const { description, min, max, expected } = fixture; + test(`should throw error when ${description}`, () => { + expect(() => + new Schema() + .min(min) + .max(max) + .validateSchema() + ).toThrow(expected); + }); + }); + }); + + describe('Label', () => { + const tests = [ + { + label: '', + expected: Errors.EMPTY_LABEL, + description: 'given label is empty' + }, + { + label: 1, + expected: generateTypeError(capitalize(LABEL_TYPE)), + description: 'given label is not a string' + } + ]; + + tests.map(fixture => { + const { label, expected, description } = fixture; + test(`should throw error when ${description}`, () => { + expect(() => new Schema().label(label)).toThrow(expected); + }); + }); + }); +}); diff --git a/src/validation/__tests__/utils.test.js b/src/validation/__tests__/utils.test.js new file mode 100644 index 0000000..748c4ad --- /dev/null +++ b/src/validation/__tests__/utils.test.js @@ -0,0 +1,91 @@ +import { TYPES } from '../constants'; +import { isString, isObject, isNumber, isBoolean, validateType, isEmptyObject, generateTypeError, capitalize } from '../utils'; + +const DEFAULT_STRING = 'abc'; +const DEFAULT_NUMBER = 1; +const DEFAULT_OBJECT = {}; +const DEFAULT_BOOLEAN = true; + +describe('Utils', () => { + test('generateTypeError should return response with type name ', () => { + expect(generateTypeError(DEFAULT_STRING)).toMatch(new RegExp(DEFAULT_STRING)); + }); + + test('validateType should throw error with custom message when object with invalid type is given', () => { + const valid = DEFAULT_STRING; + const invalid = DEFAULT_NUMBER; + const callback = isString; + const customMessage = generateTypeError(capitalize(TYPES.STRING)); + + expect(() => validateType(valid, callback)).not.toThrow(); + + expect(() => validateType(invalid, callback)).toThrow(customMessage); + }); + + describe('isEmptyObject', () => { + const tests = [ + { + obj: DEFAULT_OBJECT, + description: 'true when given value is an empty object', + expectedOutput: true + }, + { + obj: DEFAULT_NUMBER, + description: 'false when given value is not an object', + expectedOutput: false + }, + { + obj: { ...DEFAULT_OBJECT, a: '' }, + description: 'false when given object is not empty', + expectedOutput: false + } + ]; + + tests.map(fixture => { + const { description, expectedOutput, obj } = fixture; + + test(`isEmptyObject should return ${description}`, () => { + expect(isEmptyObject(obj)).toBe(expectedOutput); + }); + }); + }); + + describe('Type checking', () => { + const tests = [ + { + callback: isBoolean, + valid: DEFAULT_BOOLEAN, + invalid: DEFAULT_STRING + }, + { + callback: isString, + valid: DEFAULT_STRING, + invalid: DEFAULT_NUMBER + }, + { + callback: isNumber, + valid: DEFAULT_NUMBER, + invalid: DEFAULT_STRING + }, + { + callback: isObject, + valid: DEFAULT_OBJECT, + invalid: DEFAULT_NUMBER + } + ]; + + tests.map(fixture => { + const { valid, invalid, callback } = fixture; + const name = callback.name; + const type = name.replace('is', ''); + + test(`${name} should return true when given value is of type ${type}`, () => { + expect(callback(valid)).toBe(true); + }); + + test(`${name} should return false when given value is not of type ${type}`, () => { + expect(callback(invalid)).toBe(false); + }); + }); + }); +}); diff --git a/src/validation/__tests__/validate.test.js b/src/validation/__tests__/validate.test.js new file mode 100644 index 0000000..ab853c4 --- /dev/null +++ b/src/validation/__tests__/validate.test.js @@ -0,0 +1,264 @@ +import { EMPTY_VALUE, ERROR_MESSAGES as Errors, VALIDATION_ERROR_MESSAGES as Messages } from '../constants'; +import Schema from '../schema'; +import validate from '../validate'; + +const DEFAULT_VALUE = 'abc'; +const DEFAULT_FORM = { password: DEFAULT_VALUE, email: DEFAULT_VALUE }; +const DEFAULT_LENGTH = 4; +const DEFAULT_SCHEMA = new Schema() + .min(3) + .isEmail() + .isRequired(); +const DEFAULT_FORM_SCHEMA = { + email: new Schema().isEmail().isRequired(), + password: new Schema() + .min(1) + .hasDigit() + .isRequired() +}; + +describe('validate', () => { + test('should return object with isValid property set to false and corresponding error message when input is an empty string', () => { + const { isValid, errors } = validate(EMPTY_VALUE, DEFAULT_SCHEMA); + const expectedResponse = [Messages.REQUIRED]; + + expect(isValid).toBe(false); + expect(errors).toEqual(expectedResponse); + }); + + test('should throw error when value is neither an object nor a string', () => { + const invalidValue = 1; + + expect(() => validate(invalidValue, DEFAULT_SCHEMA)).toThrow(Errors.INVALID_VALUE_TYPE); + }); + + const schemaTests = [ + { + schema: 1, + description: 'schema is not an object' + }, + { + schema: {}, + description: 'schema is an empty object' + }, + { + schema: new Schema() + .isRequired() + .min(2) + .max(1), + description: 'schema is invalid' + } + ]; + + schemaTests.map(fixture => { + const { schema, description } = fixture; + + test(`should throw error when ${description}`, () => { + expect(() => validate(DEFAULT_VALUE, schema)).toThrow(); + }); + }); + + test('should include label in error message when preference is set', () => { + const label = 'abc'; + const options = { includeLabel: true }; + const schema = new Schema() + .label(label) + .min(2) + .hasDigit() + .isRequired(); + + const { errors } = validate(DEFAULT_VALUE, schema, options); + expect(errors[0]).toMatch(label); + }); + + const abortEarlyTests = [ + { + abortEarly: false, + expectedLength: 2, + description: 'should include all errors for value when abortEarly option set to false' + }, + { + abortEarly: true, + expectedLength: 1, + description: 'should only return the first error when abortEarly option set to true' + } + ]; + + abortEarlyTests.map(fixture => { + const { description, abortEarly, expectedLength } = fixture; + + test(description, () => { + const options = { abortEarly }; + const schema = new Schema() + .hasDigit() + .min(3) + .hasUppercase() + .isRequired(); + + const { errors } = validate(DEFAULT_VALUE, schema, options); + expect(errors.length).toBe(expectedLength); + }); + }); + + const validationResultTests = [ + { + schema: new Schema() + .min(1) + .isEmail() + .isRequired(), + ruleName: 'email', + validValue: 'abc@def.com', + invalidValue: DEFAULT_VALUE, + validationError: [Messages.EMAIL] + }, + { + schema: new Schema() + .min(1) + .hasDigit() + .isRequired(), + ruleName: 'digit', + validValue: DEFAULT_VALUE + '1', + invalidValue: DEFAULT_VALUE, + validationError: [Messages.DIGIT] + }, + { + schema: new Schema() + .min(1) + .hasSymbol() + .isRequired(), + ruleName: 'symbol', + validValue: DEFAULT_VALUE + '$', + invalidValue: DEFAULT_VALUE, + validationError: [Messages.SYMBOL] + }, + { + schema: new Schema() + .min(1) + .hasLowercase() + .isRequired(), + ruleName: 'lowercase', + validValue: DEFAULT_VALUE, + invalidValue: DEFAULT_VALUE.toUpperCase(), + validationError: [Messages.LOWERCASE] + }, + { + schema: new Schema() + .min(1) + .hasUppercase() + .isRequired(), + ruleName: 'uppercase', + validValue: DEFAULT_VALUE + 'S', + invalidValue: DEFAULT_VALUE, + validationError: [Messages.UPPERCASE] + }, + { + schema: new Schema().min(DEFAULT_LENGTH).isRequired(), + ruleName: 'uppercase', + validValue: DEFAULT_VALUE + 'S', + invalidValue: DEFAULT_VALUE, + validationError: [Messages.MIN_LENGTH.replace('VALUE', DEFAULT_LENGTH)] + }, + { + schema: new Schema().max(DEFAULT_LENGTH).isRequired(), + ruleName: 'uppercase', + validValue: DEFAULT_VALUE, + invalidValue: DEFAULT_VALUE + DEFAULT_VALUE, + validationError: [Messages.MAX_LENGTH.replace('VALUE', DEFAULT_LENGTH)] + } + ]; + + validationResultTests.map(fixture => { + const { schema, ruleName, validValue, invalidValue, validationError: validationErrors } = fixture; + + test(`should return object with custom error message when ${ruleName} rule does not match`, () => { + const { errors: invalidTestErrors, isValid: invalidTestIsValid } = validate(invalidValue, schema); + + expect(invalidTestIsValid).toBe(false); + expect(invalidTestErrors).toEqual(validationErrors); + + // Valid test + const { isValid, errors } = validate(validValue, schema); + expect(isValid).toBe(true); + expect(errors.length).toBe(0); + }); + }); +}); + +describe('Validate Form', () => { + test('should return validation error when matching property is not the same as current property', () => { + const form = { a: 'abcd@def.com', b: 'abcd' }; + const schema = { + a: new Schema() + .min(1) + .isEmail() + .isRequired(), + b: new Schema().matches('a').isRequired() + }; + + const { errors } = validate(form, schema, { abortEarly: false }); + + expect(errors.b[0]).toBe(Messages.MATCHING.replace('PROPERTY', 'a')); + }); + + test('should throw error when matching schema not found', () => { + const form = { a: 'a' }; + const matchingProperty = 'b'; + const schema = { a: new Schema().matches(matchingProperty) }; + + expect(() => validate(form, schema)).toThrow(Errors.NO_MATCHING_PROPERTY.replace('PROPERTY', matchingProperty)); + }); + + test('should return no errors and isValid set to true when property validation not required', () => { + const { isValid, errors } = validate(DEFAULT_VALUE, new Schema().isEmail().min(1)); + + expect(isValid).toBe(true); + expect(errors).toEqual([]); + }); + + const schemaTests = [ + { + schema: DEFAULT_SCHEMA, + description: 'no corresponding form property schema is provided' + }, + { + schema: {}, + description: 'form schema is an empty object' + } + ]; + + schemaTests.map(fixture => { + const { schema, description } = fixture; + + test(`should throw error when ${description}`, () => { + expect(() => validate(DEFAULT_FORM, schema)).toThrow(Errors.FORM_SCHEMA_MISMATCH); + }); + }); + + const formValidationTests = [ + { + form: { password: DEFAULT_VALUE + '1', email: 'abc@def.com' }, + schema: DEFAULT_FORM_SCHEMA, + expectedOutput: { isValid: true, errors: {} }, + description: 'empty errors object when all form properties validate without errors' + }, + { + form: { password: DEFAULT_VALUE, email: 'abc@def.com' }, + schema: DEFAULT_FORM_SCHEMA, + expectedOutput: { + isValid: false, + errors: { + password: [Messages.DIGIT] + } + }, + description: 'empty errors object when all form properties validate without errors' + } + ]; + + formValidationTests.map(fixture => { + const { form, schema, description, expectedOutput } = fixture; + + test(`should return object with ${description}`, () => { + expect(validate(form, schema)).toEqual(expectedOutput); + }); + }); +}); diff --git a/src/validation/constants.js b/src/validation/constants.js new file mode 100644 index 0000000..2894bd0 --- /dev/null +++ b/src/validation/constants.js @@ -0,0 +1,44 @@ +export const TYPES = { + STRING: 'string', + NUMBER: 'number', + OBJECT: 'object', + BOOLEAN: 'boolean' +}; + +export const ERROR_MESSAGES = { + EMPTY_LABEL: 'Label cannot be empty', + EMPTY_SCHEMA: 'Schema must not be an empty object', + INVALID_TYPE: 'Value must be of type TYPE', + INVALID_SCHEMA: 'Invalid schema', + INVALID_NUMBER: 'Length cannot be negative', + INVALID_VALUE_TYPE: 'Invalid value type', + INVALID_SCHEMA_TYPE: 'Invalid schema type', + FORM_SCHEMA_MISMATCH: 'Schema and form do not match', + NO_MATCHING_PROPERTY: `No PROPERTY property to match`, + INVALID_MIN_OVER_MAX: 'Minimum length cannot be greater than the maximum', + EMPTY_PROPERTY: 'PROPERTY cannot be empty', + EMPTY_MATCHING_PROPERTY: 'Matching property cannot be empty', + INVALID_MIN_MAX: 'Minimum or maximum length cannot be less than the number of required characters' +}; + +export const VALIDATION_ERROR_MESSAGES = { + EMAIL: 'must be a valid email address', + DIGIT: 'must include at least one digit', + SYMBOL: 'must include at least one special character', + REQUIRED: 'must not be empty', + MATCHING: 'does not match PROPERTY', + LOWERCASE: 'must include at least one lowercase character', + UPPERCASE: 'must include at least one uppercase character', + MIN_LENGTH: 'must be at least VALUE character(s) long', + MAX_LENGTH: 'cannot be longer than VALUE character(s)' +}; + +export const SCHEMA = { + DEFAULT_MIN: 1, + DEFAULT_MAX: 255 +}; + +export const NO_ERRORS = 0; +export const LABEL_TYPE = TYPES.STRING; +export const EMPTY_VALUE = ''; +export const MIN_MAX_TYPE = TYPES.NUMBER; diff --git a/src/validation/index.js b/src/validation/index.js new file mode 100644 index 0000000..0f6cc95 --- /dev/null +++ b/src/validation/index.js @@ -0,0 +1,2 @@ +export { default as Schema } from './schema'; +export { default as validate } from './validate'; diff --git a/src/validation/rules.js b/src/validation/rules.js new file mode 100644 index 0000000..4aee26a --- /dev/null +++ b/src/validation/rules.js @@ -0,0 +1,104 @@ +import { VALIDATION_ERROR_MESSAGES as Messages } from './constants'; + +/** + * A rule to be validated against. + * @typedef ValidationRule + * @property {RegExp} pattern - The regex pattern. + * @property {string} error - The corresponding custom error message. + */ + +// This regex pattern was gotten from: https://www.w3resource.com/javascript/form/email-validation.php +const emailPattern = /^\w+([-]?\w+)*@\w+([-]?\w+)*(\.\w{2,3})+$/; + +/** + * Email rule. + */ +export const EMAIL = { + pattern: emailPattern, + error: Messages.EMAIL +}; + +/** + * Digit rule. + */ +export const DIGIT = { + pattern: new RegExp('[0-9]'), + error: Messages.DIGIT +}; + +/** + * Special character rule. + */ +export const SYMBOL = { + pattern: new RegExp(`[!@#$%^&*(),.?":{}|<>]`), + error: Messages.SYMBOL +}; + +/** + * Lowercase rule. + */ +export const LOWERCASE = { + pattern: new RegExp('[a-z]'), + error: Messages.LOWERCASE +}; + +/** + * Uppercase rule. + */ +export const UPPERCASE = { + pattern: new RegExp('[A-Z]'), + error: Messages.UPPERCASE +}; + +/** + * Required property error message. + */ +export const REQUIRED = { + error: Messages.REQUIRED +}; + +/** + * Generate a rule for matching properties. + * @param {string} value - The matching property's value. + * @param {string} matchingProperty - The property corresponding to the current one. + * @returns {ValidationRule} The matching property regex pattern and message. + */ +export function getMatchesRule(value, matchingProperty) { + return { + pattern: new RegExp(`^${escape(value)}$`), + error: Messages.MATCHING.replace('PROPERTY', matchingProperty) + }; +} + +/** + * Generate a rule for minimum number of characters in a string. + * @param {number} value - The minimum length. + * @returns {ValidationRule} The minimum length regex pattern and message. + */ +export function getMinLengthRule(value) { + return { + pattern: new RegExp(`^.{${value},}$`), + error: Messages.MIN_LENGTH.replace('VALUE', `${value}`) + }; +} + +/** + * Generate a rule for maximum number of characters in a string. + * @param {number} value - The maximum length. + * @returns {ValidationRule} The maximum length regex pattern and message. + */ +export function getMaxLengthRule(value) { + return { + pattern: new RegExp(`^.{0,${value}}$`), + error: Messages.MAX_LENGTH.replace('VALUE', `${value}`) + }; +} + +/** + * Escape a value in regex pattern. + * @param {string} value - The value to be escaped. + * @returns {string} The value with escape character. + */ +function escape(value) { + return value.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&'); +} diff --git a/src/validation/schema.js b/src/validation/schema.js new file mode 100644 index 0000000..47414ae --- /dev/null +++ b/src/validation/schema.js @@ -0,0 +1,413 @@ +import * as Rules from './rules'; +import { SCHEMA, ERROR_MESSAGES as Errors } from './constants'; +import { validateType, isNumber, isString, isEmptyString } from './utils'; + +/************************************ + * Symbolic Constants + ************************************/ +const DEFAULT_RULES = { + minimum: { + value: SCHEMA.DEFAULT_MIN, + ...Rules.getMinLengthRule(SCHEMA.DEFAULT_MIN) + }, + maximum: { + value: SCHEMA.DEFAULT_MAX, + ...Rules.getMaxLengthRule(SCHEMA.DEFAULT_MAX) + } +}; + +/************************************ + * Class Declaration + ************************************/ + +/** + * Creates a new Schema + * + * @example + * const schema = new Schema(); + */ +export default class Schema { + /** + * The object detailing the validation rules to be tested for. + * @private + * @static + * @type {object} + */ + #schema = { rules: { ...DEFAULT_RULES } }; + + /** + * Set the minimum number of characters the property should contain. + * @param {number} value - The minimum length. + * @return {Schema} The current schema instance. + * @throws {TypeError} when value is not a number. + * @throws {RangeError} when value is negative. + * + * @example + * const schema = new Schema().min(4); + */ + min(value) { + validateLength(value); + + this.#schema.rules.minimum = { + value, + ...Rules.getMinLengthRule(value) + }; + return this; + } + + /** + * Get the minimum number of characters the property should contain. + * @readonly + * @type {number} + * + * @example + * const schema = new Schema().min(4).max(7); + * const minLength = schema.minimum; // 4 + */ + get minimum() { + return this.#schema.rules.minimum.value; + } + + /** + * Set the maximum number of characters the property should contain. + * @param {number} value - The maximum length. + * @return {Schema} The current schema instance. + * @throws {TypeError} when value is not a number. + * @throws {RangeError} when value is negative. + * + * @example + * const schema = new Schema().max(4); + */ + max(value) { + validateLength(value); + + this.#schema.rules.maximum = { + value, + ...Rules.getMaxLengthRule(value) + }; + return this; + } + + /** + * Get the maximum number of characters the property should contain. + * @readonly + * @type {number} + * + * @example + * const schema = new Schema().min(4).max(7); + * const maxLength = schema.maximum; // 7 + */ + get maximum() { + return this.#schema.rules.maximum.value; + } + + /** + * Set property to contain at least one digit. + * @return {Schema} The current schema instance. + * + * @example + * const schema = new Schema().hasDigit(); + */ + hasDigit() { + this.#schema.rules.digit = Rules.DIGIT; + return this; + } + + /** + * Return whether property should contain at least one digit. + * @readonly + * @type {boolean} + * + * @example + * const schema = new Schema().min(4).hasDigit(); + * const hasDigit = schema.digit; // true + */ + get digit() { + return this.#schema.rules.digit ? true : false; + } + + /** + * Set property to contain at least one special character. + * @return {Schema} The current schema instance. + * + * @example + * const schema = new Schema().hasSymbol(); + */ + hasSymbol() { + this.#schema.rules.symbol = Rules.SYMBOL; + return this; + } + + /** + * Return whether property should contain at least + * one special character. + * + * @readonly + * @type {boolean} + * + * @example + * const schema = new Schema().min(4).hasSymbol(); + * const hasSpecialCharacter = schema.symbol; // true + */ + get symbol() { + return this.#schema.rules.symbol ? true : false; + } + + /** + * Set property to contain at least one uppercase character + * @return {Schema} The current schema instance. + * + * @example + * const schema = new Schema().hasUppercase(); + */ + hasUppercase() { + this.#schema.rules.uppercase = Rules.UPPERCASE; + return this; + } + + /** + * Return whether property should contain at least + * one uppercase character. + * + * @readonly + * @type {boolean} + * + * @example + * const schema = new Schema().min(4).hasUppercase(); + * const hasUppercase = schema.uppercase; // true + */ + get uppercase() { + return this.#schema.rules.uppercase ? true : false; + } + + /** + * Set property to contain at least one lowercase character + * @return {Schema} The current schema instance. + * + * @example + * const schema = new Schema().hasLowercase(); + */ + hasLowercase() { + this.#schema.rules.lowercase = Rules.LOWERCASE; + return this; + } + + /** + * Return whether property should contain at least + * one lowercase character. + * + * @readonly + * @type {boolean} + * + * @example + * const schema = new Schema().min(4).hasLowercase(); + * const hasLowercase = schema.lowercase; // true + */ + get lowercase() { + return this.#schema.rules.lowercase ? true : false; + } + + /** + * Set label to be pre-appended to the property's + * validation error messages + * + * @param {string} name + * @return {Schema} The current schema instance. + * + * @example + * const schema = new Schema().label("abc"); + */ + label(name) { + validateStringInput(name, 'Label'); + + this.#schema.label = name; + return this; + } + + /** + * Get property label. + * @readonly + * @type {string} + * + * @example + * const schema = new Schema().min(4).label("def"); + * const name = schema.alias; // def + */ + get alias() { + return this.#schema.label; + } + + /** + * Set property to be validated as an email address. + * @return {Schema} The current schema instance. + * + * @example + * const schema = new Schema().isEmail(); + */ + isEmail() { + this.#schema.email = true; + return this; + } + + /** + * Get whether property is an email. + * @readonly + * @type {boolean} + * + * @example + * const schema = new Schema().min(4).isEmail(); + * const schemaIsEmail = schema.email; // true + */ + get email() { + return this.#schema.email ? true : false; + } + + /** + * Set property to be validated. + * @return {Schema} The current schema instance. + * + * @example + * const schema = new Schema().isRequired(); + */ + isRequired() { + this.#schema.required = true; + return this; + } + + /** + * Return whether property should be validated. + * @readonly + * @type {boolean} + * + * @example + * const schema = new Schema().min(4).isRequired(); + * const isRequired = schema.required; // true + */ + get required() { + return this.#schema.required ? true : false; + } + + /** + * Set property validation to match the value of given property name. + * @param {string} name - The matching property name. + * @return {Schema} The current schema instance. + * @throws {TypeError} When the name is not a string. + * @throws Throws an error the name is an empty string. + * + * @example + * const schema = new Schema().matches("password"); + */ + matches(name) { + validateStringInput(name, 'Matching property'); + this.#schema.matchingProperty = name; + return this; + } + + /** + * Get the name of the matching property. + * @readonly + * @type {string} + * + * @example + * const schema = new Schema().min(4).matches("abc"); + * const property = schema.matchingProperty; // abc + */ + get matchingProperty() { + return this.#schema.matchingProperty; + } + + /** + * Determines whether schema is configured properly and is called + * automatically by the validate function. + * @see {@link validation.js} for further information. + * + * @returns {object} New object containing the schema rules. + * @throws When minimum length is greater than maximum length + * @throws When minimum or maximum length is less than the number + * of required characters. + */ + validateSchema() { + const { email, label, rules, required, matchingProperty } = this.#schema; + const { minimum, maximum } = rules; + + // Ignore everything else + if (matchingProperty) { + return { required, label, matchingProperty, rules: [] }; + } + + // Ignore everything else + if (email) { + return { required, label, rules: [Rules.EMAIL] }; + } + + // Get 'required characters' - ex: hasSymbol set to true means + // that the minimum value must be at least one in order to match the + // symbol rule. + // Note that min and max are included by default + const requiredChars = Object.keys(rules).length - Object.keys(DEFAULT_RULES).length; + + // Set minimum to the least number of required characters if + // it was not set explicitly + + if (minimum.value === SCHEMA.DEFAULT_MIN) { + const minRule = { ...Rules.getMinLengthRule(requiredChars) }; + minimum.value = requiredChars; + minimum.error = minRule.error; + minimum.pattern = minRule.pattern; + } + + // min greater than max + if (minimum.value > maximum.value) { + throw new Error(Errors.INVALID_MIN_OVER_MAX); + } + + // more characters than min/max length + if (maximum.value < requiredChars || minimum.value < requiredChars) { + throw new Error(Errors.INVALID_MIN_MAX); + } + + // values not needed anymore + delete rules.minimum.value; + delete rules.maximum.value; + + // Return rules as array + return { ...this.#schema, rules: Object.values(rules) }; + } +} + +/************************************ + * Helper Functions + ************************************/ + +/** + * Validate minimum and maximum number of characters. + * @param {number} value The value to be validated. + * @returns {void} Nothing. + * @throws {TypeError} When the given value is not a number. + * @throws {RangeError} When the length is negative. + */ +function validateLength(value) { + validateType(value, isNumber); + + // Validate range + if (value < SCHEMA.DEFAULT_MIN) { + throw new RangeError(Errors.INVALID_NUMBER); + } +} + +/** + * Validate property names. + * @param {string} value - The value to be validated. + * @param {string} propertyName - The property name being validated. + * @returns {void} Nothing. + * @throws Throws an error when the given value is an empty string. + * @throws {TypeError} When the given value is not a string. + */ +function validateStringInput(value, propertyName) { + validateType(value, isString); + + // Empty validation + if (isEmptyString(value)) { + throw new Error(Errors.EMPTY_PROPERTY.replace('PROPERTY', propertyName)); + } +} diff --git a/src/validation/utils.js b/src/validation/utils.js new file mode 100644 index 0000000..bfda6c0 --- /dev/null +++ b/src/validation/utils.js @@ -0,0 +1,87 @@ +import { ERROR_MESSAGES as Errors, EMPTY_VALUE, TYPES } from './constants'; + +/** + * Check value type. + * @param {*} value - The value to check. + * @param {function} callback - The typechecking function. + * @throws {TypeError} When value is not of the expected type. + * + * @example + * validateType(5, isBoolean); // Error: Value must be of type boolean. + */ +export function validateType(value, callback) { + if (!callback(value)) { + throw new TypeError(generateTypeError(callback.name.replace('is', ''))); + } +} + +/** + * Check whether object is empty. + * @param {object} obj - The object to check; + * @returns {boolean} Whether value is an empty object. + */ +export function isEmptyObject(obj) { + return Object.keys(obj).length === 0 && isObject(obj); +} + +/** + * Check whether value is of type boolean. + * @param {*} value - The value to be checked. + * @returns {boolean} Whether value is a boolean. + */ +export function isBoolean(value) { + return typeof value === TYPES.BOOLEAN; +} + +/** + * Check whether value is of type string. + * @param {*} value - The value to be checked. + * @returns {boolean} Whether value is a string. + */ +export function isString(value) { + return typeof value === TYPES.STRING || value instanceof String; +} + +/** + * Check whether string is empty. + * @param {string} value - The value to be checked. + * @returns {boolean} Whether value is an empty string. + */ +export function isEmptyString(value) { + return value === EMPTY_VALUE; +} + +/** + * Check whether value is of type object. + * @param {*} value - The value to be checked. + * @returns {boolean} Whether value is an object. + */ +export function isObject(value) { + return typeof value === TYPES.OBJECT && value !== null; +} + +/** + * Check whether value is of type number. + * @param {*} value - The value to be checked. + * @returns {boolean} Whether value is a number. + */ +export function isNumber(value) { + return typeof value === TYPES.NUMBER && Number.isInteger(value); +} + +/** + * Generate a custom TypeError error message. + * @param {string} type - The name of data type. + * @return {string} The custom error message. + */ +export function generateTypeError(type) { + return Errors.INVALID_TYPE.replace('TYPE', type); +} + +/** + * Capitalize the first letter of a string. + * @param {string} value - The string to capitalize. + */ +export function capitalize(value) { + return value[0].toUpperCase() + value.slice(1); +} diff --git a/src/validation/validate.js b/src/validation/validate.js new file mode 100644 index 0000000..a5dabec --- /dev/null +++ b/src/validation/validate.js @@ -0,0 +1,194 @@ +import { NO_ERRORS, ERROR_MESSAGES as Errors, VALIDATION_ERROR_MESSAGES as Messages } from './constants'; +import { getMatchesRule } from './rules'; +import { isString, isObject, isEmptyString } from './utils'; + +/** + * The validation configurations. + * @typedef {Object} ValidationOptions + * @property {boolean} [includeLabel=false] - Configuration for pre-appending label to the error messages. + * @property {boolean} [abortEarly=false] - Configuration indicating whether + * to stop validation at the first invalid rule. + */ + +/** + * The response from validating a form. + * @typedef {Object} FormValidationResponse + * @property {boolean} isValid - Property detailing whether the form validated successfully. + * @property {Object} errors - The errors present in the form. + */ + +/** + * The response from validating a single property. + * @typedef {Object} PropertyValidationResponse + * @property {boolean} isValid - Property detailing whether the value was validated successfully. + * @property {string[]} errors - The errors present in the property. + */ + +/************************************ + * Symbolic Constants + ************************************/ +const DEFAULT_OPTIONS = { includeLabel: false, abortEarly: false }; + +/** + * Validate a form or string value based on corresponding schema. + * @param {(string|Object)} value - The string or form to validate. + * @param {Object | Function} schema - The corresponding schema. + * @param {ValidationOptions} [options] - The validation configurations. + * @returns {( FormValidationResponse | PropertyValidationResponse)} Object with validation results. + * @throws {TypeError} When given value is neither an object (form) nor a string (single property). + */ +export default function validate(value, schema, options = DEFAULT_OPTIONS) { + if (isString(value)) { + return validateProperty(value, validateSchema(schema), options); + } + + if (isObject(value)) { + return validateForm(value, schema, options); + } + + // Given value is neither a single value nor a form + throw new TypeError(Errors.INVALID_VALUE_TYPE); +} + +/************************************ + * Helper Functions + ************************************/ + +/** + * Validate entire form based on given schema. + * @param {Object} form - The form to validate. + * @param {Object} formSchema - The corresponding schema. + * @param {ValidationOptions} options - The validation configurations. + * @returns {...FormValidationResponse} + */ +function validateForm(form, formSchema, options) { + let formIsValid = true; + + const formErrors = {}; + + // Check that schema matches form and validate + let schema, errors, isValid; + Object.keys(form).forEach(property => { + // Throw error if property does not have corresponding schema + schema = formSchema[property]; + if (!schema) { + throw new Error(Errors.FORM_SCHEMA_MISMATCH); + } + + // Check if current schema has corresponding property and + // set current schema to test for matching value + schema = validateSchema(schema); + if (schema.matchingProperty) { + schema = getMatchingSchema(schema, form); + } + + // Validate properties and set errors + ({ isValid, errors } = validateProperty(form[property], schema, options)); + if (!isValid) { + formIsValid = false; + formErrors[property] = [...errors]; + } + }); + + return { isValid: formIsValid, errors: { ...formErrors } }; +} + +/** + * Generate special schema for property that matches to another form property. + * @param {object} schema - The schema for the current property. + * @param {Object} form - The form to validate. + * @returns {Object} New schema with the matching property's value as a rule. + * @throws Error when no matching property is found. + */ +function getMatchingSchema(schema, form) { + const { matchingProperty } = schema; + const matchingValue = form[matchingProperty]; + + if (!isString(matchingValue)) { + throw new Error(Errors.NO_MATCHING_PROPERTY.replace('PROPERTY', matchingProperty)); + } + + return { + ...schema, + rules: [getMatchesRule(matchingValue, matchingProperty)] + }; +} + +/** + * Validate property value based on given schema. + * @param {string} value - The value to be validated. + * @param {object} schema - The corresponding schema. + * @param {ValidationOptions} options - The validation configurations. + * @returns {PropertyValidationResponse} An object with validation status and error messages. + */ +function validateProperty(value, schema, options) { + const errors = []; + if (!schema.required) { + return { isValid: true, errors }; + } + + // Return immediately if empty + if (isEmptyString(value.trim())) { + errors.push(Messages.REQUIRED); + return { isValid: false, errors }; + } + + testRules(value, schema, errors, options); + return { isValid: errors.length === NO_ERRORS, errors }; +} + +/** + * Check whether schema is valid. + * @param {object} schema - The schema to validate. + * @returns {object} The validated schema. + * @throws Error when schema is invalid. + * @throws {TypeError} When schema type is invalid + */ +function validateSchema(schema) { + try { + schema = schema.validateSchema(); + } catch (error) { + if (error.name === TypeError.name) { + error.message = Errors.INVALID_SCHEMA_TYPE; + } + throw error; + } + return schema; +} + +/** + * Test value against all the validation rules in the schema. + * @param {string} value - The value to be validated. + * @param {object} schema - The schema with the rules to be validated against. + * @param {string[]} errors - The error messages for failed rules. + * @param {ValidationOptions} options - The validation configurations. + * @returns {void} Nothing. + */ +function testRules(value, schema, errors, options) { + const { rules, label } = schema; + const { abortEarly, includeLabel } = options; + + // Loop through testing each rule + let pattern, error; + for (let index = 0; index < rules.length; index++) { + ({ pattern, error } = rules[index]); + + // Add label if present and break if abortEarly set to true + if (pattern.test(value) === false) { + errors.push(getErrorMessage(label, error, includeLabel)); + + if (abortEarly) break; + } + } +} + +/** + * Get the appropriate error message. + * @param {string} label - The label to be pre-appended to the error message. + * @param {string} errorMessage - The error message. + * @param {boolean} includeLabel - Check determining whether the label should be included. + * @returns {string} Error message with label pre-appended if includeLabel is true. + */ +function getErrorMessage(label, errorMessage, includeLabel) { + return includeLabel && label ? `${label} ${errorMessage}` : errorMessage; +}