diff --git a/webpack/JobInvocationDetail/JobInvocationConstants.js b/webpack/JobInvocationDetail/JobInvocationConstants.js index 12d6deb11..12453eca2 100644 --- a/webpack/JobInvocationDetail/JobInvocationConstants.js +++ b/webpack/JobInvocationDetail/JobInvocationConstants.js @@ -38,6 +38,7 @@ export const STATUS = { SUCCEEDED: 'succeeded', FAILED: 'failed', CANCELLED: 'cancelled', + RUNNING: 'running', }; export const STATUS_UPPERCASE = { diff --git a/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js b/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js index 5fadf906b..7869d331c 100644 --- a/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js +++ b/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js @@ -58,6 +58,12 @@ const JobInvocationToolbarButtons = ({ jobId, data }) => { ); }, [jobId, reportTemplateJobId, templateInputId]); + const isCreateReportDisabled = + !canGenerateReportTemplates || + task?.state === STATUS.RUNNING || + task?.state === STATUS.PENDING || + reportHref === undefined; + const onActionFocus = useCallback(() => { const element = document.getElementById( `toggle-split-button-action-primary-${jobId}` @@ -280,11 +286,7 @@ const JobInvocationToolbarButtons = ({ jobId, data }) => { className="button-create-report" href={reportHref} variant="secondary" - isDisabled={ - !canGenerateReportTemplates || - task?.state === STATUS.PENDING || - reportHref === undefined - } + isDisabled={isCreateReportDisabled} > {__(`Create report`)} diff --git a/webpack/JobInvocationDetail/__tests__/MainInformation.test.js b/webpack/JobInvocationDetail/__tests__/MainInformation.test.js index 0cf21da4b..76ce24b39 100644 --- a/webpack/JobInvocationDetail/__tests__/MainInformation.test.js +++ b/webpack/JobInvocationDetail/__tests__/MainInformation.test.js @@ -1,15 +1,15 @@ /* eslint-disable max-lines */ import React from 'react'; -import configureMockStore from 'redux-mock-store'; -import { fireEvent, render, screen, act } from '@testing-library/react'; -import '@testing-library/jest-dom/extend-expect'; +import { fireEvent, screen, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; import { createMemoryHistory } from 'history'; import { Router } from 'react-router-dom'; -import { Provider } from 'react-redux'; -import thunk from 'redux-thunk'; +import { rtlHelpers } from 'foremanReact/common/testHelpers'; import { STATUS } from 'foremanReact/constants'; import { foremanUrl } from 'foremanReact/common/helpers'; +import { usePermissions } from 'foremanReact/common/hooks/Permissions/permissionHooks'; import * as api from 'foremanReact/redux/API'; +import { APIActions } from 'foremanReact/redux/API'; import JobInvocationDetailPage from '../index'; import { jobInvocationData, @@ -23,6 +23,7 @@ import { enableRecurringLogic, cancelRecurringLogic, } from '../JobInvocationActions'; +import { createForemanContextWrapper } from './foremanTestHelpers'; import { CANCEL_JOB, CANCEL_RECURRING_LOGIC, @@ -32,9 +33,29 @@ import { GET_TASK, JOB_INVOCATION_KEY, } from '../JobInvocationConstants'; -import { createForemanContextWrapper } from './foremanTestHelpers'; + +const { renderWithStore } = rtlHelpers; + +jest.mock('foremanReact/redux/API/APISelectors', () => + jest.requireActual('foremanReact/redux/API/APISelectors') +); + +jest.mock('foremanReact/common/hooks/Permissions/permissionHooks'); +jest.mock('../JobInvocationActions', () => { + const actual = jest.requireActual('../JobInvocationActions'); + + return { + ...actual, + getJobInvocation: jest.fn(() => () => undefined), + getTask: jest.fn(() => () => undefined), + }; +}); jest.spyOn(api, 'get'); +jest.spyOn(APIActions, 'post'); +jest.spyOn(APIActions, 'put'); + +usePermissions.mockReturnValue(true); // Mock toLocaleString to always use UTC timezone for consistent test results const originalToLocaleString = Date.prototype.toLocaleString; @@ -62,46 +83,59 @@ jest.mock('foremanReact/routes/common/PageLayout/PageLayout', () => )) ); -const initialState = { - JOB_INVOCATION_KEY: { - response: jobInvocationData, - status: STATUS.RESOLVED, - }, - GET_REPORT_TEMPLATES: mockReportTemplatesResponse, - extendable: {}, -}; +const setupApiMocks = () => { + api.get.mockImplementation(({ handleSuccess, key, ...action }) => { + if (key === GET_REPORT_TEMPLATES) { + if (handleSuccess) { + handleSuccess({ + data: mockReportTemplatesResponse, + }); + } + } else if (key === GET_REPORT_TEMPLATE_INPUTS) { + if (handleSuccess) { + handleSuccess({ + data: mockReportTemplateInputsResponse, + }); + } + } -const initialStateScheduled = { - JOB_INVOCATION_KEY: { - response: jobInvocationDataScheduled, - status: STATUS.RESOLVED, - }, - extendable: {}, + return { type: 'get', key, ...action }; + }); + APIActions.post.mockImplementation(payload => ({ type: 'post', ...payload })); + APIActions.put.mockImplementation(payload => ({ type: 'put', ...payload })); }; -api.get.mockImplementation(({ handleSuccess, ...action }) => { - if (action.key === 'GET_REPORT_TEMPLATES') { - handleSuccess && - handleSuccess({ - data: mockReportTemplatesResponse, - }); - } else if (action.key === 'GET_REPORT_TEMPLATE_INPUTS') { - handleSuccess && - handleSuccess({ - data: mockReportTemplateInputsResponse, - }); - } - - return { type: 'get', ...action }; -}); +const createJobInvocationDetailState = ({ + jobInvocation = jobInvocationData, + jobInvocationStatus = STATUS.RESOLVED, + jobInvocationError = null, + includeTaskState = true, +} = {}) => { + const jobInvocationResponse = + jobInvocationError || JSON.parse(JSON.stringify(jobInvocation)); -const initialStateRecurring = { - JOB_INVOCATION_KEY: { - response: jobInvocationDataRecurring, - status: STATUS.RESOLVED, - }, - GET_REPORT_TEMPLATES: mockReportTemplatesResponse, - extendable: {}, + return { + API: { + [JOB_INVOCATION_KEY]: jobInvocationError + ? { + response: jobInvocationResponse, + status: STATUS.ERROR, + } + : { + response: jobInvocationResponse, + status: jobInvocationStatus, + }, + ...(includeTaskState && { + [GET_TASK]: { + response: { + available_actions: { cancellable: true }, + ...(jobInvocationResponse.task || {}), + }, + status: STATUS.RESOLVED, + }, + }), + }, + }; }; jest.mock('../JobInvocationHostTable.js', () => () => ( @@ -110,21 +144,66 @@ jest.mock('../JobInvocationHostTable.js', () => () => ( const reportTemplateJobId = mockReportTemplatesResponse.results[0].id; -const mockStore = configureMockStore([thunk]); -const props = { history: { push: jest.fn() } }; +const defaultHistory = { push: jest.fn() }; + +const renderJobInvocationDetailPage = ( + initialState, + { jobId, history = defaultHistory } = {} +) => { + const id = + jobId ?? initialState.API?.[JOB_INVOCATION_KEY]?.response?.id ?? '1'; + + return renderWithStore( + , + initialState, + undefined + ); +}; describe('JobInvocationDetailPage', () => { + beforeEach(() => { + setupApiMocks(); + usePermissions.mockReturnValue(true); + + const { getJobInvocation, getTask } = jest.requireMock( + '../JobInvocationActions' + ); + getJobInvocation.mockImplementation(() => () => undefined); + getTask.mockImplementation(() => () => undefined); + }); + + afterEach(() => { + api.get.mockClear(); + APIActions.post.mockClear(); + APIActions.put.mockClear(); + }); + + it('shows scheduled date', async () => { + const scheduledJobInvocation = JSON.parse( + JSON.stringify(jobInvocationDataScheduled) + ); + + renderJobInvocationDetailPage( + createJobInvocationDetailState({ + jobInvocation: scheduledJobInvocation, + includeTaskState: false, + }), + { jobId: jobInvocationDataScheduled.id } + ); + + expect(screen.getByText('Scheduled at:')).toBeInTheDocument(); + expect(screen.getByText('Jan 1, 3000, 11:34 UTC')).toBeInTheDocument(); + expect(screen.getByText('Not yet')).toBeInTheDocument(); + }); + it('renders main information', async () => { const jobId = jobInvocationData.id; - const store = mockStore(initialState); - - const { container } = render( - - - + + const { container } = renderJobInvocationDetailPage( + createJobInvocationDetailState() ); expect(screen.getByText('Description')).toBeInTheDocument(); @@ -212,115 +291,121 @@ describe('JobInvocationDetailPage', () => { }); it('keeps toolbar buttons mounted while job invocation data is refreshing', async () => { - const jobId = jobInvocationData.id; - const store = mockStore({ - ...initialState, - JOB_INVOCATION_KEY: { - response: jobInvocationData, - status: STATUS.PENDING, - }, - }); - - render( - - - + renderJobInvocationDetailPage( + createJobInvocationDetailState({ + jobInvocationStatus: STATUS.PENDING, + }) ); expect(screen.getByText('Create report')).toBeInTheDocument(); expect(screen.getByText('Rerun all')).toBeInTheDocument(); }); - it('shows scheduled date', async () => { - const store = mockStore(initialStateScheduled); - render( - - - + it('disables Create report when the job task state is running', async () => { + usePermissions.mockImplementation(requiredPermissions => + requiredPermissions.includes('generate_report_templates') ); - expect(screen.getByText('Scheduled at:')).toBeInTheDocument(); - expect(screen.getByText('Jan 1, 3000, 11:34 UTC')).toBeInTheDocument(); - expect(screen.getByText('Not yet')).toBeInTheDocument(); + renderJobInvocationDetailPage( + createJobInvocationDetailState({ + jobInvocation: { + ...jobInvocationData, + task: { ...jobInvocationData.task, state: 'running' }, + }, + }) + ); + + const createReportButton = screen.getByRole('link', { + name: 'Create report', + }); + + expect(createReportButton).toHaveAttribute('aria-disabled', 'true'); + expect(createReportButton).toHaveClass('pf-m-disabled'); }); it('should dispatch global actions', async () => { - // recurring in the future + const actualActions = jest.requireActual('../JobInvocationActions'); + const { getTask } = jest.requireMock('../JobInvocationActions'); + + getTask.mockImplementation(taskId => dispatch => + actualActions.getTask(taskId)(dispatch) + ); + const jobId = jobInvocationDataRecurring.id; const taskId = jobInvocationDataRecurring.task.id; const recurrenceId = jobInvocationDataRecurring.recurrence.id; - const store = mockStore(initialStateRecurring); - render( - - - + + const { store } = renderJobInvocationDetailPage( + createJobInvocationDetailState({ + jobInvocation: jobInvocationDataRecurring, + }), + { jobId } ); - const expectedActions = [ - { key: GET_REPORT_TEMPLATES, url: '/api/report_templates' }, - { - key: JOB_INVOCATION_KEY, - url: `/api/job_invocations/${jobId}`, - }, - { + expect(api.get).toHaveBeenCalledWith( + expect.objectContaining({ + key: GET_REPORT_TEMPLATES, + url: '/api/report_templates', + }) + ); + expect(api.get).toHaveBeenCalledWith( + expect.objectContaining({ key: GET_TASK, url: `/foreman_tasks/api/tasks/${taskId}`, - }, - { + }) + ); + expect(api.get).toHaveBeenCalledWith( + expect.objectContaining({ key: GET_REPORT_TEMPLATE_INPUTS, url: `/api/templates/${reportTemplateJobId}/template_inputs`, - }, - { + }) + ); + + api.get.mockClear(); + APIActions.post.mockClear(); + APIActions.put.mockClear(); + + store.dispatch(cancelJob(jobId, false)); + store.dispatch(cancelJob(jobId, true)); + store.dispatch(enableRecurringLogic(recurrenceId, true, jobId)); + store.dispatch(enableRecurringLogic(recurrenceId, false, jobId)); + store.dispatch(cancelRecurringLogic(recurrenceId, jobId)); + + expect(APIActions.post).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ key: CANCEL_JOB, url: `/job_invocations/${jobId}/cancel`, - }, - { + }) + ); + expect(APIActions.post).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ key: CANCEL_JOB, url: `/job_invocations/${jobId}/cancel?force=true`, - }, - { + }) + ); + expect(APIActions.put).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ key: CHANGE_ENABLED_RECURRING_LOGIC, url: `/foreman_tasks/api/recurring_logics/${recurrenceId}`, - }, - { + }) + ); + expect(APIActions.put).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ key: CHANGE_ENABLED_RECURRING_LOGIC, url: `/foreman_tasks/api/recurring_logics/${recurrenceId}`, - }, - { + }) + ); + expect(APIActions.post).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ key: CANCEL_RECURRING_LOGIC, url: `/foreman_tasks/recurring_logics/${recurrenceId}/cancel`, - }, - ]; - - store.dispatch(cancelJob(jobId, false)); - store.dispatch(cancelJob(jobId, true)); - store.dispatch(enableRecurringLogic(recurrenceId, true, jobId)); - store.dispatch(enableRecurringLogic(recurrenceId, false, jobId)); - store.dispatch(cancelRecurringLogic(recurrenceId, jobId)); - - const actualActions = store.getActions(); - expect(actualActions).toHaveLength(expectedActions.length); - - expectedActions.forEach((expectedAction, index) => { - if (actualActions[index].type === 'WITH_INTERVAL') { - expect(actualActions[index].key.key).toEqual(expectedAction.key); - expect(actualActions[index].key.url).toEqual(expectedAction.url); - } else { - expect(actualActions[index].key).toEqual(expectedAction.key); - if (expectedAction.url) { - expect(actualActions[index].url).toEqual(expectedAction.url); - } - } - }); + }) + ); }); it('renders empty state when the job invocation fails to load', () => { @@ -328,28 +413,23 @@ describe('JobInvocationDetailPage', () => { const errorMessage = 'Record not found'; const history = createMemoryHistory(); const ForemanContextWrapper = createForemanContextWrapper(); - const store = mockStore({ - JOB_INVOCATION_KEY: { - status: STATUS.ERROR, - response: { - message: errorMessage, - response: { status: 404 }, - }, - }, - extendable: {}, - }); - render( + renderWithStore( - - - + - + , + createJobInvocationDetailState({ + jobInvocationError: { + message: errorMessage, + response: { status: 404 }, + }, + }), + undefined ); expect(