├── .gitignore ├── README.md ├── api ├── README.md ├── package-lock.json ├── package.json ├── src │ ├── config.ts │ ├── index.ts │ ├── models │ │ ├── BadRequestError.ts │ │ ├── IQuote.ts │ │ ├── IQuoteCreateRequest.ts │ │ └── Routes.ts │ ├── quotes.json │ ├── routes │ │ └── quotes.routes.ts │ └── utils │ │ ├── quotes.utils.ts │ │ └── server.utils.ts ├── test │ ├── api.proxy.js │ └── pact-provider.spec.ts └── tsconfig.json ├── assets ├── main_page.png └── quote_page.png ├── cypress.json ├── cypress └── integration │ └── app.e2e.ts ├── package-lock.json ├── package.json ├── pact ├── pact.config.json ├── pact.publish.js ├── pactSetup.ts └── pacts │ └── front_react_service-react-testing-pyramid.json ├── proxy.conf.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── assets │ └── logo.svg ├── components │ ├── containers │ │ ├── quotePage │ │ │ ├── __tests__ │ │ │ │ └── quotePage.tsx │ │ │ ├── quotePage.elements.ts │ │ │ ├── quotePage.interface.ts │ │ │ ├── quotePage.loadable.tsx │ │ │ ├── quotePage.selector.ts │ │ │ └── quotePage.tsx │ │ └── quotesPage │ │ │ ├── __tests__ │ │ │ └── quotesPage.tsx │ │ │ ├── quotesPage.elements.ts │ │ │ ├── quotesPage.interface.ts │ │ │ ├── quotesPage.loadable.tsx │ │ │ ├── quotesPage.selector.ts │ │ │ └── quotesPage.tsx │ └── pure │ │ ├── quoteCreateForm │ │ ├── __tests__ │ │ │ └── quoteCreateForm.tsx │ │ ├── quoteCreateForm.interface.ts │ │ ├── quoteCreateForm.tsx │ │ └── qutesCreateForm.elements.ts │ │ └── quotesList │ │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── quotesList.tsx.snap │ │ └── quotesList.tsx │ │ ├── quotesList.interface.ts │ │ ├── quotesList.tsx │ │ └── qutesList.elements.ts ├── helpers │ ├── quotes │ │ ├── __tests__ │ │ │ └── quotesHttp.ts │ │ ├── quoteValidation.ts │ │ └── quotesHttp.ts │ └── validators │ │ ├── maxLengthValidator.ts │ │ ├── minLengthValidator.ts │ │ └── requiredValidator.ts ├── index.css ├── index.tsx ├── interfaces │ ├── IAction.ts │ └── IQuote.ts ├── react-app-env.d.ts ├── router │ ├── router.ts │ ├── routerPaths.ts │ └── routes.tsx ├── setupProxy.js ├── store │ ├── actions │ │ └── quotes.ts │ ├── index.ts │ ├── middlewares │ │ ├── __tests__ │ │ │ └── quotes.ts │ │ ├── index.ts │ │ └── quotes.ts │ ├── reducers │ │ ├── __tests__ │ │ │ └── quotes.ts │ │ ├── index.ts │ │ └── quotes.ts │ ├── selectors │ │ ├── __tests__ │ │ │ └── quotes.ts │ │ └── quotes.ts │ └── states │ │ ├── index.ts │ │ └── quotes.ts ├── test-utils │ ├── axiosMocks.ts │ ├── generateState.ts │ ├── initEnzyme.ts │ ├── mocks │ │ └── qoutes.mock.ts │ ├── pageObjects │ │ └── quoteCreateForm.po.ts │ └── recordSaga.ts └── ui-elements │ ├── button.tsx │ └── input.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .idea 26 | pact/log -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## React testing pyramid 2 | 3 | | Main page (quotes list) | Quote page | 4 | | ----------------------------------- | ------------------------------------- | 5 | | ![main page](assets/main_page.png) | ![quote_page](assets/quote_page.png) | 6 | 7 | ### Actions: 8 | 1. The quotes list display 9 | 2. Open/close the "add quote" form 10 | 3. Create new quote (+ form validation) 11 | 4. Open the quote page 12 | 5. Back to the main page 13 | 14 | ### Tests (e2e / integration / contract / unit) 15 | 16 | #### E2E: 17 | 18 | ##### [Quotes app:](cypress/integration/app.e2e.ts) 19 | 20 | 1. A list of quotes should be displayed on the page 21 | 2. The created quote should be appended to list 22 | 3. Quote page should be opened on click to quote item 23 | 24 | #### Integration: 25 | 26 | ##### [Quote page:](src/components/containers/quotePage/__tests__/quotePage.tsx) 27 | 1. Displayed text of quote is correct 28 | 2. Displayed author of quote is correct 29 | 3. Page should be changed to main on click to "To quotes list" 30 | 31 | ##### [Main page:](src/components/containers/quotesPage/__tests__/quotesPage.tsx) 32 | 1. Title of page contains correct text 33 | 2. Quotes list is displayed and count of displayed quotes matches the input data 34 | 3. Quote create form is opened by default 35 | 4. Form is closing when "X" button is clicked 36 | 5. Form is opening when "Create quote" button is clicked 37 | 38 | ##### [Quotes list:](src/components/pure/quotesList/__tests__/quotesList.tsx) 39 | 1. Count of displayed quotes matches the input data 40 | 2. Text of first quote is correct 41 | 3. Author of first quote is correct 42 | 4. First quote item have link to its page 43 | 44 | ##### [Quote create form:](src/components/pure/quotesList/__tests__/quotesList.tsx) 45 | 1. Text of error validation is not displayed by default 46 | 2. Validation error is displayed, when: 47 | 1. Author name length less than 2 characters 48 | 2. Text less than 2 characters 49 | 3. Author name length greater than 64 characters 50 | 4. Text length greater than 256 characters 51 | 5. Author is not filled 52 | 6.Text is not filled 53 | 3. Validation error is not displayed, when: 54 | 1. length of author name > 2 & < 64 and length of text > 2 & < 256 55 | 4. Form submitting 56 | 1. When form is valid 57 | 1. The entered data is sent 58 | 2. Fields of form cleans 59 | 4. When form is not valid 60 | 1. The entered data is not sent 61 | 62 | #### Contract: 63 | 64 | ##### [Quotes API:](src/helpers/quotes/__tests__/quotesHttp.ts) 65 | 1. loadQuotesList() - requests a list of quotes 66 | 2. loadQuote() - requests quote by id 67 | 3. createQuote() - quote creating 68 | 69 | #### Unit: 70 | 71 | ##### [Store middlewares:](src/store/middlewares/__tests__/quotes.ts) 72 | 1. Quote creation 73 | 1. A quote should be created on server and be added to store by CREATED_SUCCESS 74 | 2. When server responds error, action CREATED_FAIL should be created 75 | 2. Quote fetching 76 | 1. Quote should be fetched from server and be added to store by FETCH_ONE_SUCCESS 77 | 2. When server responds error, action FETCH_ONE_FAIL should be created 78 | 3. Quotes list fetching 79 | 1. Quotes list should be fetched from server and be added to store by FETCH_ALL_SUCCESS 80 | 2. When server responds error, action FETCH_ALL_FAIL should be created 81 | 82 | ##### [Store reducers:](src/store/reducers/__tests__/quotes.ts) 83 | 1. FETCH_ALL_SUCCESS must replace quotes list in store 84 | 2. CREATED_SUCCESS must append quote to the list in store 85 | 3. FETCH_ONE_SUCCESS must append quote to the list in store, if quote is not exist in list 86 | 4. FETCH_ONE_SUCCESS must do not append quote to the list in store, if quote is exist in list 87 | 88 | ##### [Store selectors:](src/store/selectors/__tests__/quotes.ts) 89 | 1. getQuotesList() - must return list of quotes 90 | 2. getQuoteIdMatch() - must return match of quoteId from location, if quoteId is exist 91 | 3. getQuoteIdMatch() - must return null, if quoteId is not exist 92 | 4. getQuoteIdByLocation() - must return quoteId from location, if quoteId is exist and valid 93 | 5. getQuoteIdByLocation() - must return null, if quoteId is not exist in location 94 | 6. getCurrentQuoteByLocation() - must return quote from store, if quote with id from location is exist 95 | 7. getCurrentQuoteByLocation() - must return null, if quoteId from location is not exist or invalid 96 | 8. getCurrentQuoteByLocation() - must return null, if quote with id from location is not exist -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | ## How to run pact tests 2 | 3 | Before tests you must to run pact consumer and publish result to broker. 4 | 5 | Process 1: `npm start` 6 | 7 | Process 2: `npm run proxy:pact` 8 | 9 | Run tests: `npm run pact:provider` -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "pact:provider": "ts-node test/pact-provider.spec.ts", 9 | "proxy:pact": "node test/api.proxy.js", 10 | "start": "ts-node src/index.ts", 11 | "start:ci": "ts-node src/index.ts" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "@pact-foundation/karma-pact": "^2.1.9", 17 | "@pact-foundation/pact-node": "^6.20.0", 18 | "http-proxy-middleware": "^0.19.1", 19 | "npm-run-all": "^4.1.5", 20 | "ts-node": "^7.0.1" 21 | }, 22 | "dependencies": { 23 | "typescript": "^3.1.6" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /api/src/config.ts: -------------------------------------------------------------------------------- 1 | import {resolve} from 'path'; 2 | 3 | export const PORT = 8041; 4 | export const QUOTES_FILE_PATH = resolve(__dirname, './quotes.json'); 5 | -------------------------------------------------------------------------------- /api/src/index.ts: -------------------------------------------------------------------------------- 1 | import {PORT} from './config'; 2 | import {quotesRoutes} from './routes/quotes.routes'; 3 | import {runServer} from './utils/server.utils'; 4 | 5 | runServer(PORT, quotesRoutes); 6 | -------------------------------------------------------------------------------- /api/src/models/BadRequestError.ts: -------------------------------------------------------------------------------- 1 | export class BadRequestError implements Error { 2 | stack?: string; 3 | name = 'BadRequestError'; 4 | 5 | constructor(public message: string) { 6 | Error.captureStackTrace(this, this.constructor); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /api/src/models/IQuote.ts: -------------------------------------------------------------------------------- 1 | export interface IQuote { 2 | id: number; 3 | text: string; 4 | author: string; 5 | } 6 | -------------------------------------------------------------------------------- /api/src/models/IQuoteCreateRequest.ts: -------------------------------------------------------------------------------- 1 | export interface IQuoteCreateRequest { 2 | text: string; 3 | author: string; 4 | } 5 | -------------------------------------------------------------------------------- /api/src/models/Routes.ts: -------------------------------------------------------------------------------- 1 | import {IncomingMessage, ServerResponse} from 'http'; 2 | 3 | export type RouteResolver = (req: IncomingMessage, res: ServerResponse) => Promise; 4 | export type Routes = { [route: string]: RouteResolver }; 5 | -------------------------------------------------------------------------------- /api/src/quotes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "text": "Start", 5 | "author": "Typing" 6 | } 7 | ] -------------------------------------------------------------------------------- /api/src/routes/quotes.routes.ts: -------------------------------------------------------------------------------- 1 | import {IncomingMessage} from 'http'; 2 | import { 3 | createNewQuote, 4 | findQuoteById, 5 | getRequestQuoteId, 6 | loadQuotes, 7 | saveQuotes, 8 | validateQuoteCreateRequest, 9 | validateQuoteId 10 | } from '../utils/quotes.utils'; 11 | import {createRequestResolver, getRequestJson} from '../utils/server.utils'; 12 | import {IQuote} from '../models/IQuote'; 13 | import {BadRequestError} from '../models/BadRequestError'; 14 | import {IQuoteCreateRequest} from '../models/IQuoteCreateRequest'; 15 | import {Routes} from '../models/Routes'; 16 | 17 | async function quotesGetList(): Promise { 18 | return loadQuotes(); 19 | } 20 | 21 | async function quotesGetOne(req: IncomingMessage): Promise { 22 | const id = getRequestQuoteId(req); 23 | const quoteIdIsValid = validateQuoteId(id); 24 | 25 | if (!quoteIdIsValid) { 26 | throw new BadRequestError('Quote id is not valid!'); 27 | } 28 | 29 | const quotes = loadQuotes(); 30 | 31 | return findQuoteById(quotes, id); 32 | } 33 | 34 | async function quotesCreate(req: IncomingMessage): Promise { 35 | const quoteCreateRequest = await getRequestJson(req); 36 | const createQuoteRequestIsValid = validateQuoteCreateRequest(quoteCreateRequest); 37 | 38 | if (!createQuoteRequestIsValid) { 39 | throw new BadRequestError('Quote create request is not valid!'); 40 | } 41 | 42 | const quotes = loadQuotes(); 43 | const quote = createNewQuote(quoteCreateRequest, quotes); 44 | 45 | saveQuotes([...quotes, quote]); 46 | 47 | return quote; 48 | } 49 | 50 | export const quotesRoutes: Routes = { 51 | 'GET /quotes': createRequestResolver(quotesGetList, 'Get quotes list'), 52 | 'GET /quote': createRequestResolver(quotesGetOne, 'Get one quote'), 53 | 'POST /quote': createRequestResolver(quotesCreate, 'Create quote') 54 | }; 55 | -------------------------------------------------------------------------------- /api/src/utils/quotes.utils.ts: -------------------------------------------------------------------------------- 1 | import {readFileSync, writeFileSync} from 'fs'; 2 | import {IncomingMessage} from 'http'; 3 | import {QUOTES_FILE_PATH} from '../config'; 4 | import {getRequestQueryParams} from './server.utils'; 5 | import {IQuoteCreateRequest} from '../models/IQuoteCreateRequest'; 6 | import {IQuote} from '../models/IQuote'; 7 | 8 | export function createNewQuote(quoteCreateRequest: IQuoteCreateRequest, quotes: IQuote[]): IQuote { 9 | return { 10 | id: getQuotesMaxId(quotes) + 1, 11 | text: quoteCreateRequest.text, 12 | author: quoteCreateRequest.author 13 | }; 14 | } 15 | 16 | export function findQuoteById(quotes: IQuote[], id: number): IQuote | null { 17 | return quotes.find(q => q.id === id) || null; 18 | } 19 | 20 | export function validateQuoteId(id: number): boolean { 21 | return !isNaN(id) && id >= 0 && id <= 99999; 22 | } 23 | 24 | export function getRequestQuoteId(req: IncomingMessage): number { 25 | const params = getRequestQueryParams<{ id: string }>(req); 26 | 27 | return parseInt(params.id, 10); 28 | } 29 | 30 | export function getQuotesMaxId(quotes: IQuote[]): number { 31 | let maxId = 0; 32 | 33 | quotes.forEach(({id}) => { 34 | if (id > maxId) { 35 | maxId = id; 36 | } 37 | }); 38 | 39 | return maxId; 40 | } 41 | 42 | export function validateQuoteCreateRequest(requestBody: IQuoteCreateRequest): boolean { 43 | return !!requestBody 44 | && typeof requestBody.author === 'string' 45 | && typeof requestBody.text === 'string' 46 | && requestBody.author.length >= 2 && requestBody.author.length <= 64 47 | && requestBody.text.length >= 2 && requestBody.text.length <= 256; 48 | } 49 | 50 | export function loadQuotes(): IQuote[] { 51 | return JSON.parse(readFileSync(QUOTES_FILE_PATH).toString()); 52 | } 53 | 54 | export function saveQuotes(quotes: IQuote[]) { 55 | writeFileSync(QUOTES_FILE_PATH, JSON.stringify(quotes, undefined, 2)); 56 | } 57 | -------------------------------------------------------------------------------- /api/src/utils/server.utils.ts: -------------------------------------------------------------------------------- 1 | import {createServer, IncomingMessage, ServerResponse} from 'http'; 2 | import {BadRequestError} from '../models/BadRequestError'; 3 | import {parse} from 'querystring'; 4 | import {RouteResolver, Routes} from '../models/Routes'; 5 | 6 | export function runServer(port: number, routes: Routes) { 7 | const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { 8 | await resolveRequest(req, res, routes); 9 | }); 10 | 11 | server.listen(port); 12 | 13 | console.log(`Server started. http://localhost:${port}`); 14 | } 15 | 16 | export async function resolveRequest(req: IncomingMessage, res: ServerResponse, routes: Routes) { 17 | const route = getRequestRouteResolver(req, routes); 18 | 19 | if (route === null) { 20 | sendNotFound(res); 21 | 22 | return; 23 | } 24 | 25 | await route(req, res); 26 | } 27 | 28 | export function getRequestRouteResolver(req: IncomingMessage, routes: Routes): RouteResolver { 29 | return routes[getRequestRouteName(req)] || null; 30 | } 31 | 32 | export function getRequestRouteName(req: IncomingMessage): string { 33 | return `${req.method} ${getRequestPath(req)}`; 34 | } 35 | 36 | export function getRequestPath(req: IncomingMessage): string { 37 | return (req.url || '').split('?')[0]; 38 | } 39 | 40 | export function getRequestQueryParams(req: IncomingMessage): T { 41 | const [, query] = (req.url || '').split('?'); 42 | 43 | return parse(query) as any as T; 44 | } 45 | 46 | export function createRequestResolver(resolver: RouteResolver, actionName: string): RouteResolver { 47 | return async (req: IncomingMessage, res: ServerResponse) => { 48 | try { 49 | const jsonResponse = await resolver(req, res); 50 | 51 | sendJsonResponse(res, jsonResponse); 52 | 53 | console.info(`On "${actionName}" response: `, jsonResponse); 54 | } catch (error) { 55 | onRequestResolverError(res, error, actionName); 56 | } 57 | }; 58 | } 59 | 60 | export function onRequestResolverError(res: ServerResponse, error: Error, actionName: string) { 61 | if (error instanceof BadRequestError) { 62 | sendBadRequest(res); 63 | 64 | return; 65 | } 66 | 67 | console.error(`On "${actionName}" error: `, error); 68 | sendServerError(res); 69 | } 70 | 71 | export function sendNotFound(res: ServerResponse) { 72 | res.statusCode = 404; 73 | res.end(); 74 | } 75 | 76 | export function sendBadRequest(res: ServerResponse) { 77 | res.statusCode = 400; 78 | res.end(); 79 | } 80 | 81 | export function sendServerError(res: ServerResponse) { 82 | res.statusCode = 500; 83 | res.end(); 84 | } 85 | 86 | export function sendJsonResponse(res: ServerResponse, json: any) { 87 | res.setHeader('Content-Type', 'application/json'); 88 | 89 | res.end(JSON.stringify(json)); 90 | } 91 | 92 | export async function getRequestJson(req: IncomingMessage): Promise { 93 | return new Promise((onResolve, onReject) => { 94 | let buffer = ''; 95 | 96 | req.on('data', (chunk: string) => { 97 | buffer += chunk; 98 | }); 99 | 100 | req.on('end', () => { 101 | try { 102 | const json = JSON.parse(buffer); 103 | 104 | onResolve(json); 105 | } catch (e) { 106 | onReject(new Error('Request json parse error')); 107 | } 108 | }); 109 | 110 | req.on('error', error => { 111 | onReject(error); 112 | }); 113 | }); 114 | } 115 | -------------------------------------------------------------------------------- /api/test/api.proxy.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const proxy = require('http-proxy-middleware'); 3 | const proxyConfig = require('../../proxy.conf'); 4 | const {providerProxyPort} = require('../../pact/pact.config'); 5 | 6 | const proxyPath = '/api'; 7 | 8 | const server = http.createServer(function (req, res) { 9 | proxy(proxyPath, proxyConfig[proxyPath])(req, res, () => {}); 10 | }); 11 | 12 | server.listen(providerProxyPort); 13 | 14 | console.log(`Proxy on port ${providerProxyPort}`); 15 | -------------------------------------------------------------------------------- /api/test/pact-provider.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * https://github.com/pact-foundation/pact-js/tree/master/examples/e2e 3 | */ 4 | 5 | import pact, {VerifierOptions} from '@pact-foundation/pact-node'; 6 | const {pactBrokerConfig, pactConfig, providerProxyPort} = require('../../pact/pact.config'); 7 | 8 | const opts: VerifierOptions = { 9 | provider: pactConfig.provider, 10 | providerBaseUrl: `http://localhost:${providerProxyPort}`, 11 | publishVerificationResult: true, 12 | providerVersion: '1.0.0', 13 | ...pactBrokerConfig 14 | }; 15 | 16 | 17 | (async function () { 18 | try { 19 | const result = await pact.verifyPacts(opts); 20 | 21 | console.log('Pact provider test success!'); 22 | console.log(result); 23 | 24 | process.exit(0); 25 | } catch (error) { 26 | console.log('Pact provider test failed!'); 27 | console.log(error); 28 | 29 | process.exit(1); 30 | } 31 | })(); 32 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["es2015"], 5 | "rootDir": "src", 6 | "outDir": "dist", 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "strict": true, 10 | "declaration": true, 11 | "sourceMap": true, 12 | "inlineSources": true, 13 | "types": ["node"] 14 | }, 15 | "include": [ 16 | "src/**/*" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /assets/main_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shoom3301/react-testing-pyramid/bb65c575b7a3706deb3e98dcab43f3a0a899bce5/assets/main_page.png -------------------------------------------------------------------------------- /assets/quote_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shoom3301/react-testing-pyramid/bb65c575b7a3706deb3e98dcab43f3a0a899bce5/assets/quote_page.png -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /cypress/integration/app.e2e.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('Quotes app', () => { 4 | const quotesListSelector = '#quotes-list'; 5 | const createQuoteBtnSelector = '#create-quote-btn'; 6 | const authorInpSelector = 'input[name="author"]'; 7 | const textInpSelector = 'textarea[name="text"]'; 8 | const quoteItemSelector = '.quote-item'; 9 | const quoteTextSelector = '.quote-text'; 10 | const quoteAuthorSelector = '.quote-author'; 11 | const quotePageTextSelector = '.quote-page-text'; 12 | const quotePageAuthorSelector = '.quote-page-author'; 13 | 14 | beforeEach(() => { 15 | cy.visit('http://localhost:3000'); 16 | }); 17 | 18 | it('A list of quotes should be displayed on the page', () => { 19 | cy.get(quotesListSelector).children(quoteItemSelector).should($list => { 20 | expect($list.length).to.be.greaterThan(0); 21 | }); 22 | }); 23 | 24 | it('The created quote should be appended to list', () => { 25 | const seed = Date.now(); 26 | const quoteText = `Some text ${seed}`; 27 | const quoteAuthor = `Some author ${seed}`; 28 | 29 | cy.get(authorInpSelector).type(quoteAuthor); 30 | cy.get(textInpSelector).type(quoteText); 31 | cy.get(createQuoteBtnSelector).click(); 32 | 33 | cy.get(quotesListSelector).children(quoteItemSelector).last().as('lastQuoteItem'); 34 | 35 | cy.get('@lastQuoteItem').find(quoteTextSelector).should('have.text', quoteText); 36 | cy.get('@lastQuoteItem').find(quoteAuthorSelector).should('have.text', quoteAuthor); 37 | }); 38 | 39 | it('Quote page should be opened on click to quote item', () => { 40 | cy.get(quotesListSelector).children(quoteItemSelector).first().as('firstQuoteItem') 41 | .then($item => { 42 | const text = $item.find(quoteTextSelector).text(); 43 | const author = $item.find(quoteAuthorSelector).text(); 44 | 45 | cy.get('@firstQuoteItem').click(); 46 | cy.url().should('match', /quote\/\d+/); 47 | 48 | cy.get(quotePageTextSelector).should('have.text', text); 49 | cy.get(quotePageAuthorSelector).should('have.text', author); 50 | }); 51 | 52 | }); 53 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-testing-pyramid", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/enzyme-adapter-react-16": "^1.0.5", 7 | "@types/history": "^4.7.2", 8 | "@types/jest": "24.0.12", 9 | "@types/node": "12.0.0", 10 | "@types/react": "16.8.17", 11 | "@types/react-dom": "16.8.4", 12 | "@types/react-loadable": "^5.5.1", 13 | "@types/react-redux": "^7.0.8", 14 | "@types/react-router": "^5.0.0", 15 | "@types/react-router-dom": "^4.3.3", 16 | "@types/redux-saga": "^0.10.5", 17 | "@types/styled-components": "^4.1.14", 18 | "axios": "^0.18.0", 19 | "connected-react-router": "^6.4.0", 20 | "history": "^4.9.0", 21 | "react": "^16.8.6", 22 | "react-dom": "^16.8.6", 23 | "react-loadable": "^5.5.0", 24 | "react-redux": "^7.0.3", 25 | "react-router": "^5.0.0", 26 | "react-router-dom": "^5.0.0", 27 | "react-scripts": "3.0.0", 28 | "redux": "^4.0.1", 29 | "redux-saga": "^1.0.2", 30 | "reselect": "^4.0.0", 31 | "styled-components": "^4.2.0", 32 | "typescript": "3.4.5" 33 | }, 34 | "scripts": { 35 | "start": "react-scripts start", 36 | "build": "react-scripts build", 37 | "test": "react-scripts test", 38 | "eject": "react-scripts eject", 39 | "pact:publish": "node pact/pact.publish.js", 40 | "e2e": "cypress run" 41 | }, 42 | "eslintConfig": { 43 | "extends": "react-app" 44 | }, 45 | "browserslist": { 46 | "production": [ 47 | ">0.2%", 48 | "not dead", 49 | "not op_mini all" 50 | ], 51 | "development": [ 52 | "last 1 chrome version", 53 | "last 1 firefox version", 54 | "last 1 safari version" 55 | ] 56 | }, 57 | "jest": { 58 | "watchPathIgnorePatterns": [ 59 | "/pact/" 60 | ] 61 | }, 62 | "devDependencies": { 63 | "@pact-foundation/pact": "^8.2.2", 64 | "@pact-foundation/pact-node": "^8.2.0", 65 | "@types/enzyme": "^3.9.1", 66 | "@types/react-test-renderer": "^16.8.1", 67 | "@types/redux-mock-store": "^1.0.1", 68 | "axios-mock-adapter": "^1.16.0", 69 | "cypress": "^3.2.0", 70 | "enzyme": "^3.9.0", 71 | "enzyme-adapter-react-16": "^1.13.0", 72 | "http-proxy-middleware": "^0.19.1", 73 | "react-test-renderer": "^16.8.6", 74 | "redux-mock-store": "^1.5.3" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /pact/pact.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "pactConfig": { 3 | "consumer": "Front react service", 4 | "provider": "react-testing-pyramid", 5 | "port": 1234 6 | }, 7 | "pactBrokerConfig": { 8 | "pactBrokerUrl": "https://test.pact.dius.com.au", 9 | "pactBrokerUsername": "dXfltyFMgNOFZAxr8io9wJ37iUpY42M", 10 | "pactBrokerPassword": "O5AIZWxelWbLvqMd8PkAVycBJh2Psyg1" 11 | }, 12 | "providerProxyPort": 8999 13 | } -------------------------------------------------------------------------------- /pact/pact.publish.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const pact = require('@pact-foundation/pact-node'); 3 | const {pactBrokerConfig} = require('./pact.config'); 4 | 5 | const opts = { 6 | pactFilesOrDirs: [path.resolve(__dirname, 'pacts')], 7 | pactBroker: pactBrokerConfig.pactBrokerUrl, 8 | pactBrokerUsername: pactBrokerConfig.pactBrokerUsername, 9 | pactBrokerPassword: pactBrokerConfig.pactBrokerPassword, 10 | consumerVersion: `1.0.${Math.floor(Date.now() / 1000)}` 11 | }; 12 | 13 | (async () => { 14 | try { 15 | const result = await pact.publishPacts(opts); 16 | 17 | console.log('Pact contract publishing complete!'); 18 | console.log(result); 19 | 20 | process.exit(0); 21 | } catch (error) { 22 | console.log('Pact contract publishing failed: ', error); 23 | 24 | process.exit(1); 25 | } 26 | })(); 27 | -------------------------------------------------------------------------------- /pact/pactSetup.ts: -------------------------------------------------------------------------------- 1 | import { PactOptions, LogLevel } from '@pact-foundation/pact/dsl/options'; 2 | import * as path from 'path'; 3 | import { Pact, PactfileWriteMode } from '@pact-foundation/pact'; 4 | 5 | export const {pactConfig} : {pactConfig: PactOptions} = require('./pact.config'); 6 | 7 | function configurePactProvider(config: PactOptions): PactOptions { 8 | const commonConfiguration = { 9 | cors: true, 10 | dir: 'pact/pacts', 11 | spec: 2, 12 | logLevel: 'WARN' as LogLevel, 13 | log: path.resolve(process.cwd(), 'pact/log', `${config.provider}.log`), 14 | pactfileWriteMode: 'overwrite' as PactfileWriteMode 15 | }; 16 | 17 | return { 18 | ...commonConfiguration, 19 | ...config 20 | }; 21 | } 22 | 23 | export function getProvider(): Pact { 24 | return new Pact(configurePactProvider(pactConfig)); 25 | } -------------------------------------------------------------------------------- /pact/pacts/front_react_service-react-testing-pyramid.json: -------------------------------------------------------------------------------- 1 | { 2 | "consumer": { 3 | "name": "Front react service" 4 | }, 5 | "provider": { 6 | "name": "react-testing-pyramid" 7 | }, 8 | "interactions": [ 9 | { 10 | "description": "Quotes list", 11 | "providerState": "Requests quotes list", 12 | "request": { 13 | "method": "GET", 14 | "path": "/api/quotes", 15 | "query": "", 16 | "headers": { 17 | "Accept": "application/json, text/plain, */*" 18 | } 19 | }, 20 | "response": { 21 | "status": 200, 22 | "headers": { 23 | "content-type": "application/json" 24 | }, 25 | "body": [ 26 | { 27 | "id": 1, 28 | "text": "Hakuna matata!", 29 | "author": "Pumba" 30 | } 31 | ], 32 | "matchingRules": { 33 | "$.body": { 34 | "min": 1 35 | }, 36 | "$.body[*].*": { 37 | "match": "type" 38 | }, 39 | "$.body[*]": { 40 | "match": "type" 41 | } 42 | } 43 | } 44 | }, 45 | { 46 | "description": "Quote by id", 47 | "providerState": "Requests quote by id", 48 | "request": { 49 | "method": "GET", 50 | "path": "/api/quote", 51 | "query": "id=1", 52 | "headers": { 53 | "Accept": "application/json, text/plain, */*" 54 | } 55 | }, 56 | "response": { 57 | "status": 200, 58 | "headers": { 59 | "content-type": "application/json" 60 | }, 61 | "body": { 62 | "id": 1, 63 | "text": "Hakuna matata!", 64 | "author": "Pumba" 65 | }, 66 | "matchingRules": { 67 | "$.body": { 68 | "match": "type" 69 | } 70 | } 71 | } 72 | }, 73 | { 74 | "description": "Quote", 75 | "providerState": "Quote creating", 76 | "request": { 77 | "method": "POST", 78 | "path": "/api/quote", 79 | "query": "", 80 | "headers": { 81 | "Accept": "application/json, text/plain, */*", 82 | "content-type": "application/json;charset=utf-8" 83 | }, 84 | "body": { 85 | "text": "Hakuna matata!", 86 | "author": "Pumba" 87 | } 88 | }, 89 | "response": { 90 | "status": 200, 91 | "headers": { 92 | "content-type": "application/json" 93 | }, 94 | "body": { 95 | "id": 1, 96 | "text": "Hakuna matata!", 97 | "author": "Pumba" 98 | }, 99 | "matchingRules": { 100 | "$.body": { 101 | "match": "type" 102 | } 103 | } 104 | } 105 | } 106 | ], 107 | "metadata": { 108 | "pactSpecification": { 109 | "version": "2.0.0" 110 | } 111 | } 112 | } -------------------------------------------------------------------------------- /proxy.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "/api": { 3 | "target": "http://localhost:8041", 4 | "secure": false, 5 | "pathRewrite": { 6 | "^/api": "" 7 | }, 8 | "changeOrigin": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shoom3301/react-testing-pyramid/bb65c575b7a3706deb3e98dcab43f3a0a899bce5/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/components/containers/quotePage/__tests__/quotePage.tsx: -------------------------------------------------------------------------------- 1 | import { ReactWrapper, mount } from 'enzyme'; 2 | import React from 'react'; 3 | import { Provider } from 'react-redux'; 4 | import { MemoryRouter, Route } from 'react-router'; 5 | import configureStore, { MockStoreEnhanced } from 'redux-mock-store'; 6 | import { quotePageRoute, mainRoute } from '../../../../router/routerPaths'; 7 | import { initEnzyme } from 'test-utils/initEnzyme'; 8 | import { quotesMock } from 'test-utils/mocks/qoutes.mock'; 9 | import { generateState } from '../../../../test-utils/generateState'; 10 | import { QuotePage } from '../quotePage'; 11 | import { QuoteText, QuoteAuthor, ToMain } from '../quotePage.elements'; 12 | 13 | describe('QuotePage - quote page component', () => { 14 | const mockStore = configureStore(); 15 | const [currentQuote] = quotesMock; 16 | const currentPath = quotePageRoute(currentQuote.id); 17 | const mainPageText = 'main page'; 18 | 19 | let wrapper: ReactWrapper; 20 | let store: MockStoreEnhanced; 21 | 22 | beforeAll(() => { 23 | initEnzyme(); 24 | }); 25 | 26 | beforeEach(() => { 27 | store = mockStore(generateState(currentPath)); 28 | wrapper = mount( 29 | 30 | 32 | mainPageText}/> 33 | 34 | 35 | 36 | ); 37 | }); 38 | 39 | it('Displayed text of quote is correct', () => { 40 | expect(wrapper.find(QuoteText).first().text()).toBe(currentQuote.text); 41 | }); 42 | 43 | it('Displayed author of quote is correct', () => { 44 | expect(wrapper.find(QuoteAuthor).first().text()).toBe(currentQuote.author); 45 | }); 46 | 47 | it('Page should be changed to main on click to "To quotes list"', () => { 48 | wrapper.find(ToMain).first().simulate('click', {button: 0}); 49 | 50 | expect(wrapper.text()).toBe(mainPageText); 51 | }); 52 | }); -------------------------------------------------------------------------------- /src/components/containers/quotePage/quotePage.elements.ts: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | import styled from 'styled-components'; 3 | 4 | export const QuotePageContainer = styled.div` 5 | text-align: center; 6 | margin-top: 50px; 7 | display: block; 8 | font-family: 'Times New Roman', serif; 9 | font-style: italic; 10 | `; 11 | 12 | export const QuoteText = styled.h1.attrs({className: 'quote-page-text'})` 13 | font-size: 36px; 14 | 15 | ::before { 16 | content: '«'; 17 | } 18 | 19 | ::after { 20 | content: '»'; 21 | } 22 | `; 23 | 24 | export const QuoteAuthor = styled.p.attrs({className: 'quote-page-author'})` 25 | font-size: 18px; 26 | `; 27 | 28 | export const ToMain = styled(Link)` 29 | display: inline-block; 30 | margin-top: 30px; 31 | color: rgba(26, 90, 188, 0.83); 32 | font-size: 18px; 33 | `; 34 | -------------------------------------------------------------------------------- /src/components/containers/quotePage/quotePage.interface.ts: -------------------------------------------------------------------------------- 1 | import { QuoteId, IQuote } from '../../../interfaces/IQuote'; 2 | 3 | export interface IQuotePageDispatchProps { 4 | fetchQuote(quoteId: QuoteId): void; 5 | } 6 | 7 | export interface IQuotePageStateProps { 8 | quoteId: QuoteId | null; 9 | quote: IQuote | null; 10 | } 11 | 12 | export interface IQuotePageProps extends IQuotePageDispatchProps, IQuotePageStateProps { 13 | } 14 | -------------------------------------------------------------------------------- /src/components/containers/quotePage/quotePage.loadable.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Loadable from 'react-loadable'; 3 | 4 | export const QuotePageLoadable = Loadable({ 5 | loader() { 6 | return import('components/containers/quotePage/quotePage'); 7 | }, 8 | render({QuotePage}, props) { 9 | return ; 10 | }, 11 | loading() { 12 | return null; 13 | } 14 | }); -------------------------------------------------------------------------------- /src/components/containers/quotePage/quotePage.selector.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import { QuoteId, IQuote } from 'interfaces/IQuote'; 3 | import { getQuoteIdByLocation, getCurrentQuoteByLocation } from 'store/selectors/quotes'; 4 | import { IState } from 'store/states'; 5 | import { IQuotePageStateProps } from './quotePage.interface'; 6 | 7 | export const quotePageSelector = createSelector( 8 | getQuoteIdByLocation, 9 | getCurrentQuoteByLocation, 10 | (quoteId: QuoteId | null, quote: IQuote | null) => { 11 | return { 12 | quoteId, 13 | quote 14 | } 15 | } 16 | ); -------------------------------------------------------------------------------- /src/components/containers/quotePage/quotePage.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Dispatch } from 'redux'; 4 | import { QuoteId } from '../../../interfaces/IQuote'; 5 | import { mainRoute } from '../../../router/routerPaths'; 6 | import { quoteFetchOne } from '../../../store/actions/quotes'; 7 | import { QuotePageContainer, QuoteText, QuoteAuthor, ToMain } from './quotePage.elements'; 8 | import { IQuotePageProps, IQuotePageDispatchProps } from './quotePage.interface'; 9 | import { quotePageSelector } from './quotePage.selector'; 10 | 11 | export class QuotePageComponent extends Component { 12 | componentDidMount() { 13 | // TODO: handle this condition 14 | if (this.props.quoteId === null) { 15 | return; 16 | } 17 | 18 | this.props.fetchQuote(this.props.quoteId); 19 | } 20 | 21 | render(): React.ReactElement { 22 | return ( 23 | 24 | {this.props.quote 25 | &&
26 | {this.props.quote.text} 27 | {this.props.quote.author} 28 |
} 29 | To quotes list 30 |
31 | ); 32 | } 33 | } 34 | 35 | function mapDispatchToProps(dispatch: Dispatch): IQuotePageDispatchProps { 36 | return { 37 | fetchQuote: (quoteId: QuoteId) => dispatch(quoteFetchOne(quoteId)) 38 | } 39 | } 40 | 41 | export const QuotePage = connect(quotePageSelector, mapDispatchToProps)(QuotePageComponent); 42 | 43 | -------------------------------------------------------------------------------- /src/components/containers/quotesPage/__tests__/quotesPage.tsx: -------------------------------------------------------------------------------- 1 | import { mount, ReactWrapper } from 'enzyme'; 2 | import React from 'react'; 3 | import { Provider } from 'react-redux'; 4 | import { MemoryRouter } from 'react-router'; 5 | import { initEnzyme } from 'test-utils/initEnzyme'; 6 | import { quotesMock } from 'test-utils/mocks/qoutes.mock'; 7 | import { UIButton } from 'ui-elements/button'; 8 | import { QuoteCreateForm } from 'components/pure/quoteCreateForm/quoteCreateForm'; 9 | import { CloseForm } from 'components/pure/quoteCreateForm/qutesCreateForm.elements'; 10 | import { QuoteItem } from 'components/pure/quotesList/qutesList.elements'; 11 | import { QuotesPage } from '../quotesPage'; 12 | import configureStore, { MockStoreEnhanced } from 'redux-mock-store'; 13 | import { QuotesPageTitle } from '../quotesPage.elements'; 14 | 15 | describe('QuotesPage - quotes page component', () => { 16 | const mockStore = configureStore(); 17 | const initialState = {quotes: quotesMock}; 18 | 19 | let wrapper: ReactWrapper; 20 | let store: MockStoreEnhanced; 21 | 22 | beforeAll(() => { 23 | initEnzyme(); 24 | }); 25 | 26 | beforeEach(() => { 27 | store = mockStore(initialState); 28 | wrapper = mount(); 29 | }); 30 | 31 | it('Title of page contains correct text', () => { 32 | expect(wrapper.find(QuotesPageTitle).text()).toBe('Quotes app'); 33 | }); 34 | 35 | it('Quotes list is displayed and count of displayed quotes matches the input data', () => { 36 | expect(wrapper.find(QuoteItem).length).toBe(quotesMock.length); 37 | }); 38 | 39 | it('Quote create form is opened by default', () => { 40 | expect(wrapper.find(QuoteCreateForm).length).toBe(1); 41 | }); 42 | 43 | it('Form is closing when "X" button is clicked', () => { 44 | wrapper.find(CloseForm).first().simulate('click'); 45 | 46 | expect(wrapper.find(QuoteCreateForm).length).toBe(0); 47 | }); 48 | 49 | it('Form is opening when "Create quote" button is clicked', () => { 50 | wrapper.find(CloseForm).first().simulate('click'); 51 | wrapper.find(UIButton).first().simulate('click'); 52 | 53 | expect(wrapper.find(QuoteCreateForm).length).toBe(1); 54 | }); 55 | }); -------------------------------------------------------------------------------- /src/components/containers/quotesPage/quotesPage.elements.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const QuotesPageTitle = styled.h3` 4 | font-size: 24px; 5 | `; 6 | 7 | export const QuotesPageContainer = styled.div` 8 | width: 500px; 9 | margin: 0 auto; 10 | `; -------------------------------------------------------------------------------- /src/components/containers/quotesPage/quotesPage.interface.ts: -------------------------------------------------------------------------------- 1 | import { IQuote, IQuoteBlank } from '../../../interfaces/IQuote'; 2 | 3 | export interface IQuotesPageDispatchProps { 4 | fetchAll(): void; 5 | createQuote(quote: IQuoteBlank): void; 6 | } 7 | 8 | export interface IQuotesPageStateProps { 9 | quotes: IQuote[]; 10 | } 11 | 12 | export interface IQuotesPageProps extends IQuotesPageDispatchProps, IQuotesPageStateProps { 13 | } 14 | 15 | export interface IQuotesPageState { 16 | formIsOpened: boolean; 17 | } -------------------------------------------------------------------------------- /src/components/containers/quotesPage/quotesPage.loadable.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Loadable from 'react-loadable'; 3 | 4 | export const QuotesPageLoadable = Loadable({ 5 | loader() { 6 | return import('components/containers/quotesPage/quotesPage'); 7 | }, 8 | render({QuotesPage}, props) { 9 | return ; 10 | }, 11 | loading() { 12 | return null; 13 | } 14 | }); -------------------------------------------------------------------------------- /src/components/containers/quotesPage/quotesPage.selector.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import { getQuotesList } from '../../../store/selectors/quotes'; 3 | 4 | export const quotesPageSelector = createSelector(getQuotesList, quotes => ({quotes})); -------------------------------------------------------------------------------- /src/components/containers/quotesPage/quotesPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Dispatch } from 'redux'; 4 | import { IQuoteBlank } from '../../../interfaces/IQuote'; 5 | import { quoteFetchAll, quoteCreate } from '../../../store/actions/quotes'; 6 | import { UIButton } from '../../../ui-elements/button'; 7 | import { QuoteCreateForm } from '../../pure/quoteCreateForm/quoteCreateForm'; 8 | import { QuotesList } from '../../pure/quotesList/quotesList'; 9 | import { IQuotesPageState, IQuotesPageDispatchProps, IQuotesPageProps } from './quotesPage.interface'; 10 | import { QuotesPageTitle, QuotesPageContainer } from './quotesPage.elements'; 11 | import { quotesPageSelector } from './quotesPage.selector'; 12 | 13 | export class QuotesPageComponent extends Component { 14 | static defaultState: IQuotesPageState = {formIsOpened: true}; 15 | 16 | state = QuotesPageComponent.defaultState; 17 | 18 | toggleForm = () => { 19 | this.setState({formIsOpened: !this.state.formIsOpened}); 20 | }; 21 | 22 | componentDidMount() { 23 | this.props.fetchAll(); 24 | } 25 | 26 | render(): React.ReactElement { 27 | return ( 28 | 29 | Quotes app 30 | 31 |
32 | { this.state.formIsOpened 33 | ? 34 | : Create quote 35 | } 36 |
37 |
38 | ); 39 | } 40 | } 41 | 42 | function mapDispatchToProps(dispatch: Dispatch): IQuotesPageDispatchProps { 43 | return { 44 | fetchAll: () => dispatch(quoteFetchAll()), 45 | createQuote: (quote: IQuoteBlank) => dispatch(quoteCreate(quote)) 46 | } 47 | } 48 | 49 | export const QuotesPage = connect(quotesPageSelector, mapDispatchToProps)(QuotesPageComponent); 50 | -------------------------------------------------------------------------------- /src/components/pure/quoteCreateForm/__tests__/quoteCreateForm.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, ShallowWrapper } from 'enzyme'; 3 | import { initEnzyme } from 'test-utils/initEnzyme'; 4 | import { QuoteCreateForm } from '../quoteCreateForm'; 5 | import { QuoteCreateFormPo } from 'test-utils/pageObjects/quoteCreateForm.po'; 6 | 7 | describe('QuoteCreateForm - quote create form component', () => { 8 | let onClose: jest.Mock; 9 | let onSubmit: jest.Mock; 10 | let wrapper: ShallowWrapper; 11 | let pageObject: QuoteCreateFormPo; 12 | 13 | beforeAll(() => { 14 | initEnzyme(); 15 | }); 16 | 17 | beforeEach(() => { 18 | onClose = jest.fn(); 19 | onSubmit = jest.fn(); 20 | wrapper = shallow(); 21 | pageObject = new QuoteCreateFormPo(wrapper); 22 | }); 23 | 24 | describe('Form validation', () => { 25 | it('Text of error validation is not displayed by default', () => { 26 | expect(pageObject.getErrorBoxes().length).toBe(0); 27 | }); 28 | 29 | describe('Validation error is displayed, when:', () => { 30 | it('Author name length less than 2 characters', () => { 31 | pageObject.changeAuthor('1'); 32 | 33 | expect(pageObject.getErrorBoxes().length).toBe(1); 34 | }); 35 | 36 | it('Text less than 2 characters', () => { 37 | pageObject.changeText('2'); 38 | 39 | expect(pageObject.getErrorBoxes().length).toBe(1); 40 | }); 41 | 42 | it('Author name length greater than 64 characters', () => { 43 | pageObject.changeAuthor('g'.repeat(65)); 44 | 45 | expect(pageObject.getErrorBoxes().length).toBe(1); 46 | }); 47 | 48 | it('Text length greater than 256 characters', () => { 49 | pageObject.changeText('l'.repeat(257)); 50 | 51 | expect(pageObject.getErrorBoxes().length).toBe(1); 52 | }); 53 | 54 | it('Author is not filled', () => { 55 | pageObject.changeText(pageObject.validText); 56 | pageObject.changeAuthor(''); 57 | 58 | expect(pageObject.getErrorBoxes().length).toBe(1); 59 | }); 60 | 61 | it('Text is not filled', () => { 62 | pageObject.changeAuthor(pageObject.validText); 63 | pageObject.changeText(''); 64 | 65 | expect(pageObject.getErrorBoxes().length).toBe(1); 66 | }); 67 | }); 68 | 69 | describe('Validation error is not displayed, when', () => { 70 | beforeEach(() => { 71 | pageObject.changeAuthor('a'); 72 | pageObject.changeText(''); 73 | }); 74 | 75 | it('Length of author name > 2 & < 64 and length of text > 2 & < 256', () => { 76 | expect(pageObject.getErrorBoxes().length).toBe(1); 77 | 78 | pageObject.changeAuthor(pageObject.validAuthor); 79 | pageObject.changeText(pageObject.validText); 80 | 81 | expect(pageObject.getErrorBoxes().length).toBe(0); 82 | }); 83 | }); 84 | 85 | describe('Form submitting', () => { 86 | describe('When form is valid', () => { 87 | it('The entered data is sent', () => { 88 | pageObject.fillAndSubmitForm(); 89 | 90 | expect(onSubmit.mock.calls.length).toBe(1); 91 | }); 92 | 93 | it('Fields of form cleans', () => { 94 | pageObject.fillAndSubmitForm(); 95 | 96 | expect(pageObject.getAuthorInput().props().value).toBe(''); 97 | expect(pageObject.getTextInput().props().value).toBe(''); 98 | }); 99 | }); 100 | 101 | describe('When form is not valid', () => { 102 | it('The entered data is not sent', () => { 103 | pageObject.changeAuthor('a'); 104 | pageObject.changeText('b'); 105 | pageObject.submitForm(); 106 | 107 | expect(onSubmit.mock.calls.length).toBe(0); 108 | }); 109 | }); 110 | }); 111 | }); 112 | }); -------------------------------------------------------------------------------- /src/components/pure/quoteCreateForm/quoteCreateForm.interface.ts: -------------------------------------------------------------------------------- 1 | import { IQuoteBlank } from '../../../interfaces/IQuote'; 2 | 3 | export interface IQuoteCreateFormProps { 4 | onClose(): void; 5 | onSubmit(quote: IQuoteBlank): void; 6 | } 7 | 8 | export interface IQuoteCreateFormState extends IQuoteBlank { 9 | isValid?: boolean | null; 10 | } -------------------------------------------------------------------------------- /src/components/pure/quoteCreateForm/quoteCreateForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, PureComponent } from 'react'; 2 | import { UIButton } from 'ui-elements/button'; 3 | import { UIInput } from 'ui-elements/input'; 4 | import { authorIsValid, textIsValid } from 'helpers/quotes/quoteValidation'; 5 | import { IQuoteCreateFormProps, IQuoteCreateFormState } from './quoteCreateForm.interface'; 6 | import { FormContainer, CloseForm, Box, Title, Label } from './qutesCreateForm.elements'; 7 | 8 | export class QuoteCreateForm extends PureComponent { 9 | static defaultState: IQuoteCreateFormState = {text: '', author: '', isValid: null}; 10 | 11 | state = QuoteCreateForm.defaultState; 12 | 13 | createQuote = () => { 14 | if (this.state.isValid !== true) { 15 | return; 16 | } 17 | 18 | const {text, author} = this.state; 19 | 20 | this.props.onSubmit({text, author}); 21 | this.setState(QuoteCreateForm.defaultState); 22 | }; 23 | 24 | onChangeAuthor = (event: ChangeEvent) => { 25 | this.updateForm({author: event.target.value}); 26 | }; 27 | 28 | onChangeText = (event: ChangeEvent) => { 29 | this.updateForm({text: event.target.value}); 30 | }; 31 | 32 | private updateForm(updateState: Partial) { 33 | this.setState({...this.state, ...updateState}, () => this.validate()); 34 | } 35 | 36 | private validate() { 37 | const {author, text} = this.state; 38 | const isValid = authorIsValid(author) && textIsValid(text); 39 | 40 | this.setState({isValid}); 41 | } 42 | 43 | render(): React.ReactElement { 44 | return ( 45 | 46 | 47 | Add quote 48 | X 49 | 50 | 51 | 52 | 55 | 56 | 57 | 58 | 62 | 63 | {this.state.isValid === false 64 | && 65 |

66 | {` 67 | Text and author fields is required.
68 | Text length must be >=2 && <=256, author length must be >=2 && <=64 69 | `} 70 |

71 |
72 | } 73 | 74 | Create 76 | 77 |
78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/components/pure/quoteCreateForm/qutesCreateForm.elements.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | export const FormContainer = styled.div` 4 | display: block; 5 | background: #f3f3f3; 6 | border: 1px solid #e1e1e1; 7 | padding: 20px; 8 | `; 9 | 10 | export const CloseForm = styled.button` 11 | float: right; 12 | margin-top: -22px; 13 | border: 0; 14 | border-radius: 50%; 15 | background: rgba(26, 90, 188, 0.83); 16 | color: #fff; 17 | text-align: center; 18 | width: 30px; 19 | height: 30px; 20 | cursor: pointer; 21 | `; 22 | 23 | export const Box = styled.div<{error?: boolean}>` 24 | margin-bottom: 15px; 25 | 26 | :last-child { 27 | margin-bottom: 0; 28 | } 29 | 30 | ${({error}) => error && css` 31 | color: #d52315; 32 | font-size: 13px; 33 | `} 34 | `; 35 | 36 | export const Title = styled.h3` 37 | margin: 0; 38 | `; 39 | 40 | export const Label = styled.label` 41 | display: block; 42 | font-size: 14px; 43 | margin-bottom: 4px; 44 | `; 45 | -------------------------------------------------------------------------------- /src/components/pure/quotesList/__tests__/__snapshots__/quotesList.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`QuotesList - quotes list component Snapshot of component is match 1`] = ` 4 | 59 | `; 60 | -------------------------------------------------------------------------------- /src/components/pure/quotesList/__tests__/quotesList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { MemoryRouter } from 'react-router'; 3 | import { quotesMock } from '../../../../test-utils/mocks/qoutes.mock'; 4 | import { quotePageRoute } from '../../../../router/routerPaths'; 5 | import { QuotesList } from '../quotesList'; 6 | import { QuoteItem, QuoteText, QuoteAuthor } from '../qutesList.elements'; 7 | import { create, ReactTestRenderer, ReactTestInstance } from 'react-test-renderer'; 8 | 9 | describe('QuotesList - quotes list component', () => { 10 | const [firstQuote] = quotesMock; 11 | 12 | let container: ReactTestRenderer; 13 | let instance: ReactTestInstance; 14 | 15 | beforeEach(() => { 16 | container = create(); 17 | instance = container.root; 18 | 19 | }); 20 | 21 | afterEach(() => { 22 | container.unmount(); 23 | }); 24 | 25 | it('Snapshot of component is match', () => { 26 | expect(container.toJSON()).toMatchSnapshot(); 27 | }); 28 | 29 | /** 30 | * TODO: other tests is legacy 31 | * @see https://www.valentinog.com/blog/testing-react/ 32 | */ 33 | it('Count of displayed quotes matches the input data', () => { 34 | const quotesList = instance.findAllByType(QuoteItem); 35 | 36 | expect(quotesList.length).toBe(quotesMock.length); 37 | }); 38 | 39 | it('Text of first quote is correct', () => { 40 | const [quoteText] = instance.findAllByType(QuoteText); 41 | const text = quoteText.props.children; 42 | 43 | expect(text).toBe(firstQuote.text); 44 | }); 45 | 46 | it('Author of first quote is correct', () => { 47 | const [quoteAuthor] = instance.findAllByType(QuoteAuthor); 48 | const text = quoteAuthor.props.children; 49 | 50 | expect(text).toBe(firstQuote.author); 51 | }); 52 | 53 | it('First quote item have link to its page', () => { 54 | const [quoteItem] = instance.findAllByType(QuoteItem); 55 | 56 | expect(quoteItem.props.to).toBe(quotePageRoute(firstQuote.id)); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/components/pure/quotesList/quotesList.interface.ts: -------------------------------------------------------------------------------- 1 | import { IQuote } from '../../../interfaces/IQuote'; 2 | 3 | export interface IQuotesProps { 4 | quotes: IQuote[]; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/pure/quotesList/quotesList.tsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import { quotePageRoute } from '../../../router/routerPaths'; 3 | import { IQuotesProps } from './quotesList.interface'; 4 | import { QuoteItem, QuoteText, QuoteAuthor } from './qutesList.elements'; 5 | 6 | export class QuotesList extends PureComponent { 7 | render(): React.ReactElement { 8 | return ( 9 |
10 | {this.props.quotes.map(({text, author, id}) => ( 11 | 12 | {text} 13 |
14 | {author} 15 |
16 | ))} 17 |
18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/components/pure/quotesList/qutesList.elements.ts: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | import styled from 'styled-components'; 3 | 4 | export const QuoteItem = styled(Link).attrs({className: 'quote-item'})` 5 | margin-bottom: 10px; 6 | padding: 10px; 7 | border: 1px solid #e1e1e1; 8 | color: #000; 9 | display: block; 10 | text-decoration: none; 11 | `; 12 | 13 | export const QuoteText = styled.span.attrs({className: 'quote-text'})` 14 | margin-bottom: 5px; 15 | font-size: 16px; 16 | text-decoration: underline; 17 | `; 18 | 19 | export const QuoteAuthor = styled.span.attrs({className: 'quote-author'})` 20 | font-style: italic; 21 | font-size: 14px; 22 | color: #666; 23 | `; -------------------------------------------------------------------------------- /src/helpers/quotes/__tests__/quotesHttp.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Pact } from '@pact-foundation/pact'; 3 | import { InteractionObject, Matchers } from '@pact-foundation/pact'; 4 | import { getProvider, pactConfig } from '../../../../pact/pactSetup'; 5 | import { quotesMock } from '../../../test-utils/mocks/qoutes.mock'; 6 | import { loadQuotesList, loadQuote, createQuote } from '../quotesHttp'; 7 | 8 | describe('Http requests to quotes API', () => { 9 | let provider: Pact; 10 | 11 | beforeAll( async () => { 12 | axios.defaults.baseURL = `http://localhost:${pactConfig.port}`; 13 | 14 | provider = getProvider(); 15 | 16 | await provider.setup(); 17 | }); 18 | 19 | afterAll(async () => { 20 | await provider.finalize(); 21 | }); 22 | 23 | beforeEach(async () => { 24 | await provider.removeInteractions(); 25 | }); 26 | afterEach(async () => { 27 | await provider.verify(); 28 | }); 29 | 30 | it('loadQuotesList() - requests a list of quotes', async () => { 31 | const quote = quotesMock[0]; 32 | const interaction: InteractionObject = { 33 | state: 'Requests quotes list', 34 | uponReceiving: 'Quotes list', 35 | withRequest: { 36 | method: 'GET', 37 | path: '/api/quotes', 38 | query: '', 39 | headers: { 40 | Accept: 'application/json, text/plain, */*' 41 | } 42 | }, 43 | willRespondWith: { 44 | status: 200, 45 | headers: { 46 | 'content-type': 'application/json' 47 | }, 48 | body: Matchers.eachLike(Matchers.like(quote), {min: 1}) 49 | } 50 | }; 51 | 52 | await provider 53 | .addInteraction(interaction) 54 | .then(() => loadQuotesList()); 55 | }); 56 | 57 | it('loadQuote() - requests quote by id', async () => { 58 | const quote = quotesMock[0]; 59 | const interaction: InteractionObject = { 60 | state: 'Requests quote by id', 61 | uponReceiving: 'Quote by id', 62 | withRequest: { 63 | method: 'GET', 64 | path: '/api/quote', 65 | query: { 66 | id: `${quote.id}` 67 | }, 68 | headers: { 69 | Accept: 'application/json, text/plain, */*' 70 | } 71 | }, 72 | willRespondWith: { 73 | status: 200, 74 | headers: { 75 | 'content-type': 'application/json' 76 | }, 77 | body: Matchers.like(quote) 78 | } 79 | }; 80 | 81 | await provider 82 | .addInteraction(interaction) 83 | .then(() => loadQuote(quote.id)); 84 | }); 85 | 86 | it('createQuote() - quote creating', async () => { 87 | const quote = quotesMock[0]; 88 | const interaction: InteractionObject = { 89 | state: 'Quote creating', 90 | uponReceiving: 'Quote', 91 | withRequest: { 92 | method: 'POST', 93 | path: '/api/quote', 94 | query: '', 95 | headers: { 96 | Accept: 'application/json, text/plain, */*', 97 | 'content-type': 'application/json;charset=utf-8' 98 | }, 99 | body: { 100 | text: quote.text, 101 | author: quote.author 102 | } 103 | }, 104 | willRespondWith: { 105 | status: 200, 106 | headers: { 107 | 'content-type': 'application/json' 108 | }, 109 | body: Matchers.like(quote) 110 | } 111 | }; 112 | 113 | await provider 114 | .addInteraction(interaction) 115 | .then(() => createQuote({text: quote.text, author: quote.author})); 116 | }); 117 | }); -------------------------------------------------------------------------------- /src/helpers/quotes/quoteValidation.ts: -------------------------------------------------------------------------------- 1 | import { maxLengthValidator } from '../validators/maxLengthValidator'; 2 | import { minLengthValidator } from '../validators/minLengthValidator'; 3 | import { requiredValidator } from '../validators/requiredValidator'; 4 | 5 | export const authorIsValid = (author: string): boolean => requiredValidator(author) 6 | && maxLengthValidator(author, 64) 7 | && minLengthValidator(author, 2); 8 | 9 | export const textIsValid = (text: string): boolean => requiredValidator(text) 10 | && maxLengthValidator(text, 256) 11 | && minLengthValidator(text, 2); -------------------------------------------------------------------------------- /src/helpers/quotes/quotesHttp.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosPromise } from 'axios'; 2 | import { IQuote, QuoteId, IQuoteBlank } from '../../interfaces/IQuote'; 3 | 4 | export const API_QUOTE_PATH = '/api/quote'; 5 | export const API_QUOTES_PATH = '/api/quotes'; 6 | 7 | export function loadQuotesList(): AxiosPromise { 8 | return axios.get(API_QUOTES_PATH).then(({data}) => data); 9 | } 10 | 11 | export function loadQuote(id: QuoteId): AxiosPromise { 12 | return axios.get(API_QUOTE_PATH, {params: {id}}).then(({data}) => data); 13 | } 14 | 15 | export function createQuote(quote: IQuoteBlank): AxiosPromise { 16 | return axios.post(API_QUOTE_PATH, quote).then(({data}) => data); 17 | } 18 | -------------------------------------------------------------------------------- /src/helpers/validators/maxLengthValidator.ts: -------------------------------------------------------------------------------- 1 | export function maxLengthValidator(value: string, maxLength: number): boolean { 2 | return !!value && value.length <= maxLength; 3 | } -------------------------------------------------------------------------------- /src/helpers/validators/minLengthValidator.ts: -------------------------------------------------------------------------------- 1 | export function minLengthValidator(value: string, mixLength: number): boolean { 2 | return !!value && value.length >= mixLength; 3 | } -------------------------------------------------------------------------------- /src/helpers/validators/requiredValidator.ts: -------------------------------------------------------------------------------- 1 | export function requiredValidator(value: string): boolean { 2 | return !!value; 3 | } -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 50px 0 0 0; 3 | font-family: Arial; 4 | } 5 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { ConnectedRouter } from 'connected-react-router'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import { Routes } from './router/routes'; 5 | import { store } from './store'; 6 | import { Provider } from 'react-redux'; 7 | import { history } from 'router/router'; 8 | import './index.css'; 9 | 10 | const rootElement = document.getElementById('root'); 11 | 12 | ReactDOM.render( 13 | 14 | 15 | 16 | 17 | , 18 | rootElement 19 | ); -------------------------------------------------------------------------------- /src/interfaces/IAction.ts: -------------------------------------------------------------------------------- 1 | export interface IAction { 2 | type: T; 3 | payload: R; 4 | } -------------------------------------------------------------------------------- /src/interfaces/IQuote.ts: -------------------------------------------------------------------------------- 1 | export interface IQuoteBlank { 2 | text: string; 3 | author: string; 4 | } 5 | 6 | export type QuoteId = number; 7 | 8 | export interface IQuote extends IQuoteBlank { 9 | id: QuoteId; 10 | } 11 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/router/router.ts: -------------------------------------------------------------------------------- 1 | import { connectRouter } from 'connected-react-router'; 2 | import { createBrowserHistory } from 'history'; 3 | 4 | export const history = createBrowserHistory(); 5 | export const router = connectRouter(history); -------------------------------------------------------------------------------- /src/router/routerPaths.ts: -------------------------------------------------------------------------------- 1 | export const mainRoute = '/'; 2 | export const quoteIdParam = 'quoteId'; 3 | export const quotePageRoute = (quoteId: string | number = `:${quoteIdParam}`): string => `/quote/${quoteId}`; 4 | -------------------------------------------------------------------------------- /src/router/routes.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, Switch } from 'react-router'; 3 | import { QuotePageLoadable } from '../components/containers/quotePage/quotePage.loadable'; 4 | import { QuotesPageLoadable } from '../components/containers/quotesPage/quotesPage.loadable'; 5 | import { mainRoute, quotePageRoute } from './routerPaths'; 6 | 7 | export const Routes = (): JSX.Element => ( 8 | 9 | 10 | 11 | 12 | ); -------------------------------------------------------------------------------- /src/setupProxy.js: -------------------------------------------------------------------------------- 1 | const proxy = require('http-proxy-middleware'); 2 | const proxyConfig = require('../proxy.conf'); 3 | const API = '/api'; 4 | 5 | module.exports = function(app) { 6 | app.use(proxy(API, proxyConfig[API])); 7 | }; -------------------------------------------------------------------------------- /src/store/actions/quotes.ts: -------------------------------------------------------------------------------- 1 | import { IAction } from '../../interfaces/IAction'; 2 | import { IQuoteBlank, IQuote, QuoteId } from '../../interfaces/IQuote'; 3 | 4 | export enum QuotesActionTypes { 5 | CREATE = 'CREATE', 6 | CREATED_SUCCESS = 'CREATED_SUCCESS', 7 | CREATED_FAIL = 'CREATED_FAIL', 8 | FETCH_ONE = 'FETCH_ONE', 9 | FETCH_ONE_SUCCESS = 'FETCH_ONE_SUCCESS', 10 | FETCH_ONE_FAIL = 'FETCH_ONE_FAIL', 11 | FETCH_ALL = 'FETCH_ALL', 12 | FETCH_ALL_SUCCESS = 'FETCH_ALL_SUCCESS', 13 | FETCH_ALL_FAIL = 'FETCH_ALL_FAIL', 14 | } 15 | 16 | export type QuoteCreateAction = IAction; 17 | export type QuoteCreateSuccessAction = IAction; 18 | export type QuoteCreateFailAction = IAction; 19 | 20 | export type QuoteFetchOneAction = IAction; 21 | export type QuoteFetchOneSuccessAction = IAction; 22 | export type QuoteFetchOneFailAction = IAction; 23 | 24 | export type QuoteFetchAllAction = IAction; 25 | export type QuoteFetchAllSuccessAction = IAction; 26 | export type QuoteFetchAllFailAction = IAction; 27 | 28 | export function quoteCreate(quote: IQuoteBlank): QuoteCreateAction { 29 | return { 30 | type: QuotesActionTypes.CREATE, 31 | payload: quote 32 | }; 33 | } 34 | 35 | export function quoteCreateSuccess(quote: IQuote): QuoteCreateSuccessAction { 36 | return { 37 | type: QuotesActionTypes.CREATED_SUCCESS, 38 | payload: quote 39 | }; 40 | } 41 | 42 | export function quoteCreateError(error: Error): QuoteCreateFailAction { 43 | return { 44 | type: QuotesActionTypes.CREATED_FAIL, 45 | payload: error 46 | }; 47 | } 48 | 49 | export function quoteFetchOne(quoteId: QuoteId): QuoteFetchOneAction { 50 | return { 51 | type: QuotesActionTypes.FETCH_ONE, 52 | payload: quoteId 53 | }; 54 | } 55 | 56 | export function quoteFetchOneSuccess(quote: IQuote): QuoteFetchOneSuccessAction { 57 | return { 58 | type: QuotesActionTypes.FETCH_ONE_SUCCESS, 59 | payload: quote 60 | }; 61 | } 62 | 63 | export function quoteFetchOneError(error: Error): QuoteFetchOneFailAction { 64 | return { 65 | type: QuotesActionTypes.FETCH_ONE_FAIL, 66 | payload: error 67 | }; 68 | } 69 | 70 | export function quoteFetchAll(): QuoteFetchAllAction { 71 | return { 72 | type: QuotesActionTypes.FETCH_ALL, 73 | payload: undefined 74 | }; 75 | } 76 | 77 | export function quoteFetchAllSuccess(quotes: IQuote[]): QuoteFetchAllSuccessAction { 78 | return { 79 | type: QuotesActionTypes.FETCH_ALL_SUCCESS, 80 | payload: quotes 81 | }; 82 | } 83 | 84 | export function quoteFetchAllError(error: Error): QuoteFetchAllFailAction { 85 | return { 86 | type: QuotesActionTypes.FETCH_ALL_FAIL, 87 | payload: error 88 | }; 89 | } -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { routerMiddleware } from 'connected-react-router'; 2 | import { createStore, compose, applyMiddleware } from 'redux'; 3 | import { sagaMiddleware, runSaga } from './middlewares'; 4 | import { reducers } from './reducers'; 5 | import { history } from 'router/router'; 6 | 7 | // @ts-ignore 8 | const devToolsEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__; 9 | 10 | const composeEnhancers = devToolsEnhancers || compose; 11 | 12 | export const store = createStore( 13 | reducers, 14 | composeEnhancers( 15 | applyMiddleware( 16 | routerMiddleware(history), 17 | sagaMiddleware 18 | ) 19 | ) 20 | ); 21 | 22 | runSaga(); -------------------------------------------------------------------------------- /src/store/middlewares/__tests__/quotes.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import MockAdapter from 'axios-mock-adapter'; 3 | import { API_QUOTE_PATH, API_QUOTES_PATH } from '../../../helpers/quotes/quotesHttp'; 4 | import { IQuoteBlank, IQuote } from '../../../interfaces/IQuote'; 5 | import { HTTP_ERROR_500 } from '../../../test-utils/axiosMocks'; 6 | import { recordSaga } from '../../../test-utils/recordSaga'; 7 | import { 8 | quoteCreate, 9 | quoteCreateSuccess, 10 | QuotesActionTypes, 11 | quoteCreateError, 12 | quoteFetchOne, quoteFetchOneSuccess, quoteFetchOneError, quoteFetchAll, quoteFetchAllSuccess, quoteFetchAllError 13 | } from '../../actions/quotes'; 14 | import { quoteCreation, quoteFetching, quotesFetching } from '../quotes'; 15 | 16 | describe('Middlewares for quotes', () => { 17 | const mock = new MockAdapter(axios); 18 | const quoteBlank: IQuoteBlank = {text: 'aaa', author: 'bbb'}; 19 | const quote: IQuote = {id: 1, ...quoteBlank}; 20 | 21 | describe('Quote creation', () => { 22 | const createAction = quoteCreate(quoteBlank); 23 | 24 | it(`A quote should be created on server and be added to store by ${QuotesActionTypes.CREATED_SUCCESS}`, () => { 25 | mock.onPost(API_QUOTE_PATH).reply(200, quote); 26 | 27 | return recordSaga(quoteCreation, createAction) 28 | .then(dispatched => { 29 | expect(dispatched).toEqual([quoteCreateSuccess(quote)]); 30 | }); 31 | }); 32 | 33 | it(`When server responds error, action ${QuotesActionTypes.CREATED_FAIL} should be created`, () => { 34 | mock.onPost(API_QUOTE_PATH).reply(500); 35 | 36 | return recordSaga(quoteCreation, createAction) 37 | .then(dispatched => { 38 | expect(dispatched).toEqual([quoteCreateError(HTTP_ERROR_500)]); 39 | }); 40 | }); 41 | }); 42 | 43 | describe('Quote fetching', () => { 44 | const fetchOneAction = quoteFetchOne(quote.id); 45 | 46 | it(`Quote should be fetched from server and be added to store by ${QuotesActionTypes.FETCH_ONE_SUCCESS}`, () => { 47 | mock.onGet(API_QUOTE_PATH).reply(200, quote); 48 | 49 | return recordSaga(quoteFetching, fetchOneAction) 50 | .then(dispatched => { 51 | expect(dispatched).toEqual([quoteFetchOneSuccess(quote)]); 52 | }); 53 | }); 54 | 55 | it(`When server responds error, action ${QuotesActionTypes.FETCH_ONE_FAIL} should be created`, () => { 56 | mock.onGet(API_QUOTE_PATH).reply(500); 57 | 58 | return recordSaga(quoteFetching, fetchOneAction) 59 | .then(dispatched => { 60 | expect(dispatched).toEqual([quoteFetchOneError(HTTP_ERROR_500)]); 61 | }); 62 | }); 63 | }); 64 | 65 | describe('Quotes list fetching', () => { 66 | const fetchAllAction = quoteFetchAll(); 67 | 68 | it(`Quotes list should be fetched from server and be added to store by ${QuotesActionTypes.FETCH_ALL_SUCCESS}`, () => { 69 | mock.onGet(API_QUOTES_PATH).reply(200, [quote]); 70 | 71 | return recordSaga(quotesFetching, fetchAllAction) 72 | .then(dispatched => { 73 | expect(dispatched).toEqual([quoteFetchAllSuccess([quote])]); 74 | }); 75 | }); 76 | 77 | it(`When server responds error, action ${QuotesActionTypes.FETCH_ALL_FAIL} should be created`, () => { 78 | mock.onGet(API_QUOTES_PATH).reply(500); 79 | 80 | return recordSaga(quotesFetching, fetchAllAction) 81 | .then(dispatched => { 82 | expect(dispatched).toEqual([quoteFetchAllError(HTTP_ERROR_500)]); 83 | }); 84 | }); 85 | }); 86 | }); -------------------------------------------------------------------------------- /src/store/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | import createSagaMiddleware from 'redux-saga'; 2 | import { quotesMiddleware } from './quotes'; 3 | 4 | export const sagaMiddleware = createSagaMiddleware(); 5 | 6 | export function runSaga() { 7 | sagaMiddleware.run(appMiddleware); 8 | } 9 | 10 | function* appMiddleware() { 11 | yield quotesMiddleware(); 12 | } -------------------------------------------------------------------------------- /src/store/middlewares/quotes.ts: -------------------------------------------------------------------------------- 1 | import { call, put } from 'redux-saga/effects'; 2 | import { createQuote, loadQuote, loadQuotesList } from 'helpers/quotes/quotesHttp'; 3 | import { 4 | QuoteCreateAction, 5 | quoteCreateSuccess, 6 | quoteCreateError, 7 | QuoteCreateFailAction, 8 | QuotesActionTypes, 9 | QuoteFetchOneAction, 10 | quoteFetchOneError, 11 | QuoteFetchOneFailAction, 12 | quoteFetchOneSuccess, 13 | QuoteFetchAllFailAction, QuoteFetchAllAction, quoteFetchAllSuccess, quoteFetchAllError 14 | } from '../actions/quotes'; 15 | import { takeLatest, takeEvery } from 'redux-saga/effects'; 16 | 17 | export function* quotesMiddleware() { 18 | yield takeLatest(QuotesActionTypes.CREATE, quoteCreation); 19 | yield takeEvery(QuotesActionTypes.CREATED_FAIL, quoteCreationFail); 20 | 21 | yield takeEvery(QuotesActionTypes.FETCH_ONE, quoteFetching); 22 | yield takeEvery(QuotesActionTypes.FETCH_ONE_FAIL, quoteFetchingFail); 23 | 24 | yield takeLatest(QuotesActionTypes.FETCH_ALL, quotesFetching); 25 | yield takeEvery(QuotesActionTypes.FETCH_ALL_FAIL, quotesFetchingFail); 26 | } 27 | 28 | export function* quoteCreation(action: QuoteCreateAction) { 29 | try { 30 | const newQuote = yield call(createQuote, action.payload); 31 | 32 | yield put(quoteCreateSuccess(newQuote)); 33 | } catch (error) { 34 | yield put(quoteCreateError(error)); 35 | } 36 | } 37 | 38 | export function quoteCreationFail(action: QuoteCreateFailAction) { 39 | console.error('Quote creation error: ', action.payload); 40 | } 41 | 42 | export function* quoteFetching(action: QuoteFetchOneAction) { 43 | try { 44 | const quote = yield call(loadQuote, action.payload); 45 | 46 | yield put(quoteFetchOneSuccess(quote)); 47 | } catch (error) { 48 | yield put(quoteFetchOneError(error)); 49 | } 50 | } 51 | 52 | export function quoteFetchingFail(action: QuoteFetchOneFailAction) { 53 | console.error('Quote fetching error: ', action.payload); 54 | } 55 | 56 | export function* quotesFetching(action: QuoteFetchAllAction) { 57 | try { 58 | const quote = yield call(loadQuotesList); 59 | 60 | yield put(quoteFetchAllSuccess(quote)); 61 | } catch (error) { 62 | yield put(quoteFetchAllError(error)); 63 | } 64 | } 65 | 66 | export function quotesFetchingFail(action: QuoteFetchAllFailAction) { 67 | console.error('Quotes fetching error: ', action.payload); 68 | } 69 | -------------------------------------------------------------------------------- /src/store/reducers/__tests__/quotes.ts: -------------------------------------------------------------------------------- 1 | import { IQuote } from 'interfaces/IQuote'; 2 | import { 3 | QuotesActionTypes, 4 | quoteFetchAllSuccess, 5 | quoteCreateSuccess, 6 | quoteFetchOneSuccess 7 | } from '../../actions/quotes'; 8 | import { QuotesState } from '../../states/quotes'; 9 | import { quotesReducer } from '../quotes'; 10 | 11 | describe('Reducer for quotes', () => { 12 | const state: QuotesState = []; 13 | const quote: IQuote = {id: 2, text: 'ccc', author: 'ddd'}; 14 | 15 | it(`${QuotesActionTypes.FETCH_ALL_SUCCESS} must replace quotes list in store`, () => { 16 | const newState = quotesReducer(state, quoteFetchAllSuccess([quote])); 17 | 18 | expect(newState).toEqual([quote]); 19 | }); 20 | 21 | it(`${QuotesActionTypes.CREATED_SUCCESS} must append quote to the list in store`, () => { 22 | const newState = quotesReducer(state, quoteCreateSuccess(quote)); 23 | 24 | expect(newState).toEqual([quote]); 25 | }); 26 | 27 | it(`${QuotesActionTypes.FETCH_ONE_SUCCESS} must append quote to the list in store, if quote is not exist in list`, () => { 28 | const newState = quotesReducer(state, quoteFetchOneSuccess(quote)); 29 | 30 | expect(newState).toEqual([quote]); 31 | }); 32 | 33 | it(`${QuotesActionTypes.FETCH_ONE_SUCCESS} must do not append quote to the list in store, if quote is exist in list`, () => { 34 | const newState = quotesReducer([quote], quoteFetchOneSuccess(quote)); 35 | 36 | expect(newState).toEqual([quote]); 37 | }); 38 | }); -------------------------------------------------------------------------------- /src/store/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { router } from '../../router/router'; 3 | import { quotesReducer } from './quotes'; 4 | 5 | export const reducers = combineReducers({ 6 | router, 7 | quotes: quotesReducer 8 | }); -------------------------------------------------------------------------------- /src/store/reducers/quotes.ts: -------------------------------------------------------------------------------- 1 | import { IAction } from '../../interfaces/IAction'; 2 | import { QuotesActionTypes, QuoteFetchOneSuccessAction } from '../actions/quotes'; 3 | import { defaultQuotesState, QuotesState } from '../states/quotes'; 4 | 5 | export function quotesReducer(state = defaultQuotesState, action: IAction): QuotesState { 6 | switch (action.type) { 7 | case QuotesActionTypes.FETCH_ALL_SUCCESS: { 8 | return [...action.payload]; 9 | } 10 | 11 | case QuotesActionTypes.CREATED_SUCCESS: { 12 | return [...state, action.payload]; 13 | } 14 | 15 | case QuotesActionTypes.FETCH_ONE_SUCCESS: { 16 | const quote = (action as QuoteFetchOneSuccessAction).payload; 17 | const existing = state.find(({id}) => id === quote.id); 18 | 19 | return existing 20 | ? state 21 | : [...state, quote]; 22 | } 23 | 24 | default: 25 | return state; 26 | } 27 | } -------------------------------------------------------------------------------- /src/store/selectors/__tests__/quotes.ts: -------------------------------------------------------------------------------- 1 | import { match } from 'react-router'; 2 | import { quotePageRoute } from 'router/routerPaths'; 3 | import { generateState } from '../../../test-utils/generateState'; 4 | import { quotesMock } from '../../../test-utils/mocks/qoutes.mock'; 5 | import { getQuotesList, getQuoteIdMatch, getQuoteIdByLocation, getCurrentQuoteByLocation } from '../quotes'; 6 | 7 | describe('Selectors for quotes', () => { 8 | const currentQuoteIndex = 1; 9 | const defaultQuoteId = quotesMock[currentQuoteIndex].id; 10 | 11 | it('getQuotesList() - must return list of quotes', () => { 12 | const state = generateState(); 13 | const result = getQuotesList(state); 14 | 15 | expect(result).toEqual(state.quotes); 16 | }); 17 | 18 | it('getQuoteIdMatch() - must return match of quoteId from location, if quoteId is exist', () => { 19 | const state = generateState(quotePageRoute(defaultQuoteId)); 20 | const result = getQuoteIdMatch(state) as match<{quoteId: string}>; 21 | 22 | expect(result.params.quoteId).toEqual(defaultQuoteId.toString()); 23 | }); 24 | 25 | it('getQuoteIdMatch() - must return null, if quoteId is not exist', () => { 26 | const state = generateState(); 27 | const result = getQuoteIdMatch(state); 28 | 29 | expect(result).toBeNull(); 30 | }); 31 | 32 | it('getQuoteIdByLocation() - must return quoteId from location, if quoteId is exist and valid', () => { 33 | const state = generateState(quotePageRoute(defaultQuoteId)); 34 | const result = getQuoteIdByLocation(state); 35 | 36 | expect(result).toBe(defaultQuoteId); 37 | }); 38 | 39 | it('getQuoteIdByLocation() - must return null, if quoteId is not exist in location', () => { 40 | const state = generateState(); 41 | const result = getQuoteIdByLocation(state); 42 | 43 | expect(result).toBeNull(); 44 | }); 45 | 46 | it('getCurrentQuoteByLocation() - must return quote from store, if quote with id from location is exist', () => { 47 | const state = generateState(quotePageRoute(defaultQuoteId)); 48 | const result = getCurrentQuoteByLocation(state); 49 | 50 | expect(result).toEqual(state.quotes[currentQuoteIndex]); 51 | }); 52 | 53 | it('getCurrentQuoteByLocation() - must return null, if quoteId from location is not exist or invalid', () => { 54 | const state = generateState(); 55 | const result = getCurrentQuoteByLocation(state); 56 | 57 | expect(result).toBeNull(); 58 | }); 59 | 60 | it('getCurrentQuoteByLocation() - must return null, if quote with id from location is not exist', () => { 61 | const state = generateState(quotePageRoute(8)); 62 | const result = getCurrentQuoteByLocation(state); 63 | 64 | expect(result).toBeNull(); 65 | }); 66 | }); -------------------------------------------------------------------------------- /src/store/selectors/quotes.ts: -------------------------------------------------------------------------------- 1 | import { createMatchSelector } from 'connected-react-router'; 2 | import { createSelector } from 'reselect'; 3 | import { quotePageRoute, quoteIdParam } from 'router/routerPaths'; 4 | import { QuoteId, IQuote } from '../../interfaces/IQuote'; 5 | import { IState } from '../states'; 6 | import { QuotesState } from '../states/quotes'; 7 | 8 | export const getQuotesList = ({quotes}: IState): QuotesState => quotes; 9 | 10 | export const getQuoteIdMatch = createMatchSelector( 11 | quotePageRoute() 12 | ); 13 | 14 | export const getQuoteIdByLocation = createSelector(getQuoteIdMatch, match => { 15 | if (!match) { 16 | return null; 17 | } 18 | 19 | const quoteId = parseInt(match.params[quoteIdParam]); 20 | 21 | if (Number.isNaN(quoteId)) { 22 | return null; 23 | } 24 | 25 | return quoteId; 26 | }); 27 | 28 | export const getCurrentQuoteByLocation = createSelector( 29 | getQuoteIdByLocation, 30 | getQuotesList, 31 | (quoteId: QuoteId | null, quotes: IQuote[]) => { 32 | if (quoteId === null) { 33 | return null; 34 | } 35 | 36 | return quotes.find(({id}) => id === quoteId) || null; 37 | } 38 | ); 39 | -------------------------------------------------------------------------------- /src/store/states/index.ts: -------------------------------------------------------------------------------- 1 | import { RouterState } from 'connected-react-router'; 2 | import { QuotesState } from './quotes'; 3 | 4 | export interface IState { 5 | quotes: QuotesState; 6 | router: RouterState; 7 | } -------------------------------------------------------------------------------- /src/store/states/quotes.ts: -------------------------------------------------------------------------------- 1 | import { IQuote } from '../../interfaces/IQuote'; 2 | 3 | export type QuotesState = IQuote[]; 4 | 5 | export const defaultQuotesState: QuotesState = []; -------------------------------------------------------------------------------- /src/test-utils/axiosMocks.ts: -------------------------------------------------------------------------------- 1 | export const HTTP_ERROR_500 = new Error('Request failed with status code 500'); -------------------------------------------------------------------------------- /src/test-utils/generateState.ts: -------------------------------------------------------------------------------- 1 | import { RouterState } from 'connected-react-router'; 2 | import { IState } from '../store/states'; 3 | import { quotesMock } from './mocks/qoutes.mock'; 4 | 5 | export function generateState(pathname = '', quotes = quotesMock): IState { 6 | return { 7 | router: generateRouterState(pathname), 8 | quotes 9 | }; 10 | } 11 | 12 | export function generateRouterState(pathname = ''): RouterState { 13 | return { 14 | action: 'PUSH', 15 | location: { 16 | pathname, 17 | state: '', 18 | search: '', 19 | hash: '', 20 | } 21 | }; 22 | } -------------------------------------------------------------------------------- /src/test-utils/initEnzyme.ts: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | export function initEnzyme() { 5 | Enzyme.configure({ adapter: new Adapter() }); 6 | } -------------------------------------------------------------------------------- /src/test-utils/mocks/qoutes.mock.ts: -------------------------------------------------------------------------------- 1 | import { IQuote } from '../../interfaces/IQuote'; 2 | 3 | export const quotesMock: IQuote[] = [ 4 | { 5 | id: 1, 6 | text: 'Hakuna matata!', 7 | author: 'Pumba' 8 | }, 9 | { 10 | id: 2, 11 | text: 'Developers developers developers!', 12 | author: 'Steve Ballmer' 13 | }, 14 | { 15 | id: 3, 16 | text: 'Just do it!', 17 | author: 'Shia LaBeouf' 18 | } 19 | ]; 20 | -------------------------------------------------------------------------------- /src/test-utils/pageObjects/quoteCreateForm.po.ts: -------------------------------------------------------------------------------- 1 | import { ShallowWrapper } from 'enzyme'; 2 | import { UIButton } from 'ui-elements/button'; 3 | import { Box } from 'components/pure/quoteCreateForm/qutesCreateForm.elements'; 4 | 5 | export class QuoteCreateFormPo { 6 | readonly validAuthor = 'Jhon'; 7 | readonly validText = 'Simona'; 8 | 9 | constructor(public wrapper: ShallowWrapper) {} 10 | 11 | getErrorBoxes(): ShallowWrapper { 12 | return this.wrapper.find(Box).find({error: true}); 13 | } 14 | 15 | getAuthorInput(): ShallowWrapper { 16 | return this.wrapper.find('[name="author"]').first(); 17 | } 18 | 19 | changeAuthor(author: string) { 20 | this.getAuthorInput().simulate('change', {target: {value: author}}); 21 | } 22 | 23 | getTextInput(): ShallowWrapper { 24 | return this.wrapper.find('[name="text"]').first(); 25 | } 26 | 27 | changeText(author: string) { 28 | this.getTextInput().simulate('change', {target: {value: author}}); 29 | } 30 | 31 | getSubmitButton(): ShallowWrapper { 32 | return this.wrapper.find(UIButton).first(); 33 | } 34 | 35 | submitForm() { 36 | this.getSubmitButton().simulate('click'); 37 | } 38 | 39 | fillAndSubmitForm() { 40 | this.changeAuthor(this.validAuthor); 41 | this.changeText(this.validText); 42 | this.submitForm(); 43 | } 44 | } -------------------------------------------------------------------------------- /src/test-utils/recordSaga.ts: -------------------------------------------------------------------------------- 1 | import { Action } from 'redux'; 2 | import { runSaga, Saga } from 'redux-saga'; 3 | 4 | export function recordSaga(saga: Saga, initialAction: Action): Promise { 5 | const dispatched: any[] = []; 6 | const options = { 7 | dispatch: (action: any) => dispatched.push(action), 8 | getState: () => ({}), 9 | }; 10 | 11 | return runSaga( 12 | options, 13 | saga, 14 | initialAction 15 | ) 16 | .toPromise() 17 | .then(() => dispatched); 18 | } -------------------------------------------------------------------------------- /src/ui-elements/button.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const UIButton = styled.button` 4 | height: 40px; 5 | padding: 0 15px; 6 | color: #fff; 7 | font-size: 15px; 8 | background: rgba(26, 90, 188, 0.83); 9 | border: none; 10 | border-radius: 3px; 11 | cursor: pointer; 12 | `; -------------------------------------------------------------------------------- /src/ui-elements/input.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const UIInput = styled.input` 4 | width: 100%; 5 | max-width: 100%; 6 | min-width: 100%; 7 | min-height: 40px; 8 | max-height: 100px; 9 | border: 1px solid #999; 10 | padding: 5px; 11 | border-radius: 4px; 12 | box-sizing: border-box; 13 | font-size: 14px; 14 | `; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "preserve", 21 | "baseUrl": "src" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | --------------------------------------------------------------------------------