diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..c4031e2572 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,164 @@ +# Ansible AWX/Tower V2 + +Hi there! We're excited to have you as a contributor. + +Have questions about this document or anything not covered here? Feel free to reach out to any of the contributors of this repository found here: https://github.com/ansible/awx-pf/graphs/contributors + +## Table of contents + +* [Things to know prior to submitting code](#things-to-know-prior-to-submitting-code) +* [Setting up your development environment](#setting-up-your-development-environment) + * [Prerequisites](#prerequisites) + * [Node and npm](#node-and-npm) +* [Build the user interface](#build-the-user-interface) +* [Accessing the AWX web interface](#accessing-the-awx-web-interface) +* [Working with React](#working-with-react) + * [Class constructors vs Class properties](#class-constructors-vs-class-properties) + * [Binding](#binding) +* [Testing](#testing) + * [Jest](#jest) + * [Enzyme](#enzyme) + +## Things to know prior to submitting code + +- All code submissions are done through pull requests against the `master` branch. +- If collaborating with someone else on the same branch, please use `--force-with-lease` instead of `--force` when pushing up code. This will prevent you from accidentally overwriting commits pushed by someone else. For more information, see https://git-scm.com/docs/git-push#git-push---force-with-leaseltrefnamegt + +## Setting up your development environment + +The UI is built using [ReactJS](https://reactjs.org/docs/getting-started.html) and [Patternfly](https://www.patternfly.org/). + +### Prerequisites + +#### Node and npm + +The AWX UI requires the following: + +- Node 8.x LTS +- NPM 6.x LTS + +Run the following to install all the dependencies: +```bash +(host) $ npm run install +``` + +#### Build the User Interface + +Run the following to build the AWX UI: + +```bash +(host) $ npm run start +``` + +## Accessing the AWX web interface + +You can now log into the AWX web interface at [https://127.0.0.1:3001](https://127.0.0.1:3001). + +## Working with React +### Class constructors vs Class properties +It is good practice to use constructor-bound instance methods rather than methods as class properties. Methods as arrow functions provide lexical scope and are bound to the Component class instance instead of the class itself. This makes it so we cannot easily test a Component's methods without invoking an instance of the Component and calling the method directly within our tests. + +BAD: + ```javascript + class MyComponent extends React.Component { + constructor(props) { + super(props); + } + + myEventHandler = () => { + // do a thing + } + } + ``` +GOOD: + ```javascript + class MyComponent extends React.Component { + constructor(props) { + super(props); + this.myEventHandler = this.myEventHandler.bind(this); + } + + myEventHandler() { + // do a thing + } + } + ``` + +### Binding +It is good practice to bind our class methods within our class constructor method for the following reasons: + 1. Avoid defining the method every time `render()` is called. + 2. [Performance advantages](https://stackoverflow.com/a/44844916). + 3. Ease of [testing](https://github.com/airbnb/enzyme/issues/365). + +### Component Lifecycle + +A React Component has various [lifecylce methods](http://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/). Understanding the basic lifecycle of a Component will help you determine which method to utilize for your specific need. Below are some general guidelines: + +BAD: + Use `render` method to make asynchronous calls. + ```javascript + render () { + const { data } = await api.get(API_CONFIG); + + return(
`Hello, ${data.usename}!`
); + } + ``` +GOOD: + Use `componentDidMount()` method to make asynchronous calls to retrieve data a Componenet may need. + ```javascript + async componentDidMount () { + try { + const { data } = await api.get(API_CONFIG); + this.setState({ data }); + } catch (error) { + this.setState({ error }); + } + } + + render() { + return(
`Hello, ${this.state.data.usename}!`
) + } + ``` +## Testing +All code, new or otherwise, should have at least 80% test coverage. +### Jest +We use (Jest)[https://jestjs.io/] for our JS testing framework. +Like many other JS test frameworks (Karma, Mocha, etc), Jest includes their own `spyOn` method as a way for us to test our class methods. +```javascript + const spy = jest.spyOn(MyButton.prototype, 'onSubmit'); +``` + +Jest also allows us to mock the data we expect from an external dependency, such as an API. +```javascript + axios.get.mockImplementation((endpoint) => { + if (endpoint === '/api/v2/config') { + return new Promise((resolve, reject) => { + resolve({ data: { foo: 'bar' }); + }); + } + else { + return 'get results'; + } + }); +``` + +### Enzyme +We use (Enzyme)[https://airbnb.io/enzyme/] to test our React Components. +### Mounting Components wrapped with withRouter +If you are testing a Component wrapped in React Router's `withRouter` class, you can mount the component by wrapping it with the `` component. +```javascript + test('initially renders succesfully', () => { + mount( + + + + ); + }); +``` +You can test the wrapped Component's methods like so: +```javascript + const spy = jest.spyOn(OrganizationAdd.WrappedComponent.prototype, 'onCancel'); +``` diff --git a/__mocks__/axios.js b/__mocks__/axios.js deleted file mode 100644 index aad497303f..0000000000 --- a/__mocks__/axios.js +++ /dev/null @@ -1,38 +0,0 @@ -import * as endpoints from '../src/endpoints'; - -const axios = require('axios'); -const mockAPIConfigData = { - data: { - custom_virtualenvs: ['foo', 'bar'], - ansible_version: "2.7.2", - version: "2.1.1-40-g2758a3848" - } -}; -jest.genMockFromModule('axios'); - -axios.create = jest.fn(() => axios); -axios.get = jest.fn(() => axios); -axios.post = jest.fn(() => axios); -axios.create.mockReturnValue({ - get: axios.get, - post: axios.post -}); -axios.get.mockImplementation((endpoint) => { - if (endpoint === endpoints.API_CONFIG) { - return new Promise((resolve, reject) => { - resolve(mockAPIConfigData); - }); - } - else { - return 'get results'; - } -}); -axios.post.mockResolvedValue('post results'); - -axios.customClearMocks = () => { - axios.create.mockClear(); - axios.get.mockClear(); - axios.post.mockClear(); -}; - -module.exports = axios; diff --git a/__tests__/App.test.jsx b/__tests__/App.test.jsx index c575139c57..dd6cc982ca 100644 --- a/__tests__/App.test.jsx +++ b/__tests__/App.test.jsx @@ -1,72 +1,124 @@ import React from 'react'; -import { HashRouter as Router } from 'react-router-dom'; -import { shallow, mount } from 'enzyme'; -import App from '../src/App'; -import api from '../src/api'; -import { API_LOGOUT, API_CONFIG } from '../src/endpoints'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@lingui/react'; -import Dashboard from '../src/pages/Dashboard'; -import Login from '../src/pages/Login'; +import { mount, shallow } from 'enzyme'; import { asyncFlush } from '../jest.setup'; +import App from '../src/App'; + const DEFAULT_ACTIVE_GROUP = 'views_group'; -const DEFAULT_ACTIVE_ITEM = 'views_group_dashboard'; describe('', () => { - test('renders without crashing', () => { - const appWrapper = shallow(); + test('expected content is rendered', () => { + const appWrapper = mount( + + + ( + routeGroups.map(({ groupId }) => (
)) + )} + /> + + + ); + + // page components expect(appWrapper.length).toBe(1); + expect(appWrapper.find('PageHeader').length).toBe(1); + expect(appWrapper.find('PageSidebar').length).toBe(1); + + // sidebar groups and route links + expect(appWrapper.find('NavExpandableGroup').length).toBe(2); + expect(appWrapper.find('a[href="/#/foo"]').length).toBe(1); + expect(appWrapper.find('a[href="/#/bar"]').length).toBe(1); + expect(appWrapper.find('a[href="/#/fiz"]').length).toBe(1); + + // inline render + expect(appWrapper.find('#group_one').length).toBe(1); + expect(appWrapper.find('#group_two').length).toBe(1); }); - test('renders login page when not authenticated', () => { - api.isAuthenticated = jest.fn(); - api.isAuthenticated.mockReturnValue(false); + test('opening the about modal renders prefetched config data', async (done) => { + const ansible_version = '111'; + const version = '222'; - const appWrapper = mount(); + const getConfig = jest.fn(() => Promise.resolve({ data: { ansible_version, version} })); + const api = { getConfig }; - const login = appWrapper.find(Login); - expect(login.length).toBe(1); - const dashboard = appWrapper.find(Dashboard); - expect(dashboard.length).toBe(0); - }); + const wrapper = mount( + + + + + + ); - test('renders dashboard when authenticated', () => { - api.isAuthenticated = jest.fn(); - api.isAuthenticated.mockReturnValue(true); + await asyncFlush(); + expect(getConfig).toHaveBeenCalledTimes(1); - const appWrapper = mount(); + // open about modal + const aboutDropdown = 'Dropdown QuestionCircleIcon'; + const aboutButton = 'DropdownItem li button'; + const aboutModalContent = 'AboutModalBoxContent'; + const aboutModalClose = 'button[aria-label="Close Dialog"]'; - const dashboard = appWrapper.find(Dashboard); - expect(dashboard.length).toBe(1); - const login = appWrapper.find(Login); - expect(login.length).toBe(0); + expect(wrapper.find(aboutModalContent)).toHaveLength(0); + wrapper.find(aboutDropdown).simulate('click'); + wrapper.find(aboutButton).simulate('click'); + wrapper.update(); + + // check about modal content + const content = wrapper.find(aboutModalContent); + expect(content).toHaveLength(1); + expect(content.find('dd').text()).toContain(ansible_version); + expect(content.find('pre').text()).toContain(`< Tower ${version} >`); + + // close about modal + wrapper.find(aboutModalClose).simulate('click'); + expect(wrapper.find(aboutModalContent)).toHaveLength(0); + + done(); }); test('onNavToggle sets state.isNavOpen to opposite', () => { - const appWrapper = shallow(); - expect(appWrapper.state().isNavOpen).toBe(true); - appWrapper.instance().onNavToggle(); - expect(appWrapper.state().isNavOpen).toBe(false); + const appWrapper = shallow(); + const { onNavToggle } = appWrapper.instance(); + + [true, false, true, false, true].forEach(expected => { + expect(appWrapper.state().isNavOpen).toBe(expected); + onNavToggle(); + }); }); - test('onLogoClick sets selected nav back to defaults', () => { - const appWrapper = shallow(); - appWrapper.setState({ activeGroup: 'foo', activeItem: 'bar' }); - expect(appWrapper.state().activeItem).toBe('bar'); - expect(appWrapper.state().activeGroup).toBe('foo'); - appWrapper.instance().onLogoClick(); - expect(appWrapper.state().activeGroup).toBe(DEFAULT_ACTIVE_GROUP); - }); + test('onLogout makes expected call to api client', async (done) => { + const logout = jest.fn(() => Promise.resolve()); + const api = { logout }; - test('api.logout called from logout button', async () => { - api.get = jest.fn().mockImplementation(() => Promise.resolve({})); - const appWrapper = shallow(); - appWrapper.instance().onDevLogout(); - appWrapper.setState({ activeGroup: 'foo', activeItem: 'bar' }); - expect(api.get).toHaveBeenCalledWith(API_LOGOUT); + const appWrapper = shallow(); + + appWrapper.instance().onLogout(); await asyncFlush(); - expect(appWrapper.state().activeItem).toBe(DEFAULT_ACTIVE_ITEM); - expect(appWrapper.state().activeGroup).toBe(DEFAULT_ACTIVE_GROUP); + expect(api.logout).toHaveBeenCalledTimes(1); + + done(); }); test('Componenet makes REST call to API_CONFIG endpoint when mounted', () => { diff --git a/__tests__/api.test.js b/__tests__/api.test.js index b05e8fdcbd..e9276e8c8a 100644 --- a/__tests__/api.test.js +++ b/__tests__/api.test.js @@ -1,80 +1,129 @@ -import mockAxios from 'axios'; import APIClient from '../src/api'; -import * as endpoints from '../src/endpoints'; -const CSRF_COOKIE_NAME = 'csrftoken'; -const CSRF_HEADER_NAME = 'X-CSRFToken'; - -const LOGIN_CONTENT_TYPE = 'application/x-www-form-urlencoded'; +const invalidCookie = 'invalid'; +const validLoggedOutCookie = 'current_user=%7B%22id%22%3A1%2C%22type%22%3A%22user%22%2C%22url%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2F%22%2C%22related%22%3A%7B%22admin_of_organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fadmin_of_organizations%2F%22%2C%22authorized_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fauthorized_tokens%2F%22%2C%22roles%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Froles%2F%22%2C%22organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Forganizations%2F%22%2C%22access_list%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Faccess_list%2F%22%2C%22teams%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fteams%2F%22%2C%22tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Ftokens%2F%22%2C%22personal_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fpersonal_tokens%2F%22%2C%22credentials%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fcredentials%2F%22%2C%22activity_stream%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Factivity_stream%2F%22%2C%22projects%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fprojects%2F%22%7D%2C%22summary_fields%22%3A%7B%7D%2C%22created%22%3A%222018-10-19T16%3A30%3A59.141963Z%22%2C%22username%22%3A%22admin%22%2C%22first_name%22%3A%22%22%2C%22last_name%22%3A%22%22%2C%22email%22%3A%22%22%2C%22is_superuser%22%3Atrue%2C%22is_system_auditor%22%3Afalse%2C%22ldap_dn%22%3A%22%22%2C%22external_account%22%3Anull%2C%22auth%22%3A%5B%5D%7D; userLoggedIn=false; csrftoken=lhOHpLQUFHlIVqx8CCZmEpdEZAz79GIRBIT3asBzTbPE7HS7wizt7WBsgJClz8Ge'; +const validLoggedInCookie = 'current_user=%7B%22id%22%3A1%2C%22type%22%3A%22user%22%2C%22url%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2F%22%2C%22related%22%3A%7B%22admin_of_organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fadmin_of_organizations%2F%22%2C%22authorized_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fauthorized_tokens%2F%22%2C%22roles%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Froles%2F%22%2C%22organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Forganizations%2F%22%2C%22access_list%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Faccess_list%2F%22%2C%22teams%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fteams%2F%22%2C%22tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Ftokens%2F%22%2C%22personal_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fpersonal_tokens%2F%22%2C%22credentials%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fcredentials%2F%22%2C%22activity_stream%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Factivity_stream%2F%22%2C%22projects%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fprojects%2F%22%7D%2C%22summary_fields%22%3A%7B%7D%2C%22created%22%3A%222018-10-19T16%3A30%3A59.141963Z%22%2C%22username%22%3A%22admin%22%2C%22first_name%22%3A%22%22%2C%22last_name%22%3A%22%22%2C%22email%22%3A%22%22%2C%22is_superuser%22%3Atrue%2C%22is_system_auditor%22%3Afalse%2C%22ldap_dn%22%3A%22%22%2C%22external_account%22%3Anull%2C%22auth%22%3A%5B%5D%7D; userLoggedIn=true; csrftoken=lhOHpLQUFHlIVqx8CCZmEpdEZAz79GIRBIT3asBzTbPE7HS7wizt7WBsgJClz8Ge'; describe('APIClient (api.js)', () => { - afterEach(() => { - mockAxios.customClearMocks(); + test('isAuthenticated returns false when cookie is invalid', () => { + APIClient.getCookie = jest.fn(() => invalidCookie); + + const api = new APIClient(); + expect(api.isAuthenticated()).toBe(false); }); - test('constructor calls axios create', () => { - const csrfObj = { - xsrfCookieName: CSRF_COOKIE_NAME, - xsrfHeaderName: CSRF_HEADER_NAME - }; - expect(mockAxios.create).toHaveBeenCalledTimes(1); - expect(mockAxios.create).toHaveBeenCalledWith(csrfObj); - expect(APIClient.http).toHaveProperty('get'); + test('isAuthenticated returns false when cookie is unauthenticated', () => { + APIClient.getCookie = jest.fn(() => validLoggedOutCookie); + + const api = new APIClient(); + expect(api.isAuthenticated()).toBe(false); }); - test('isAuthenticated checks authentication and sets cookie from document', () => { - APIClient.getCookie = jest.fn(); - const invalidCookie = 'invalid'; - const validLoggedOutCookie = 'current_user=%7B%22id%22%3A1%2C%22type%22%3A%22user%22%2C%22url%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2F%22%2C%22related%22%3A%7B%22admin_of_organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fadmin_of_organizations%2F%22%2C%22authorized_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fauthorized_tokens%2F%22%2C%22roles%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Froles%2F%22%2C%22organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Forganizations%2F%22%2C%22access_list%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Faccess_list%2F%22%2C%22teams%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fteams%2F%22%2C%22tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Ftokens%2F%22%2C%22personal_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fpersonal_tokens%2F%22%2C%22credentials%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fcredentials%2F%22%2C%22activity_stream%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Factivity_stream%2F%22%2C%22projects%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fprojects%2F%22%7D%2C%22summary_fields%22%3A%7B%7D%2C%22created%22%3A%222018-10-19T16%3A30%3A59.141963Z%22%2C%22username%22%3A%22admin%22%2C%22first_name%22%3A%22%22%2C%22last_name%22%3A%22%22%2C%22email%22%3A%22%22%2C%22is_superuser%22%3Atrue%2C%22is_system_auditor%22%3Afalse%2C%22ldap_dn%22%3A%22%22%2C%22external_account%22%3Anull%2C%22auth%22%3A%5B%5D%7D; userLoggedIn=false; csrftoken=lhOHpLQUFHlIVqx8CCZmEpdEZAz79GIRBIT3asBzTbPE7HS7wizt7WBsgJClz8Ge'; - const validLoggedInCookie = 'current_user=%7B%22id%22%3A1%2C%22type%22%3A%22user%22%2C%22url%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2F%22%2C%22related%22%3A%7B%22admin_of_organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fadmin_of_organizations%2F%22%2C%22authorized_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fauthorized_tokens%2F%22%2C%22roles%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Froles%2F%22%2C%22organizations%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Forganizations%2F%22%2C%22access_list%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Faccess_list%2F%22%2C%22teams%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fteams%2F%22%2C%22tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Ftokens%2F%22%2C%22personal_tokens%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fpersonal_tokens%2F%22%2C%22credentials%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fcredentials%2F%22%2C%22activity_stream%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Factivity_stream%2F%22%2C%22projects%22%3A%22%2Fapi%2Fv2%2Fusers%2F1%2Fprojects%2F%22%7D%2C%22summary_fields%22%3A%7B%7D%2C%22created%22%3A%222018-10-19T16%3A30%3A59.141963Z%22%2C%22username%22%3A%22admin%22%2C%22first_name%22%3A%22%22%2C%22last_name%22%3A%22%22%2C%22email%22%3A%22%22%2C%22is_superuser%22%3Atrue%2C%22is_system_auditor%22%3Afalse%2C%22ldap_dn%22%3A%22%22%2C%22external_account%22%3Anull%2C%22auth%22%3A%5B%5D%7D; userLoggedIn=true; csrftoken=lhOHpLQUFHlIVqx8CCZmEpdEZAz79GIRBIT3asBzTbPE7HS7wizt7WBsgJClz8Ge'; - APIClient.getCookie.mockReturnValue(invalidCookie); - expect(APIClient.isAuthenticated()).toBe(false); - APIClient.getCookie.mockReturnValue(validLoggedOutCookie); - expect(APIClient.isAuthenticated()).toBe(false); - APIClient.getCookie.mockReturnValue(validLoggedInCookie); - expect(APIClient.isAuthenticated()).toBe(true); + test('isAuthenticated returns true when cookie is valid and authenticated', () => { + APIClient.getCookie = jest.fn(() => validLoggedInCookie); + + const api = new APIClient(); + expect(api.isAuthenticated()).toBe(true); }); - test('login calls get and post to login route, and sets cookie from document', (done) => { - const un = 'foo'; - const pw = 'bar'; - const next = 'baz'; - const headers = { 'Content-Type': LOGIN_CONTENT_TYPE }; - const data = `username=${un}&password=${pw}&next=${next}`; - APIClient.setCookie = jest.fn(); - APIClient.login(un, pw, next).then(() => { - expect(mockAxios.get).toHaveBeenCalledTimes(1); - expect(mockAxios.get).toHaveBeenCalledWith(endpoints.API_LOGIN, { headers }); - expect(mockAxios.post).toHaveBeenCalledTimes(1); - expect(mockAxios.post).toHaveBeenCalledWith(endpoints.API_LOGIN, data, { headers }); - done(); - }); + test('login calls get and post with expected content headers', async (done) => { + const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; + + const createPromise = () => Promise.resolve(); + const mockHttp = ({ get: jest.fn(createPromise), post: jest.fn(createPromise) }); + + const api = new APIClient(mockHttp); + await api.login('username', 'password'); + + expect(mockHttp.get).toHaveBeenCalledTimes(1); + expect(mockHttp.get.mock.calls[0]).toContainEqual({ headers }); + + expect(mockHttp.post).toHaveBeenCalledTimes(1); + expect(mockHttp.post.mock.calls[0]).toContainEqual({ headers }); + + done(); }); - test('login encodes uri components for username, password and redirect', (done) => { - const un = '/foo/'; - const pw = '/bar/'; - const next = '/baz/'; - const headers = { 'Content-Type': LOGIN_CONTENT_TYPE }; - const data = `username=${encodeURIComponent(un)}&password=${encodeURIComponent(pw)}&next=${encodeURIComponent(next)}`; - APIClient.login(un, pw, next).then(() => { - expect(mockAxios.post).toHaveBeenCalledTimes(1); - expect(mockAxios.post).toHaveBeenCalledWith(endpoints.API_LOGIN, data, { headers }); - done(); - }); + test('login sends expected data', async (done) => { + const createPromise = () => Promise.resolve(); + const mockHttp = ({ get: jest.fn(createPromise), post: jest.fn(createPromise) }); + + const api = new APIClient(mockHttp); + await api.login('foo', 'bar'); + await api.login('foo', 'bar', 'baz'); + + expect(mockHttp.post).toHaveBeenCalledTimes(2); + expect(mockHttp.post.mock.calls[0]).toContainEqual('username=foo&password=bar&next=%2Fapi%2Fv2%2Fconfig%2F'); + expect(mockHttp.post.mock.calls[1]).toContainEqual('username=foo&password=bar&next=baz'); + + done(); }); - test('login redirect defaults to config route when not explicitly passed', (done) => { - const un = 'foo'; - const pw = 'bar'; - const headers = { 'Content-Type': LOGIN_CONTENT_TYPE }; - const data = `username=${un}&password=${pw}&next=${encodeURIComponent(endpoints.API_CONFIG)}`; - APIClient.setCookie = jest.fn(); - APIClient.login(un, pw).then(() => { - expect(mockAxios.post).toHaveBeenCalledTimes(1); - expect(mockAxios.post).toHaveBeenCalledWith(endpoints.API_LOGIN, data, { headers }); - done(); - }); + test('logout calls expected http method', async (done) => { + const createPromise = () => Promise.resolve(); + const mockHttp = ({ get: jest.fn(createPromise) }); + + const api = new APIClient(mockHttp); + await api.logout(); + + expect(mockHttp.get).toHaveBeenCalledTimes(1); + + done(); }); + test('getConfig calls expected http method', async (done) => { + const createPromise = () => Promise.resolve(); + const mockHttp = ({ get: jest.fn(createPromise) }); + + const api = new APIClient(mockHttp); + await api.getConfig(); + + expect(mockHttp.get).toHaveBeenCalledTimes(1); + + done(); + }); + + test('getOrganizations calls http method with expected data', async (done) => { + const createPromise = () => Promise.resolve(); + const mockHttp = ({ get: jest.fn(createPromise) }); + const api = new APIClient(mockHttp); + + const defaultParams = {}; + const testParams = { foo: 'bar' }; + + await api.getOrganizations(testParams); + await api.getOrganizations(); + + expect(mockHttp.get).toHaveBeenCalledTimes(2); + expect(mockHttp.get.mock.calls[0][1]).toEqual({ params: testParams }); + expect(mockHttp.get.mock.calls[1][1]).toEqual({ params: defaultParams }); + done(); + }); + + test('createOrganization calls http method with expected data', async (done) => { + const createPromise = () => Promise.resolve(); + const mockHttp = ({ post: jest.fn(createPromise) }); + + const api = new APIClient(mockHttp); + const data = { name: 'test '}; + await api.createOrganization(data); + + expect(mockHttp.post).toHaveBeenCalledTimes(1); + expect(mockHttp.post.mock.calls[0][1]).toEqual(data); + + done(); + }); + + test('getOrganizationDetails calls http method with expected data', async (done) => { + const createPromise = () => Promise.resolve(); + const mockHttp = ({ get: jest.fn(createPromise) }); + + const api = new APIClient(mockHttp); + await api.getOrganizationDetails(99); + + expect(mockHttp.get).toHaveBeenCalledTimes(1); + expect(mockHttp.get.mock.calls[0][0]).toContain('99'); + + done(); + }); }); diff --git a/__tests__/components/About.test.jsx b/__tests__/components/About.test.jsx index e20fe77057..c6d322a55d 100644 --- a/__tests__/components/About.test.jsx +++ b/__tests__/components/About.test.jsx @@ -1,8 +1,6 @@ import React from 'react'; import { mount } from 'enzyme'; import { I18nProvider } from '@lingui/react'; -import api from '../../src/api'; -import { API_CONFIG } from '../../src/endpoints'; import About from '../../src/components/About'; describe('', () => { @@ -19,16 +17,16 @@ describe('', () => { aboutWrapper.unmount(); }); - test('close button calls onAboutModalClose', () => { - const onAboutModalClose = jest.fn(); + test('close button calls onClose handler', () => { + const onClose = jest.fn(); aboutWrapper = mount( - + ); closeButton = aboutWrapper.find('AboutModalBoxCloseButton Button'); closeButton.simulate('click'); - expect(onAboutModalClose).toBeCalled(); + expect(onClose).toBeCalled(); aboutWrapper.unmount(); }); }); diff --git a/__tests__/components/AnsibleSelect.test.jsx b/__tests__/components/AnsibleSelect.test.jsx index d2eacf2129..5a225b804d 100644 --- a/__tests__/components/AnsibleSelect.test.jsx +++ b/__tests__/components/AnsibleSelect.test.jsx @@ -29,4 +29,16 @@ describe('', () => { wrapper.find('select').simulate('change'); expect(spy).toHaveBeenCalled(); }); -}); \ No newline at end of file + test('content not rendered when data property is falsey', () => { + const wrapper = mount( + { }} + labelName={label} + data={null} + /> + ); + expect(wrapper.find('FormGroup')).toHaveLength(0); + expect(wrapper.find('Select')).toHaveLength(0); + }); +}); diff --git a/__tests__/components/Background.test.jsx b/__tests__/components/Background.test.jsx new file mode 100644 index 0000000000..fdee92b49d --- /dev/null +++ b/__tests__/components/Background.test.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { mount } from 'enzyme'; + +import { I18nProvider } from '@lingui/react'; +import Background from '../../src/components/Background'; + +describe('Background', () => { + test('renders the expected content', () => { + const wrapper = mount(
); + expect(wrapper).toHaveLength(1); + expect(wrapper.find('BackgroundImage')).toHaveLength(1); + expect(wrapper.find('#test')).toHaveLength(1); + }); +}); diff --git a/__tests__/components/ConditionalRedirect.test.jsx b/__tests__/components/ConditionalRedirect.test.jsx deleted file mode 100644 index c437ae6971..0000000000 --- a/__tests__/components/ConditionalRedirect.test.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import { - Route, - Redirect -} from 'react-router-dom'; -import { shallow } from 'enzyme'; -import ConditionalRedirect from '../../src/components/ConditionalRedirect'; - -describe('', () => { - test('renders Redirect when shouldRedirect is passed truthy func', () => { - const truthyFunc = () => true; - const shouldHaveRedirectChild = shallow( - truthyFunc()} - /> - ); - const redirectChild = shouldHaveRedirectChild.find(Redirect); - expect(redirectChild.length).toBe(1); - const routeChild = shouldHaveRedirectChild.find(Route); - expect(routeChild.length).toBe(0); - }); - - test('renders Route when shouldRedirect is passed falsy func', () => { - const falsyFunc = () => false; - const shouldHaveRouteChild = shallow( - falsyFunc()} - /> - ); - const routeChild = shouldHaveRouteChild.find(Route); - expect(routeChild.length).toBe(1); - const redirectChild = shouldHaveRouteChild.find(Redirect); - expect(redirectChild.length).toBe(0); - }); -}); diff --git a/__tests__/components/DataListToolbar.test.jsx b/__tests__/components/DataListToolbar.test.jsx index c1c3a4e3af..facc3b1128 100644 --- a/__tests__/components/DataListToolbar.test.jsx +++ b/__tests__/components/DataListToolbar.test.jsx @@ -4,7 +4,6 @@ import { I18nProvider } from '@lingui/react'; import DataListToolbar from '../../src/components/DataListToolbar'; describe('', () => { - const columns = [{ name: 'Name', key: 'name', isSortable: true }]; let toolbar; afterEach(() => { @@ -15,6 +14,8 @@ describe('', () => { }); test('it triggers the expected callbacks', () => { + const columns = [{ name: 'Name', key: 'name', isSortable: true }]; + const search = 'button[aria-label="Search"]'; const searchTextInput = 'input[aria-label="Search text input"]'; const selectAll = 'input[aria-label="Select all"]'; @@ -28,6 +29,7 @@ describe('', () => { ', () => { expect(onSearch).toHaveBeenCalledTimes(1); expect(onSearch).toBeCalledWith('test-321'); }); + + test('dropdown items sortable columns work', () => { + const sortDropdownToggleSelector = '.pf-l-toolbar__group.sortDropdownGroup .pf-l-toolbar__item button'; + const sortDropdownItemsSelector = '.pf-l-toolbar__group.sortDropdownGroup button.pf-c-dropdown__menu-item'; + const searchDropdownToggleSelector = '.pf-c-dropdown.searchKeyDropdown .pf-c-dropdown__toggle'; + const searchDropdownItemsSelector = '.pf-c-dropdown.searchKeyDropdown button.pf-c-dropdown__menu-item'; + + const multipleColumns = [ + { name: 'Foo', key: 'foo', isSortable: true }, + { name: 'Bar', key: 'bar', isSortable: true }, + { name: 'Bakery', key: 'bakery', isSortable: true }, + { name: 'Baz', key: 'baz' } + ]; + + const onSearch = jest.fn(); + const onSort = jest.fn(); + const onSelectAll = jest.fn(); + + toolbar = mount( + + + + ); + const sortDropdownToggle = toolbar.find(sortDropdownToggleSelector); + expect(sortDropdownToggle.length).toBe(2); + sortDropdownToggle.at(1).simulate('click'); + sortDropdownToggle.at(0).simulate('click'); + toolbar.update(); + + const sortDropdownItems = toolbar.find(sortDropdownItemsSelector); + expect(sortDropdownItems.length).toBe(2); + + const mockedSortEvent = { target: { innerText: 'Bar' } }; + sortDropdownItems.at(0).simulate('click', mockedSortEvent); + toolbar = mount( + + + + ); + toolbar.update(); + + const sortDropdownToggleDescending = toolbar.find(sortDropdownToggleSelector); + expect(sortDropdownToggleDescending.length).toBe(2); + sortDropdownToggleDescending.at(1).simulate('click'); + sortDropdownToggleDescending.at(0).simulate('click'); + toolbar.update(); + + const sortDropdownItemsDescending = toolbar.find(sortDropdownItemsSelector); + expect(sortDropdownItemsDescending.length).toBe(2); + + const mockedSortEventDescending = { target: { innerText: 'Bar' } }; + sortDropdownItems.at(0).simulate('click', mockedSortEventDescending); + toolbar.update(); + + const searchDropdownToggle = toolbar.find(searchDropdownToggleSelector); + expect(searchDropdownToggle.length).toBe(1); + searchDropdownToggle.at(0).simulate('click'); + toolbar.update(); + + const searchDropdownItems = toolbar.find(searchDropdownItemsSelector); + expect(searchDropdownItems.length).toBe(3); + + const mockedSearchEvent = { target: { innerText: 'Bar' } }; + searchDropdownItems.at(0).simulate('click', mockedSearchEvent); + }); + + test('it displays correct sort icon', () => { + const downNumericIconSelector = 'SortNumericDownIcon'; + const upNumericIconSelector = 'SortNumericUpIcon'; + const downAlphaIconSelector = 'SortAlphaDownIcon'; + const upAlphaIconSelector = 'SortAlphaUpIcon'; + + const numericColumns = [{ name: 'ID', key: 'id', isSortable: true, isNumeric: true }]; + const alphaColumns = [{ name: 'Name', key: 'name', isSortable: true, isNumeric: false }]; + const onSearch = jest.fn(); + const onSort = jest.fn(); + const onSelectAll = jest.fn(); + + toolbar = mount( + + + + ); + + const downNumericIcon = toolbar.find(downNumericIconSelector); + expect(downNumericIcon.length).toBe(1); + + toolbar = mount( + + + + ); + + const upNumericIcon = toolbar.find(upNumericIconSelector); + expect(upNumericIcon.length).toBe(1); + + toolbar = mount( + + + + ); + + const downAlphaIcon = toolbar.find(downAlphaIconSelector); + expect(downAlphaIcon.length).toBe(1); + + toolbar = mount( + + + + ); + + const upAlphaIcon = toolbar.find(upAlphaIconSelector); + expect(upAlphaIcon.length).toBe(1); + }); }); diff --git a/__tests__/components/HelpDropdown.test.jsx b/__tests__/components/HelpDropdown.test.jsx deleted file mode 100644 index b2b9da1df1..0000000000 --- a/__tests__/components/HelpDropdown.test.jsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react'; -import { mount } from 'enzyme'; -import { I18nProvider } from '@lingui/react'; -import HelpDropdown from '../../src/components/HelpDropdown'; - -let questionCircleIcon; -let dropdownWrapper; -let dropdownComponentInstance; -let dropdownToggle; -let dropdownItems; -let dropdownItem; - -beforeEach(() => { - dropdownWrapper = mount( - - - - ); - dropdownComponentInstance = dropdownWrapper.find(HelpDropdown).instance(); -}); - -afterEach(() => { - dropdownWrapper.unmount(); -}); - -describe('', () => { - test('initially renders without crashing', () => { - expect(dropdownWrapper.length).toBe(1); - expect(dropdownComponentInstance.state.isOpen).toEqual(false); - expect(dropdownComponentInstance.state.showAboutModal).toEqual(false); - questionCircleIcon = dropdownWrapper.find('QuestionCircleIcon'); - expect(questionCircleIcon.length).toBe(1); - }); - - test('renders two dropdown items', () => { - dropdownComponentInstance.setState({ isOpen: true }); - dropdownWrapper.update(); - dropdownItems = dropdownWrapper.find('DropdownItem'); - expect(dropdownItems.length).toBe(2); - const dropdownTexts = dropdownItems.map(item => item.text()); - expect(dropdownTexts).toEqual(['Help', 'About']); - }); - - test('onToggle sets state.isOpen to opposite', () => { - dropdownComponentInstance.setState({ isOpen: true }); - dropdownWrapper.update(); - dropdownToggle = dropdownWrapper.find('DropdownToggle > DropdownToggle'); - dropdownToggle.simulate('click'); - expect(dropdownComponentInstance.state.isOpen).toEqual(false); - }); - - test('about dropdown item sets state.showAboutModal to true', () => { - dropdownComponentInstance.setState({ isOpen: true }); - dropdownWrapper.update(); - dropdownItem = dropdownWrapper.find('DropdownItem a').at(1); - dropdownItem.simulate('click'); - expect(dropdownComponentInstance.state.showAboutModal).toEqual(true); - }); - - test('onAboutModalClose sets state.showAboutModal to false', () => { - dropdownComponentInstance.setState({ showAboutModal: true }); - dropdownWrapper.update(); - const aboutModal = dropdownWrapper.find('AboutModal'); - aboutModal.find('AboutModalBoxCloseButton Button').simulate('click'); - expect(dropdownComponentInstance.state.showAboutModal).toEqual(false); - }); -}); - diff --git a/__tests__/components/LogoutButton.test.jsx b/__tests__/components/LogoutButton.test.jsx deleted file mode 100644 index aaded3cd3f..0000000000 --- a/__tests__/components/LogoutButton.test.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import { mount } from 'enzyme'; -import { I18nProvider } from '@lingui/react'; -import LogoutButton from '../../src/components/LogoutButton'; - -let buttonWrapper; -let buttonElem; -let userIconElem; - -const findChildren = () => { - buttonElem = buttonWrapper.find('Button'); - userIconElem = buttonWrapper.find('UserIcon'); -}; - -describe('', () => { - test('initially renders without crashing', () => { - const onDevLogout = jest.fn(); - buttonWrapper = mount( - - - - ); - findChildren(); - expect(buttonWrapper.length).toBe(1); - expect(buttonElem.length).toBe(1); - expect(userIconElem.length).toBe(1); - buttonElem.simulate('keyDown', { keyCode: 40, which: 40 }); - expect(onDevLogout).toHaveBeenCalledTimes(0); - buttonElem.simulate('keyDown', { keyCode: 13, which: 13 }); - expect(onDevLogout).toHaveBeenCalledTimes(1); - }); -}); diff --git a/__tests__/components/NavExpandableGroup.test.jsx b/__tests__/components/NavExpandableGroup.test.jsx index 68cf571f7d..7619dcbfcd 100644 --- a/__tests__/components/NavExpandableGroup.test.jsx +++ b/__tests__/components/NavExpandableGroup.test.jsx @@ -12,7 +12,7 @@ describe('NavExpandableGroup', () => {