├── .whitesource ├── public ├── sad_panda.gif └── index.html ├── docs ├── consumer-scope.png └── consumer-pipeline.png ├── src ├── product.js ├── setupTests.js ├── index.css ├── Layout.js ├── Heading.js ├── index.js ├── ErrorBoundary.js ├── api.js ├── ProductPage.js ├── api.pact.spec.js └── App.js ├── pactflow └── github-commit-status-webhook.json ├── .gitignore ├── .github ├── renovate.json └── workflows │ ├── trigger_partner_docs_update.yml │ └── build.yml ├── .eslintrc.json ├── LICENSE ├── package.json ├── README.md └── Makefile /.whitesource: -------------------------------------------------------------------------------- 1 | { 2 | "settingsInheritedFrom": "pactflow/whitesource-config@main" 3 | } -------------------------------------------------------------------------------- /public/sad_panda.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pactflow/example-consumer/HEAD/public/sad_panda.gif -------------------------------------------------------------------------------- /docs/consumer-scope.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pactflow/example-consumer/HEAD/docs/consumer-scope.png -------------------------------------------------------------------------------- /docs/consumer-pipeline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pactflow/example-consumer/HEAD/docs/consumer-pipeline.png -------------------------------------------------------------------------------- /src/product.js: -------------------------------------------------------------------------------- 1 | export class Product { 2 | constructor({id, name, type}) { 3 | this.id = id 4 | this.name = name 5 | this.type = type 6 | } 7 | } -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /pactflow/github-commit-status-webhook.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": "${pactbroker.githubVerificationStatus}", 3 | "description": "Pact Verification Tests", 4 | "context": "${pactbroker.providerName} ${pactbroker.providerVersionBranch}", 5 | "target_url": "${pactbroker.verificationResultUrl}" 6 | } 7 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | .idea/ 4 | 5 | # dependencies 6 | node_modules/ 7 | .pnp/ 8 | .pnp.js 9 | 10 | # testing 11 | coverage 12 | 13 | # production 14 | build/ 15 | 16 | # misc 17 | .DS_Store 18 | .env 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | pacts/ 29 | logs/ -------------------------------------------------------------------------------- /src/Layout.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | function Layout(props) { 5 | return ( 6 |
7 |
8 |
9 | {props.children} 10 |
11 |
12 |
13 | ); 14 | } 15 | 16 | Layout.propTypes = { 17 | children: PropTypes.elementType.isRequired 18 | }; 19 | 20 | export default Layout; -------------------------------------------------------------------------------- /src/Heading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | function Heading(props) { 5 | return ( 6 |
7 |

{props.text}

11 |
12 |
13 | ); 14 | } 15 | 16 | Heading.propTypes = { 17 | href: PropTypes.string.isRequired, 18 | text: PropTypes.string.isRequired 19 | }; 20 | 21 | export default Heading; -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:best-practices" 5 | ], 6 | "pre-commit": { 7 | "enabled": true 8 | }, 9 | "git-submodules": { 10 | "enabled": true 11 | }, 12 | "lockFileMaintenance": { 13 | "enabled": true 14 | }, 15 | "prHourlyLimit": 0, 16 | "prConcurrentLimit": 0, 17 | "packageRules": [ 18 | { 19 | "matchUpdateTypes": [ 20 | "minor", 21 | "patch", 22 | "pin", 23 | "digest" 24 | ], 25 | "automerge": true 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "react": { 4 | "version": "18.12.0" 5 | } 6 | }, 7 | "env": { 8 | "browser": true, 9 | "es6": true, 10 | "jest": true 11 | }, 12 | "extends": ["eslint:recommended", "plugin:react/recommended"], 13 | "globals": { 14 | "Atomics": "readonly", 15 | "SharedArrayBuffer": "readonly", 16 | "process": true, 17 | "__dirname": true 18 | }, 19 | "parserOptions": { 20 | "ecmaFeatures": { 21 | "jsx": true 22 | }, 23 | "ecmaVersion": 2018, 24 | "sourceType": "module" 25 | }, 26 | "plugins": ["react"], 27 | "rules": {} 28 | } 29 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { BrowserRouter, Routes, Route } from 'react-router-dom'; 4 | import './index.css'; 5 | import App from './App'; 6 | import ProductPage from './ProductPage'; 7 | import ErrorBoundary from './ErrorBoundary'; 8 | 9 | const routing = ( 10 | 11 |
12 | 13 | 14 | } /> 15 | 16 | } /> 17 | 18 | 19 | 20 |
21 |
22 | ); 23 | 24 | const root = ReactDOM.createRoot(document.getElementById('root')); 25 | root.render(routing); 26 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | Pact Workshop JS 12 | 13 | 14 | 15 |
16 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2021 PactFlow 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/ErrorBoundary.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import Layout from "./Layout"; 4 | import Heading from "./Heading"; 5 | 6 | export default class ErrorBoundary extends React.Component { 7 | state = { has_error: false }; 8 | 9 | componentDidCatch() { 10 | this.setState({ has_error: true }); 11 | } 12 | 13 | render() { 14 | if (this.state.has_error) { 15 | return ( 16 | 17 | 18 |
19 | sad_panda 27 |

33 |           
34 |
35 | ); 36 | } 37 | return this.props.children; 38 | } 39 | } 40 | 41 | ErrorBoundary.propTypes = { 42 | children: PropTypes.object.isRequired, 43 | }; 44 | -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Product } from './product'; 3 | 4 | export class API { 5 | constructor(url) { 6 | if (url === undefined || url === '') { 7 | url = process.env.REACT_APP_API_BASE_URL; 8 | } 9 | if (url.endsWith('/')) { 10 | url = url.substr(0, url.length - 1); 11 | } 12 | this.url = url; 13 | } 14 | 15 | withPath(path) { 16 | if (!path.startsWith('/')) { 17 | path = '/' + path; 18 | } 19 | return `${this.url}${path}`; 20 | } 21 | 22 | generateAuthToken() { 23 | return 'Bearer ' + new Date().toISOString(); 24 | } 25 | 26 | async getAllProducts() { 27 | return axios 28 | .get(this.withPath('/products'), { 29 | headers: { 30 | Authorization: this.generateAuthToken() 31 | } 32 | }) 33 | .then((r) => r.data.map((p) => new Product(p))); 34 | } 35 | 36 | async getProduct(id) { 37 | return axios 38 | .get(this.withPath('/product/' + id), { 39 | headers: { 40 | Authorization: this.generateAuthToken() 41 | } 42 | }) 43 | .then((r) => new Product(r.data)); 44 | } 45 | } 46 | 47 | export default new API( 48 | process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080' 49 | ); 50 | -------------------------------------------------------------------------------- /.github/workflows/trigger_partner_docs_update.yml: -------------------------------------------------------------------------------- 1 | name: Trigger update to partners.pactflow.io 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - '**.md' 9 | 10 | jobs: 11 | run: 12 | runs-on: ubuntu-latest 13 | if: ${{ github.repository == 'pactflow/example-consumer' }} 14 | steps: 15 | - name: Trigger docs.pactflow.io update workflow 16 | run: | 17 | curl -X POST https://api.github.com/repos/pactflow/docs.pactflow.io/dispatches \ 18 | -H 'Accept: application/vnd.github.everest-preview+json' \ 19 | -H "Authorization: Bearer $GITHUB_TOKEN" \ 20 | -d '{"event_type": "pactflow-example-consumer-js-updated"}' 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GHTOKENFORTRIGGERINGPACTDOCSUPDATE }} 23 | - name: Trigger partners.pact.io update workflow 24 | run: | 25 | curl -X POST https://api.github.com/repos/pactflow/partners.pactflow.io/dispatches \ 26 | -H 'Accept: application/vnd.github.everest-preview+json' \ 27 | -H "Authorization: Bearer $GITHUB_TOKEN" \ 28 | -d '{"event_type": "pactflow-example-consumer-js-updated"}' 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GHTOKENFORTRIGGERINGPACTDOCSUPDATE }} 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "consumer", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "axios": "1.13.2", 7 | "prop-types": "15.8.1", 8 | "react": "19.2.3", 9 | "react-dom": "19.2.3", 10 | "react-router": "7.11.0", 11 | "react-router-dom": "7.11.0", 12 | "react-scripts": "5.0.1", 13 | "spectre.css": "^0.5.9" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "cross-env CI=true react-scripts test --transformIgnorePatterns \"node_modules/(?!axios)/\"", 19 | "eject": "react-scripts eject", 20 | "test:pact": "cross-env CI=true react-scripts test --testTimeout 30000 pact.spec.js --transformIgnorePatterns \"node_modules/(?!axios)/\"" 21 | }, 22 | "eslintConfig": { 23 | "extends": [ 24 | "react-app", 25 | "react-app/jest" 26 | ] 27 | }, 28 | "browserslist": { 29 | "production": [ 30 | ">0.2%", 31 | "not dead", 32 | "not op_mini all" 33 | ], 34 | "development": [ 35 | "last 1 chrome version", 36 | "last 1 firefox version", 37 | "last 1 safari version" 38 | ] 39 | }, 40 | "devDependencies": { 41 | "@babel/preset-react": "7.28.5", 42 | "@pact-foundation/pact": "16.0.4", 43 | "@testing-library/jest-dom": "6.9.1", 44 | "@testing-library/react": "16.3.1", 45 | "@testing-library/user-event": "14.6.1", 46 | "cross-env": "10.1.0", 47 | "dotenv": "17.2.3" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | workflow_dispatch: 6 | 7 | env: 8 | PACT_BROKER_BASE_URL: ${{ vars.PACT_BROKER_BASE_URL }} 9 | PACT_PROVIDER: pactflow-example-provider 10 | PACT_BROKER_TOKEN: ${{ secrets.PACTFLOW_TOKEN_FOR_CI_CD_WORKSHOP }} 11 | REACT_APP_API_BASE_URL: http://localhost:3001 12 | GIT_COMMIT: ${{ github.sha }} 13 | GIT_REF: ${{ github.ref }} 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 20 | - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 21 | with: 22 | node-version: '24' 23 | - name: Install 24 | run: npm i 25 | - name: Test 26 | env: 27 | PACT_PROVIDER: ${{ env.PACT_PROVIDER }} 28 | run: make test 29 | - name: Publish pacts between pactflow-example-consumer and ${{ env.PACT_PROVIDER }} 30 | run: GIT_BRANCH=${GIT_REF:11} make publish_pacts 31 | env: 32 | PACT_PROVIDER: ${{ env.PACT_PROVIDER }} 33 | 34 | # Runs on branches as well, so we know the status of our PRs 35 | can-i-deploy: 36 | runs-on: ubuntu-latest 37 | needs: test 38 | steps: 39 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 40 | - run: docker pull pactfoundation/pact-cli:latest 41 | - name: Can I deploy? 42 | run: GIT_BRANCH=${GIT_REF:11} make can_i_deploy 43 | 44 | # Only deploy from master 45 | deploy: 46 | runs-on: ubuntu-latest 47 | needs: can-i-deploy 48 | steps: 49 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 50 | - run: docker pull pactfoundation/pact-cli:latest 51 | - name: Deploy 52 | run: GIT_BRANCH=${GIT_REF:11} make deploy 53 | if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/test' 54 | -------------------------------------------------------------------------------- /src/ProductPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import 'spectre.css/dist/spectre.min.css'; 3 | import 'spectre.css/dist/spectre-icons.min.css'; 4 | import 'spectre.css/dist/spectre-exp.min.css'; 5 | import Layout from './Layout'; 6 | import Heading from './Heading'; 7 | import API from './api'; 8 | 9 | class ProductPage extends React.Component { 10 | constructor(props) { 11 | super(props); 12 | 13 | const bits = window.location.pathname.split('/'); 14 | 15 | this.state = { 16 | loading: true, 17 | product: { 18 | id: bits[bits.length - 1] 19 | } 20 | }; 21 | } 22 | 23 | componentDidMount() { 24 | API.getProduct(this.state.product.id) 25 | .then((r) => { 26 | this.setState({ 27 | loading: false, 28 | product: r 29 | }); 30 | }) 31 | .catch(() => { 32 | this.setState({ error: true }); 33 | }); 34 | } 35 | 36 | render() { 37 | if (this.state.error) { 38 | throw Error('unable to fetch product data'); 39 | } 40 | const productInfo = ( 41 |
42 |

ID: {this.state.product.id}

43 |

Name: {this.state.product.name}

44 |

Type: {this.state.product.type}

45 |
46 | ); 47 | 48 | return ( 49 | 50 | 51 | {this.state.loading ? ( 52 |
61 | ) : ( 62 | productInfo 63 | )} 64 | 65 | ); 66 | } 67 | } 68 | 69 | ProductPage.propTypes = { 70 | // match: PropTypes.array.isRequired, 71 | // history: PropTypes.shape({ 72 | // push: PropTypes.func.isRequired 73 | // }).isRequired 74 | }; 75 | 76 | export default ProductPage; 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Example Consumer 2 | 3 | [![Build](https://github.com/pactflow/example-consumer/actions/workflows/build.yml/badge.svg)](https://github.com/pactflow/example-consumer/actions/workflows/build.yml) 4 | 5 | [![Pact Status](https://test.pactflow.io/pacts/provider/pactflow-example-provider/consumer/pactflow-example-consumer/latest/badge.svg?label=provider)](https://test.pactflow.io/pacts/provider/pactflow-example-provider/consumer/pactflow-example-consumer/latest) (latest pact) 6 | 7 | [![Can I deploy Status](https://test.pactflow.io/pacticipants/pactflow-example-consumer/branches/master/latest-version/can-i-deploy/to-environment/production/badge)](https://test.pactflow.io/pacticipants/pactflow-example-consumer/branches/master/latest-version/can-i-deploy/to-environment/production/badge) 8 | 9 | This is an example of a Node consumer using Pact to create a consumer driven contract, and sharing it via [PactFlow](https://pactflow.io). 10 | 11 | It is using a public tenant on PactFlow, which you can access [here](https://test.pactflow.io/) using the credentials `dXfltyFMgNOFZAxr8io9wJ37iUpY42M`/`O5AIZWxelWbLvqMd8PkAVycBJh2Psyg1`. The latest version of the Example Consumer/Example Provider pact is published [here](https://test.pactflow.io/pacts/provider/pactflow-example-provider/consumer/pactflow-example-consumer/latest). 12 | 13 | The project uses a Makefile to simulate a very simple build pipeline with two stages - test and deploy. 14 | 15 | * Test 16 | * Run tests (including the pact tests that generate the contract) 17 | * Publish pacts, associating the consumer version with the name of the current branch 18 | * Check if we are safe to deploy to prod (ie. has the pact content been successfully verified) 19 | * Deploy (only from master) 20 | * Deploy app (just pretend for the purposes of this example!) 21 | * Record the deployment in the Pact Broker 22 | 23 | ## Usage 24 | 25 | See the [PactFlow CI/CD Workshop](https://github.com/pactflow/ci-cd-workshop). 26 | 27 | ## Running the application 28 | 29 | Start up the [provider](https://github.com/pactflow/example-provider/) (or another [compatible](https://docs.pactflow.io/docs/examples) provider) API by running `npm run start`. 30 | 31 | Open a separate terminal for the consumer. 32 | 33 | Before starting the consumer, create a `.env` file in the root of the project and set the URL to point to your running provider: 34 | 35 | ```bash 36 | REACT_APP_API_BASE_URL=http://localhost:8080 37 | ``` 38 | 39 | Then run: 40 | 41 | ```bash 42 | npm run start 43 | ``` 44 | 45 | ### Pre-requisites 46 | 47 | **Software**: 48 | 49 | * Tools listed at: https://docs.pactflow.io/docs/workshops/ci-cd/set-up-ci/prerequisites/ 50 | * A pactflow.io account with an valid [API token](https://docs.pactflow.io/#configuring-your-api-token) 51 | 52 | 53 | #### Environment variables 54 | 55 | To be able to run some of the commands locally, you will need to export the following environment variables into your shell: 56 | 57 | * `PACT_BROKER_TOKEN`: a valid [API token](https://docs.pactflow.io/#configuring-your-api-token) for PactFlow 58 | * `PACT_BROKER_BASE_URL`: a fully qualified domain name with protocol to your pact broker e.g. https://testdemo.pactflow.io 59 | 60 | ### Usage 61 | 62 | #### Pact use case 63 | 64 | * `make test` - run the pact test locally 65 | * `make fake_ci` - run the CI process locally 66 | -------------------------------------------------------------------------------- /src/api.pact.spec.js: -------------------------------------------------------------------------------- 1 | import { Pact } from '@pact-foundation/pact'; 2 | import { API } from './api'; 3 | import { Matchers } from '@pact-foundation/pact'; 4 | import { Product } from './product'; 5 | const { eachLike, like } = Matchers; 6 | 7 | const mockProvider = new Pact({ 8 | consumer: 'pactflow-example-consumer', 9 | provider: process.env.PACT_PROVIDER 10 | ? process.env.PACT_PROVIDER 11 | : 'pactflow-example-provider' 12 | }); 13 | 14 | describe('API Pact test', () => { 15 | describe('retrieving a product', () => { 16 | it('ID 10 exists', async () => { 17 | // Arrange 18 | const expectedProduct = { 19 | id: '10', 20 | type: 'CREDIT_CARD', 21 | name: '28 Degrees' 22 | }; 23 | 24 | // Uncomment to see this interaction fail on the provider side 25 | // const expectedProduct = { id: '10', type: 'CREDIT_CARD', name: '28 Degrees', price: 30.0, newField: 22} 26 | 27 | await mockProvider 28 | .addInteraction() 29 | .given('a product with ID 10 exists') 30 | .uponReceiving('a request to get a product') 31 | .withRequest('GET', '/product/10', (builder) => { 32 | builder.headers({ 33 | Authorization: like('Bearer 2019-01-14T11:34:18.045Z') 34 | }); 35 | }) 36 | .willRespondWith(200, (builder) => { 37 | builder.headers({ 'Content-Type': 'application/json; charset=utf-8' }); 38 | builder.jsonBody(like(expectedProduct)); 39 | }) 40 | .executeTest(async (mockserver) => { 41 | // Act 42 | const api = new API(mockserver.url); 43 | const product = await api.getProduct('10'); 44 | 45 | // Assert - did we get the expected response 46 | expect(product).toStrictEqual(new Product(expectedProduct)); 47 | return; 48 | }); 49 | }); 50 | 51 | it('product does not exist', async () => { 52 | await mockProvider 53 | .addInteraction() 54 | .given('a product with ID 11 does not exist') 55 | .uponReceiving('a request to get a product') 56 | .withRequest('GET', '/product/11', (builder) => { 57 | builder.headers({ 58 | Authorization: like('Bearer 2019-01-14T11:34:18.045Z') 59 | }); 60 | }) 61 | .willRespondWith(404) 62 | .executeTest(async (mockserver) => { 63 | const api = new API(mockserver.url); 64 | 65 | // make request to Pact mock server 66 | await expect(api.getProduct('11')).rejects.toThrow( 67 | 'Request failed with status code 404' 68 | ); 69 | return; 70 | }); 71 | }); 72 | }); 73 | 74 | describe('retrieving products', () => { 75 | it('products exists', async () => { 76 | // set up Pact interactions 77 | const expectedProduct = { 78 | id: '10', 79 | type: 'CREDIT_CARD', 80 | name: '28 Degrees' 81 | }; 82 | 83 | await mockProvider 84 | .addInteraction() 85 | .given('products exist') 86 | .uponReceiving('a request to get all products') 87 | .withRequest('GET', '/products', (builder) => { 88 | builder.headers({ 89 | Authorization: like('Bearer 2019-01-14T11:34:18.045Z') 90 | }); 91 | }) 92 | .willRespondWith(200, (builder) => { 93 | builder.headers({ 'Content-Type': 'application/json; charset=utf-8' }); 94 | builder.jsonBody(eachLike(expectedProduct)); 95 | }) 96 | .executeTest(async (mockserver) => { 97 | const api = new API(mockserver.url); 98 | // make request to Pact mock server 99 | const products = await api.getAllProducts(); 100 | 101 | // assert that we got the expected response 102 | expect(products).toStrictEqual([new Product(expectedProduct)]); 103 | return; 104 | }); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import 'spectre.css/dist/spectre.min.css'; 4 | import 'spectre.css/dist/spectre-icons.min.css'; 5 | import 'spectre.css/dist/spectre-exp.min.css'; 6 | import Heading from './Heading'; 7 | import Layout from './Layout'; 8 | import API from './api'; 9 | import PropTypes from 'prop-types'; 10 | 11 | const productPropTypes = { 12 | product: PropTypes.shape({ 13 | id: PropTypes.string.isRequired, 14 | name: PropTypes.string.isRequired, 15 | type: PropTypes.string.isRequired 16 | }).isRequired 17 | }; 18 | 19 | function ProductTableRow(props) { 20 | return ( 21 | 22 | {props.product.name} 23 | {props.product.type} 24 | 25 | 34 | See more! 35 | 36 | 37 | 38 | ); 39 | } 40 | ProductTableRow.propTypes = productPropTypes; 41 | 42 | function ProductTable(props) { 43 | const products = props.products.map((p) => ( 44 | 45 | )); 46 | return ( 47 | 48 | 49 | 50 | 51 | 52 | 54 | 55 | {products} 56 |
NameType 53 |
57 | ); 58 | } 59 | 60 | ProductTable.propTypes = { 61 | products: PropTypes.arrayOf(productPropTypes.product) 62 | }; 63 | 64 | class App extends React.Component { 65 | constructor(props) { 66 | super(props); 67 | 68 | this.state = { 69 | loading: true, 70 | searchText: '', 71 | products: [], 72 | visibleProducts: [] 73 | }; 74 | this.onSearchTextChange = this.onSearchTextChange.bind(this); 75 | } 76 | 77 | componentDidMount() { 78 | API.getAllProducts() 79 | .then((r) => { 80 | this.setState({ 81 | loading: false, 82 | products: r 83 | }); 84 | this.determineVisibleProducts(); 85 | }) 86 | .catch(() => { 87 | this.setState({ error: true }); 88 | }); 89 | } 90 | 91 | determineVisibleProducts() { 92 | const findProducts = (search) => { 93 | search = search.toLowerCase(); 94 | return this.state.products.filter( 95 | (p) => 96 | p.id.toLowerCase().includes(search) || 97 | p.name.toLowerCase().includes(search) || 98 | p.type.toLowerCase().includes(search) 99 | ); 100 | }; 101 | this.setState((s) => { 102 | return { 103 | visibleProducts: s.searchText ? findProducts(s.searchText) : s.products 104 | }; 105 | }); 106 | } 107 | 108 | onSearchTextChange(e) { 109 | this.setState({ 110 | searchText: e.target.value 111 | }); 112 | this.determineVisibleProducts(); 113 | } 114 | 115 | render() { 116 | if (this.state.error) { 117 | throw Error('unable to fetch product data'); 118 | } 119 | 120 | return ( 121 | 122 | 123 |
124 | 127 | 134 |
135 | {this.state.loading ? ( 136 |
137 | ) : ( 138 | 139 | )} 140 | 141 | ); 142 | } 143 | } 144 | 145 | App.propTypes = { 146 | history: PropTypes.shape({ 147 | push: PropTypes.func.isRequired 148 | }).isRequired 149 | }; 150 | 151 | export default App; 152 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Why are we using a Makefile? PactFlow has around 30 example consumer and provider projects that show how to use Pact. 2 | # We often use them for demos and workshops, and Makefiles allow us to provide a consistent language and platform agnostic interface 3 | # for each project. You do not need to use Makefiles to use Pact in your own project! 4 | 5 | # Default to the read only token - the read/write token will be present on Travis CI. 6 | # It's set as a secure environment variable in the .travis.yml file 7 | GITHUB_ORG="pactflow" 8 | PACTICIPANT="pactflow-example-consumer" 9 | GITHUB_WEBHOOK_UUID := "04510dc1-7f0a-4ed2-997d-114bfa86f8ad" 10 | PACT_CLI="docker run --rm -v ${PWD}:${PWD} -e PACT_BROKER_BASE_URL -e PACT_BROKER_TOKEN pactfoundation/pact-cli" 11 | 12 | .EXPORT_ALL_VARIABLES: 13 | GIT_COMMIT?=$(shell git rev-parse HEAD) 14 | GIT_BRANCH?=$(shell git rev-parse --abbrev-ref HEAD) 15 | ENVIRONMENT?=production 16 | 17 | # Only deploy from master (to production env) or test (to test env) 18 | ifeq ($(GIT_BRANCH),master) 19 | ENVIRONMENT=production 20 | DEPLOY_TARGET=deploy 21 | else 22 | ifeq ($(GIT_BRANCH),test) 23 | ENVIRONMENT=test 24 | DEPLOY_TARGET=deploy 25 | else 26 | DEPLOY_TARGET=no_deploy 27 | endif 28 | endif 29 | 30 | all: test 31 | 32 | ## ==================== 33 | ## CI tasks 34 | ## ==================== 35 | 36 | ci: test publish_pacts can_i_deploy $(DEPLOY_TARGET) 37 | 38 | # Run the ci target from a developer machine with the environment variables 39 | # set as if it was on CI. 40 | # Use this for quick feedback when playing around with your workflows. 41 | fake_ci: .env 42 | @CI=true \ 43 | REACT_APP_API_BASE_URL=http://localhost:8080 \ 44 | make ci 45 | 46 | publish_pacts: .env 47 | @echo "\n========== STAGE: publish pacts ==========\n" 48 | @"${PACT_CLI}" publish ${PWD}/pacts --consumer-app-version ${GIT_COMMIT} --branch ${GIT_BRANCH} 49 | 50 | ## ===================== 51 | ## Build/test tasks 52 | ## ===================== 53 | 54 | test: .env 55 | @echo "\n========== STAGE: test (pact) ==========\n" 56 | npm run test:pact 57 | 58 | ## ===================== 59 | ## Deploy tasks 60 | ## ===================== 61 | 62 | create_environment: 63 | @"${PACT_CLI}" broker create-environment --name production --production 64 | 65 | deploy: deploy_app record_deployment 66 | 67 | no_deploy: 68 | @echo "Not deploying as not on master branch" 69 | 70 | can_i_deploy: .env 71 | @echo "\n========== STAGE: can-i-deploy? ==========\n" 72 | @"${PACT_CLI}" broker can-i-deploy \ 73 | --pacticipant ${PACTICIPANT} \ 74 | --version ${GIT_COMMIT} \ 75 | --to-environment ${ENVIRONMENT} \ 76 | --retry-while-unknown 30 \ 77 | --retry-interval 10 78 | 79 | deploy_app: 80 | @echo "\n========== STAGE: deploy ==========\n" 81 | @echo "Deploying to ${ENVIRONMENT}" 82 | 83 | record_deployment: .env 84 | @"${PACT_CLI}" broker record-deployment --pacticipant ${PACTICIPANT} --version ${GIT_COMMIT} --environment ${ENVIRONMENT} 85 | 86 | ## ===================== 87 | ## PactFlow set up tasks 88 | ## ===================== 89 | 90 | # This should be called once before creating the webhook 91 | # with the environment variable GITHUB_TOKEN set 92 | create_github_token_secret: 93 | @curl -v -X POST ${PACT_BROKER_BASE_URL}/secrets \ 94 | -H "Authorization: Bearer ${PACT_BROKER_TOKEN}" \ 95 | -H "Content-Type: application/json" \ 96 | -H "Accept: application/hal+json" \ 97 | -d "{\"name\":\"githubCommitStatusToken\",\"description\":\"Github token for updating commit statuses\",\"value\":\"${GITHUB_TOKEN}\"}" 98 | 99 | # This webhook will update the Github commit status for this commit 100 | # so that any PRs will get a status that shows what the status of 101 | # the pact is. 102 | create_or_update_github_commit_status_webhook: 103 | @"${PACT_CLI}" \ 104 | broker create-or-update-webhook \ 105 | 'https://api.github.com/repos/pactflow/example-consumer/statuses/$${pactbroker.consumerVersionNumber}' \ 106 | --header 'Content-Type: application/json' 'Accept: application/vnd.github.v3+json' 'Authorization: token $${user.githubCommitStatusToken}' \ 107 | --request POST \ 108 | --data @${PWD}/pactflow/github-commit-status-webhook.json \ 109 | --uuid ${GITHUB_WEBHOOK_UUID} \ 110 | --consumer ${PACTICIPANT} \ 111 | --contract-published \ 112 | --provider-verification-published \ 113 | --description "Github commit status webhook for ${PACTICIPANT}" 114 | 115 | test_github_webhook: 116 | @curl -v -X POST ${PACT_BROKER_BASE_URL}/webhooks/${GITHUB_WEBHOOK_UUID}/execute -H "Authorization: Bearer ${PACT_BROKER_TOKEN}" 117 | 118 | 119 | ## ====================== 120 | ## Misc 121 | ## ====================== 122 | 123 | .env: 124 | touch .env 125 | 126 | output: 127 | mkdir -p ./pacts 128 | touch ./pacts/tmp 129 | 130 | clean: output 131 | rm pacts/* 132 | --------------------------------------------------------------------------------