diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-04-05 21:08:51 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-04-05 21:08:51 +0300 |
commit | 9c05a84cac5e6519ef545b14ead8989719c6f612 (patch) | |
tree | e93937c87050f9f9b5603bfe9b7f8aca86e146c8 /spec | |
parent | d4e0452ed946ca0cf4dd0537675abeda7a4c0ffa (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
13 files changed, 813 insertions, 105 deletions
diff --git a/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js b/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js index 1e125bdfd3a..2b8479eab6d 100644 --- a/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js +++ b/spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js @@ -49,7 +49,7 @@ describe('AlertMappingBuilder', () => { const fallbackColumnIcon = findColumnInRow(0, 3).findComponent(GlIcon); expect(fallbackColumnIcon.exists()).toBe(true); - expect(fallbackColumnIcon.attributes('name')).toBe('question'); + expect(fallbackColumnIcon.attributes('name')).toBe('question-o'); expect(fallbackColumnIcon.attributes('title')).toBe(i18n.fallbackTooltip); }); diff --git a/spec/frontend/environments/deploy_board_component_spec.js b/spec/frontend/environments/deploy_board_component_spec.js index 73a366457fb..f50efada91a 100644 --- a/spec/frontend/environments/deploy_board_component_spec.js +++ b/spec/frontend/environments/deploy_board_component_spec.js @@ -61,7 +61,7 @@ describe('Deploy Board', () => { const icon = iconSpan.findComponent(GlIcon); expect(tooltip.props('target')()).toBe(iconSpan.element); - expect(icon.props('name')).toBe('question'); + expect(icon.props('name')).toBe('question-o'); }); it('renders the canary weight selector', () => { @@ -116,7 +116,7 @@ describe('Deploy Board', () => { const icon = iconSpan.findComponent(GlIcon); expect(tooltip.props('target')()).toBe(iconSpan.element); - expect(icon.props('name')).toBe('question'); + expect(icon.props('name')).toBe('question-o'); }); it('renders the canary weight selector', () => { diff --git a/spec/frontend/fixtures/timelogs.rb b/spec/frontend/fixtures/timelogs.rb new file mode 100644 index 00000000000..c66e2447ea6 --- /dev/null +++ b/spec/frontend/fixtures/timelogs.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Timelogs (GraphQL fixtures)', feature_category: :team_planning do + describe GraphQL::Query, type: :request do + include ApiHelpers + include GraphqlHelpers + include JavaScriptFixturesHelpers + + let_it_be(:guest) { create(:user) } + let_it_be(:developer) { create(:user) } + + context 'for time tracking timelogs' do + let_it_be(:project) { create(:project_empty_repo, :public) } + let_it_be(:issue) { create(:issue, project: project) } + + let(:query_path) { 'time_tracking/components/queries/get_timelogs.query.graphql' } + let(:query) { get_graphql_query_as_string(query_path) } + + before_all do + project.add_guest(guest) + project.add_developer(developer) + end + + it "graphql/get_timelogs_empty_response.json" do + post_graphql(query, current_user: guest, variables: { username: guest.username }) + + expect_graphql_errors_to_be_empty + end + + context 'with 20 or less timelogs' do + let_it_be(:timelogs) { create_list(:timelog, 6, user: developer, issue: issue, time_spent: 4 * 60 * 60) } + + it "graphql/get_non_paginated_timelogs_response.json" do + post_graphql(query, current_user: guest, variables: { username: developer.username }) + + expect_graphql_errors_to_be_empty + end + end + + context 'with more than 20 timelogs' do + let_it_be(:timelogs) { create_list(:timelog, 30, user: developer, issue: issue, time_spent: 4 * 60 * 60) } + + it "graphql/get_paginated_timelogs_response.json" do + post_graphql(query, current_user: guest, variables: { username: developer.username, first: 25 }) + + expect_graphql_errors_to_be_empty + end + end + end + end +end diff --git a/spec/frontend/lib/utils/datetime/time_spent_utility_spec.js b/spec/frontend/lib/utils/datetime/time_spent_utility_spec.js new file mode 100644 index 00000000000..15e056e45d0 --- /dev/null +++ b/spec/frontend/lib/utils/datetime/time_spent_utility_spec.js @@ -0,0 +1,25 @@ +import { formatTimeSpent } from '~/lib/utils/datetime/time_spent_utility'; + +describe('Time spent utils', () => { + describe('formatTimeSpent', () => { + describe('with limitToHours false', () => { + it('formats 34500 seconds to `1d 1h 35m`', () => { + expect(formatTimeSpent(34500)).toEqual('1d 1h 35m'); + }); + + it('formats -34500 seconds to `- 1d 1h 35m`', () => { + expect(formatTimeSpent(-34500)).toEqual('- 1d 1h 35m'); + }); + }); + + describe('with limitToHours true', () => { + it('formats 34500 seconds to `9h 35m`', () => { + expect(formatTimeSpent(34500, true)).toEqual('9h 35m'); + }); + + it('formats -34500 seconds to `- 9h 35m`', () => { + expect(formatTimeSpent(-34500, true)).toEqual('- 9h 35m'); + }); + }); + }); +}); diff --git a/spec/frontend/performance_bar/stores/performance_bar_store_spec.js b/spec/frontend/performance_bar/stores/performance_bar_store_spec.js index 7d5c5031792..170469db6ad 100644 --- a/spec/frontend/performance_bar/stores/performance_bar_store_spec.js +++ b/spec/frontend/performance_bar/stores/performance_bar_store_spec.js @@ -46,6 +46,14 @@ describe('PerformanceBarStore', () => { store.addRequest('id', 'http://localhost:3001/api/graphql', 'someOperation'); expect(findUrl('id')).toBe('graphql (someOperation)'); }); + + it('appends the number of batches queries when it is a GraphQL call', () => { + store.addRequest('id', 'http://localhost:3001/api/graphql', 'someOperation'); + store.addRequest('id', 'http://localhost:3001/api/graphql', 'anotherOperation'); + store.addRequest('id', 'http://localhost:3001/api/graphql', 'anotherOne'); + store.addRequest('anotherId', 'http://localhost:3001/api/graphql', 'operationName'); + expect(findUrl('id')).toBe('graphql (someOperation) [3 queries batched]'); + }); }); describe('setRequestDetailsData', () => { diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap index f4ebc5c3e3f..ed54582ca29 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap @@ -13,7 +13,7 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] = target="_blank" > <gl-icon-stub - name="question" + name="question-o" size="12" /> </gl-link-stub> diff --git a/spec/frontend/time_tracking/components/timelog_source_cell_spec.js b/spec/frontend/time_tracking/components/timelog_source_cell_spec.js new file mode 100644 index 00000000000..b9be4689c38 --- /dev/null +++ b/spec/frontend/time_tracking/components/timelog_source_cell_spec.js @@ -0,0 +1,136 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import TimelogSourceCell from '~/time_tracking/components/timelog_source_cell.vue'; +import { + IssuableStatusText, + STATUS_CLOSED, + STATUS_MERGED, + STATUS_OPEN, + STATUS_LOCKED, + STATUS_REOPENED, +} from '~/issues/constants'; + +const createIssuableTimelogMock = ( + type, + { title, state, webUrl, reference } = { + title: 'Issuable title', + state: STATUS_OPEN, + webUrl: 'https://example.com/issuable_url', + reference: '#111', + }, +) => { + return { + timelog: { + project: { + fullPath: 'group/project', + }, + [type]: { + title, + state, + webUrl, + reference, + }, + }, + }; +}; + +describe('TimelogSourceCell component', () => { + Vue.use(VueApollo); + + let wrapper; + + const findTitleContainer = () => wrapper.findByTestId('title-container'); + const findReferenceContainer = () => wrapper.findByTestId('reference-container'); + const findStateContainer = () => wrapper.findByTestId('state-container'); + + const mountComponent = ({ timelog } = {}) => { + wrapper = shallowMountExtended(TimelogSourceCell, { + propsData: { + timelog, + }, + }); + }; + + describe('when the timelog is associated to an issue', () => { + it('shows the issue title as link to the issue', () => { + mountComponent( + createIssuableTimelogMock('issue', { + title: 'Issue title', + webUrl: 'https://example.com/issue_url', + }), + ); + + const titleContainer = findTitleContainer(); + + expect(titleContainer.text()).toBe('Issue title'); + expect(titleContainer.attributes('href')).toBe('https://example.com/issue_url'); + }); + + it('shows the issue full reference as link to the issue', () => { + mountComponent( + createIssuableTimelogMock('issue', { + reference: '#111', + webUrl: 'https://example.com/issue_url', + }), + ); + + const referenceContainer = findReferenceContainer(); + + expect(referenceContainer.text()).toBe('group/project#111'); + expect(referenceContainer.attributes('href')).toBe('https://example.com/issue_url'); + }); + + it.each` + state | stateDescription + ${STATUS_OPEN} | ${IssuableStatusText[STATUS_OPEN]} + ${STATUS_REOPENED} | ${IssuableStatusText[STATUS_REOPENED]} + ${STATUS_LOCKED} | ${IssuableStatusText[STATUS_LOCKED]} + ${STATUS_CLOSED} | ${IssuableStatusText[STATUS_CLOSED]} + `('shows $stateDescription when the state is $state', ({ state, stateDescription }) => { + mountComponent(createIssuableTimelogMock('issue', { state })); + + expect(findStateContainer().text()).toBe(stateDescription); + }); + }); + + describe('when the timelog is associated to a merge request', () => { + it('shows the merge request title as link to the merge request', () => { + mountComponent( + createIssuableTimelogMock('mergeRequest', { + title: 'MR title', + webUrl: 'https://example.com/mr_url', + }), + ); + + const titleContainer = findTitleContainer(); + + expect(titleContainer.text()).toBe('MR title'); + expect(titleContainer.attributes('href')).toBe('https://example.com/mr_url'); + }); + + it('shows the merge request full reference as link to the merge request', () => { + mountComponent( + createIssuableTimelogMock('mergeRequest', { + reference: '!111', + webUrl: 'https://example.com/mr_url', + }), + ); + + const referenceContainer = findReferenceContainer(); + + expect(referenceContainer.text()).toBe('group/project!111'); + expect(referenceContainer.attributes('href')).toBe('https://example.com/mr_url'); + }); + it.each` + state | stateDescription + ${STATUS_OPEN} | ${IssuableStatusText[STATUS_OPEN]} + ${STATUS_CLOSED} | ${IssuableStatusText[STATUS_CLOSED]} + ${STATUS_MERGED} | ${IssuableStatusText[STATUS_MERGED]} + `('shows $stateDescription when the state is $state', ({ state, stateDescription }) => { + mountComponent(createIssuableTimelogMock('mergeRequest', { state })); + + expect(findStateContainer().text()).toBe(stateDescription); + }); + }); +}); diff --git a/spec/frontend/time_tracking/components/timelogs_app_spec.js b/spec/frontend/time_tracking/components/timelogs_app_spec.js new file mode 100644 index 00000000000..ca470ce63ac --- /dev/null +++ b/spec/frontend/time_tracking/components/timelogs_app_spec.js @@ -0,0 +1,238 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import * as Sentry from '@sentry/browser'; +import { GlDatepicker, GlLoadingIcon, GlKeysetPagination } from '@gitlab/ui'; +import getTimelogsEmptyResponse from 'test_fixtures/graphql/get_timelogs_empty_response.json'; +import getPaginatedTimelogsResponse from 'test_fixtures/graphql/get_paginated_timelogs_response.json'; +import getNonPaginatedTimelogsResponse from 'test_fixtures/graphql/get_non_paginated_timelogs_response.json'; +import { createAlert } from '~/alert'; +import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import getTimelogsQuery from '~/time_tracking/components/queries/get_timelogs.query.graphql'; +import TimelogsApp from '~/time_tracking/components/timelogs_app.vue'; +import TimelogsTable from '~/time_tracking/components/timelogs_table.vue'; + +jest.mock('~/alert'); +jest.mock('@sentry/browser'); + +describe('Timelogs app', () => { + Vue.use(VueApollo); + + let wrapper; + let fakeApollo; + + const findForm = () => wrapper.find('form'); + const findUsernameInput = () => extendedWrapper(findForm()).findByTestId('form-username'); + const findTableContainer = () => wrapper.findByTestId('table-container'); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findTotalTimeSpentContainer = () => wrapper.findByTestId('total-time-spent-container'); + const findTable = () => wrapper.findComponent(TimelogsTable); + const findPagination = () => wrapper.findComponent(GlKeysetPagination); + + const findFormDatePicker = (testId) => + findForm() + .findAllComponents(GlDatepicker) + .filter((c) => c.attributes('data-testid') === testId); + const findFromDatepicker = () => findFormDatePicker('form-from-date').at(0); + const findToDatepicker = () => findFormDatePicker('form-to-date').at(0); + + const submitForm = () => findForm().trigger('submit'); + + const resolvedEmptyListMock = jest.fn().mockResolvedValue(getTimelogsEmptyResponse); + const resolvedPaginatedListMock = jest.fn().mockResolvedValue(getPaginatedTimelogsResponse); + const resolvedNonPaginatedListMock = jest.fn().mockResolvedValue(getNonPaginatedTimelogsResponse); + const rejectedMock = jest.fn().mockRejectedValue({}); + + const mountComponent = ({ props, data } = {}, queryResolverMock = resolvedEmptyListMock) => { + fakeApollo = createMockApollo([[getTimelogsQuery, queryResolverMock]]); + + wrapper = mountExtended(TimelogsApp, { + data() { + return { + ...data, + }; + }, + propsData: { + limitToHours: false, + ...props, + }, + apolloProvider: fakeApollo, + }); + }; + + beforeEach(() => { + createAlert.mockClear(); + Sentry.captureException.mockClear(); + }); + + afterEach(() => { + fakeApollo = null; + }); + + describe('the content', () => { + it('shows the form and the loading icon when loading', () => { + mountComponent(); + + expect(findForm().exists()).toBe(true); + expect(findLoadingIcon().exists()).toBe(true); + expect(findTableContainer().exists()).toBe(false); + }); + + it('shows the form and the table container when finished loading', async () => { + mountComponent(); + + await waitForPromises(); + + expect(findForm().exists()).toBe(true); + expect(findLoadingIcon().exists()).toBe(false); + expect(findTableContainer().exists()).toBe(true); + }); + }); + + describe('the filter form', () => { + it('runs the query with the correct data', async () => { + mountComponent(); + + const username = 'johnsmith'; + const fromDate = new Date('2023-02-28'); + const toDate = new Date('2023-03-28'); + + findUsernameInput().vm.$emit('input', username); + findFromDatepicker().vm.$emit('input', fromDate); + findToDatepicker().vm.$emit('input', toDate); + + resolvedEmptyListMock.mockClear(); + + submitForm(); + + await waitForPromises(); + + expect(resolvedEmptyListMock).toHaveBeenCalledWith({ + username, + startDate: fromDate, + endDate: toDate, + groupId: null, + projectId: null, + first: 20, + last: null, + after: null, + before: null, + }); + expect(createAlert).not.toHaveBeenCalled(); + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('runs the query with the correct data after the date filters are cleared', async () => { + mountComponent(); + + const username = 'johnsmith'; + + findUsernameInput().vm.$emit('input', username); + findFromDatepicker().vm.$emit('clear'); + findToDatepicker().vm.$emit('clear'); + + resolvedEmptyListMock.mockClear(); + + submitForm(); + + await waitForPromises(); + + expect(resolvedEmptyListMock).toHaveBeenCalledWith({ + username, + startDate: null, + endDate: null, + groupId: null, + projectId: null, + first: 20, + last: null, + after: null, + before: null, + }); + expect(createAlert).not.toHaveBeenCalled(); + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('shows an alert an logs to sentry when the mutation is rejected', async () => { + mountComponent({}, rejectedMock); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'Something went wrong. Please try again.', + }); + expect(Sentry.captureException).toHaveBeenCalled(); + }); + }); + + describe('the total time spent container', () => { + it('is not visible when there are no timelogs', async () => { + mountComponent(); + + await waitForPromises(); + + expect(findTotalTimeSpentContainer().exists()).toBe(false); + }); + + it('shows the correct value when `limitToHours` is false', async () => { + mountComponent({}, resolvedNonPaginatedListMock); + + await waitForPromises(); + + expect(findTotalTimeSpentContainer().exists()).toBe(true); + expect(findTotalTimeSpentContainer().text()).toBe('3d'); + }); + + it('shows the correct value when `limitToHours` is true', async () => { + mountComponent({ props: { limitToHours: true } }, resolvedNonPaginatedListMock); + + await waitForPromises(); + + expect(findTotalTimeSpentContainer().exists()).toBe(true); + expect(findTotalTimeSpentContainer().text()).toBe('24h'); + }); + }); + + describe('the table', () => { + it('gets created with the right props when `limitToHours` is false', async () => { + mountComponent({}, resolvedNonPaginatedListMock); + + await waitForPromises(); + + expect(findTable().props()).toMatchObject({ + limitToHours: false, + entries: getNonPaginatedTimelogsResponse.data.timelogs.nodes, + }); + }); + + it('gets created with the right props when `limitToHours` is true', async () => { + mountComponent({ props: { limitToHours: true } }, resolvedNonPaginatedListMock); + + await waitForPromises(); + + expect(findTable().props()).toMatchObject({ + limitToHours: true, + entries: getNonPaginatedTimelogsResponse.data.timelogs.nodes, + }); + }); + }); + + describe('the pagination element', () => { + it('is not visible whene there is no pagination data', async () => { + mountComponent({}, resolvedNonPaginatedListMock); + + await waitForPromises(); + + expect(findPagination().exists()).toBe(false); + }); + + it('is visible whene there is pagination data', async () => { + mountComponent({}, resolvedPaginatedListMock); + + await waitForPromises(); + await nextTick(); + + expect(findPagination().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/time_tracking/components/timelogs_table_spec.js b/spec/frontend/time_tracking/components/timelogs_table_spec.js new file mode 100644 index 00000000000..980fb79e8fb --- /dev/null +++ b/spec/frontend/time_tracking/components/timelogs_table_spec.js @@ -0,0 +1,223 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlTable } from '@gitlab/ui'; +import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; +import TimelogsTable from '~/time_tracking/components/timelogs_table.vue'; +import TimelogSourceCell from '~/time_tracking/components/timelog_source_cell.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import { STATUS_OPEN, STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; + +const baseTimelogMock = { + timeSpent: 600, + project: { + fullPath: 'group/project', + }, + user: { + name: 'John Smith', + avatarUrl: 'https://example.gitlab.com/john.jpg', + webPath: 'https://example.gitlab.com/john', + }, + spentAt: '2023-03-27T21:00:00Z', + note: null, + summary: 'Summary from timelog field', + issue: { + title: 'Issue title', + webUrl: 'https://example.gitlab.com/issue_url_a', + state: STATUS_OPEN, + reference: '#111', + }, + mergeRequest: null, +}; + +const timelogsMock = [ + baseTimelogMock, + { + timeSpent: 3600, + project: { + fullPath: 'group/project_b', + }, + user: { + name: 'Paul Reed', + avatarUrl: 'https://example.gitlab.com/paul.jpg', + webPath: 'https://example.gitlab.com/paul', + }, + spentAt: '2023-03-28T16:00:00Z', + note: { + body: 'Summary from the body', + }, + summary: null, + issue: { + title: 'Other issue title', + webUrl: 'https://example.gitlab.com/issue_url_b', + state: STATUS_CLOSED, + reference: '#112', + }, + mergeRequest: null, + }, + { + timeSpent: 27 * 60 * 60, // 27h or 3d 3h (3 days of 8 hours) + project: { + fullPath: 'group/project_b', + }, + user: { + name: 'Les Gibbons', + avatarUrl: 'https://example.gitlab.com/les.jpg', + webPath: 'https://example.gitlab.com/les', + }, + spentAt: '2023-03-28T18:00:00Z', + note: null, + summary: 'Other timelog summary', + issue: null, + mergeRequest: { + title: 'MR title', + webUrl: 'https://example.gitlab.com/mr_url', + state: STATUS_MERGED, + reference: '!99', + }, + }, +]; + +describe('TimelogsTable component', () => { + Vue.use(VueApollo); + + let wrapper; + + const findTable = () => wrapper.findComponent(GlTable); + const findTableRows = () => findTable().find('tbody').findAll('tr'); + const findRowSpentAt = (rowIndex) => + extendedWrapper(findTableRows().at(rowIndex)).findByTestId('date-container'); + const findRowSource = (rowIndex) => findTableRows().at(rowIndex).findComponent(TimelogSourceCell); + const findRowUser = (rowIndex) => findTableRows().at(rowIndex).findComponent(UserAvatarLink); + const findRowTimeSpent = (rowIndex) => + extendedWrapper(findTableRows().at(rowIndex)).findByTestId('time-spent-container'); + const findRowSummary = (rowIndex) => + extendedWrapper(findTableRows().at(rowIndex)).findByTestId('summary-container'); + + const mountComponent = (props = {}) => { + wrapper = mountExtended(TimelogsTable, { + propsData: { + entries: timelogsMock, + limitToHours: false, + ...props, + }, + stubs: { GlTable }, + }); + }; + + describe('when there are no entries', () => { + it('show the empty table message and no rows', () => { + mountComponent({ entries: [] }); + + expect(findTable().text()).toContain('There are no records to show'); + expect(findTableRows()).toHaveLength(1); + }); + }); + + describe('when there are some entries', () => { + it('does not show the empty table message and has the correct number of rows', () => { + mountComponent(); + + expect(findTable().text()).not.toContain('There are no records to show'); + expect(findTableRows()).toHaveLength(3); + }); + + describe('Spent at column', () => { + it('shows the spent at value with in the correct format', () => { + mountComponent(); + + expect(findRowSpentAt(0).text()).toBe('March 27, 2023, 21:00 (UTC: +0000)'); + }); + }); + + describe('Source column', () => { + it('creates the source cell component passing the right props', () => { + mountComponent(); + + expect(findRowSource(0).props()).toMatchObject({ + timelog: timelogsMock[0], + }); + expect(findRowSource(1).props()).toMatchObject({ + timelog: timelogsMock[1], + }); + expect(findRowSource(2).props()).toMatchObject({ + timelog: timelogsMock[2], + }); + }); + }); + + describe('User column', () => { + it('creates the user avatar component passing the right props', () => { + mountComponent(); + + expect(findRowUser(0).props()).toMatchObject({ + linkHref: timelogsMock[0].user.webPath, + imgSrc: timelogsMock[0].user.avatarUrl, + imgSize: 16, + imgAlt: timelogsMock[0].user.name, + tooltipText: timelogsMock[0].user.name, + username: timelogsMock[0].user.name, + }); + expect(findRowUser(1).props()).toMatchObject({ + linkHref: timelogsMock[1].user.webPath, + imgSrc: timelogsMock[1].user.avatarUrl, + imgSize: 16, + imgAlt: timelogsMock[1].user.name, + tooltipText: timelogsMock[1].user.name, + username: timelogsMock[1].user.name, + }); + expect(findRowUser(2).props()).toMatchObject({ + linkHref: timelogsMock[2].user.webPath, + imgSrc: timelogsMock[2].user.avatarUrl, + imgSize: 16, + imgAlt: timelogsMock[2].user.name, + tooltipText: timelogsMock[2].user.name, + username: timelogsMock[2].user.name, + }); + }); + }); + + describe('Time spent column', () => { + it('shows the time spent value with the correct format when `limitToHours` is false', () => { + mountComponent(); + + expect(findRowTimeSpent(0).text()).toBe('10m'); + expect(findRowTimeSpent(1).text()).toBe('1h'); + expect(findRowTimeSpent(2).text()).toBe('3d 3h'); + }); + + it('shows the time spent value with the correct format when `limitToHours` is true', () => { + mountComponent({ limitToHours: true }); + + expect(findRowTimeSpent(0).text()).toBe('10m'); + expect(findRowTimeSpent(1).text()).toBe('1h'); + expect(findRowTimeSpent(2).text()).toBe('27h'); + }); + }); + + describe('Summary column', () => { + it('shows the summary from the note when note body is present and not empty', () => { + mountComponent({ + entries: [{ ...baseTimelogMock, note: { body: 'Summary from note body' } }], + }); + + expect(findRowSummary(0).text()).toBe('Summary from note body'); + }); + + it('shows the summary from the timelog note body is present but empty', () => { + mountComponent({ + entries: [{ ...baseTimelogMock, note: { body: '' } }], + }); + + expect(findRowSummary(0).text()).toBe('Summary from timelog field'); + }); + + it('shows the summary from the timelog note body is not present', () => { + mountComponent({ + entries: [baseTimelogMock], + }); + + expect(findRowSummary(0).text()).toBe('Summary from timelog field'); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js index 98a357bac2b..bf4435fae45 100644 --- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js +++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js @@ -1,21 +1,28 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { GlDropdownItem } from '@gitlab/ui'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import SidebarAssignee from '~/vue_shared/alert_details/components/sidebar/sidebar_assignee.vue'; import SidebarAssignees from '~/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue'; import AlertSetAssignees from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import mockAlerts from '../mocks/alerts.json'; const mockAlert = mockAlerts[0]; describe('Alert Details Sidebar Assignees', () => { let wrapper; + let requestHandlers; let mock; const mockPath = '/-/autocomplete/users.json'; + const mockUrlRoot = '/gitlab'; + const expectedUrl = `${mockUrlRoot}${mockPath}`; + const mockUsers = [ { avatar_url: @@ -40,81 +47,64 @@ describe('Alert Details Sidebar Assignees', () => { const findSidebarIcon = () => wrapper.findByTestId('assignees-icon'); const findUnassigned = () => wrapper.findByTestId('unassigned-users'); + const mockDefaultHandler = (errors = []) => + jest.fn().mockResolvedValue({ + data: { + issuableSetAssignees: { + errors, + issuable: { + id: 'id', + iid: 'iid', + assignees: { + nodes: [], + }, + notes: { + nodes: [], + }, + }, + }, + }, + }); + const createMockApolloProvider = (handlers) => { + Vue.use(VueApollo); + requestHandlers = handlers; + + return createMockApollo([[AlertSetAssignees, handlers]]); + }; + function mountComponent({ - data, - users = [], - isDropdownSearching = false, + props, sidebarCollapsed = true, - loading = false, - stubs = {}, + handlers = mockDefaultHandler(), } = {}) { wrapper = shallowMountExtended(SidebarAssignees, { - data() { - return { - users, - isDropdownSearching, - }; - }, + apolloProvider: createMockApolloProvider(handlers), propsData: { alert: { ...mockAlert }, - ...data, + ...props, sidebarCollapsed, projectPath: 'projectPath', projectId: '1', }, - mocks: { - $apollo: { - mutate: jest.fn(), - queries: { - alert: { - loading, - }, - }, - }, - }, - stubs, }); } - afterEach(() => { - if (wrapper) { - wrapper.destroy(); - } - mock.restore(); - }); - describe('sidebar expanded', () => { - const mockUpdatedMutationResult = { - data: { - alertSetAssignees: { - errors: [], - alert: { - assigneeUsernames: ['root'], - }, - }, - }, - }; - beforeEach(() => { mock = new MockAdapter(axios); + window.gon = { + relative_url_root: mockUrlRoot, + }; - mock.onGet(mockPath).replyOnce(HTTP_STATUS_OK, mockUsers); + mock.onGet(expectedUrl).reply(HTTP_STATUS_OK, mockUsers); mountComponent({ - data: { alert: mockAlert }, + props: { alert: mockAlert }, sidebarCollapsed: false, - loading: false, - users: mockUsers, - stubs: { - SidebarAssignee, - }, }); }); it('renders a unassigned option', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ isDropdownSearching: false }); - await nextTick(); + await waitForPromises(); expect(findDropdown().text()).toBe('Unassigned'); }); @@ -122,60 +112,38 @@ describe('Alert Details Sidebar Assignees', () => { expect(findSidebarIcon().exists()).toBe(false); }); - it('calls `$apollo.mutate` with `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', async () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ isDropdownSearching: false }); - - await nextTick(); + it('calls `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', async () => { + await waitForPromises(); wrapper.findComponent(SidebarAssignee).vm.$emit('update-alert-assignees', 'root'); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: AlertSetAssignees, - variables: { - iid: '1527542', - assigneeUsernames: ['root'], - fullPath: 'projectPath', - }, + expect(requestHandlers).toHaveBeenCalledWith({ + iid: '1527542', + assigneeUsernames: ['root'], + fullPath: 'projectPath', }); }); it('emits an error when request contains error messages', async () => { - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ isDropdownSearching: false }); - const errorMutationResult = { - data: { - issuableSetAssignees: { - errors: ['There was a problem for sure.'], - alert: {}, - }, - }, - }; - - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(errorMutationResult); + mountComponent({ + sidebarCollapsed: false, + handlers: mockDefaultHandler(['There was a problem for sure.']), + }); + await waitForPromises(); - await nextTick(); const SideBarAssigneeItem = wrapper.findAllComponents(SidebarAssignee).at(0); await SideBarAssigneeItem.vm.$emit('update-alert-assignees'); - expect(wrapper.emitted('alert-error')).toBeDefined(); + + await waitForPromises(); + expect(wrapper.emitted('alert-error')).toHaveLength(1); }); it('stops updating and cancels loading when the request fails', () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); - wrapper.vm.updateAlertAssignees('root'); expect(findUnassigned().text()).toBe('assign yourself'); }); it('shows a user avatar, username and full name when a user is set', () => { mountComponent({ - data: { alert: mockAlerts[1] }, - sidebarCollapsed: false, - loading: false, - stubs: { - SidebarAssignee, - }, + props: { alert: mockAlerts[1] }, }); expect(findAssigned().find('img').attributes('src')).toBe('/url'); @@ -188,15 +156,10 @@ describe('Alert Details Sidebar Assignees', () => { beforeEach(() => { mock = new MockAdapter(axios); - mock.onGet(mockPath).replyOnce(HTTP_STATUS_OK, mockUsers); + mock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, mockUsers); mountComponent({ - data: { alert: mockAlert }, - loading: false, - users: mockUsers, - stubs: { - SidebarAssignee, - }, + props: { alert: mockAlert }, }); }); it('does not display the status dropdown', () => { diff --git a/spec/graphql/resolvers/timelog_resolver_spec.rb b/spec/graphql/resolvers/timelog_resolver_spec.rb index cd52308d895..5177873321c 100644 --- a/spec/graphql/resolvers/timelog_resolver_spec.rb +++ b/spec/graphql/resolvers/timelog_resolver_spec.rb @@ -214,7 +214,11 @@ RSpec.describe Resolvers::TimelogResolver, feature_category: :team_planning do let_it_be(:timelog3) { create(:merge_request_timelog, merge_request: merge_request, user: current_user) } it 'blah' do - expect(timelogs).to contain_exactly(timelog1, timelog3) + if user_found + expect(timelogs).to contain_exactly(timelog1, timelog3) + else + expect(timelogs).to be_empty + end end end @@ -250,16 +254,28 @@ RSpec.describe Resolvers::TimelogResolver, feature_category: :team_planning do let(:object) { current_user } let(:extra_args) { {} } let(:args) { {} } + let(:user_found) { true } it_behaves_like 'with a user' end context 'with a user filter' do let(:object) { nil } - let(:extra_args) { { username: current_user.username } } let(:args) { {} } - it_behaves_like 'with a user' + context 'when the user has timelogs' do + let(:extra_args) { { username: current_user.username } } + let(:user_found) { true } + + it_behaves_like 'with a user' + end + + context 'when the user doest not have timelogs' do + let(:extra_args) { { username: 'not_existing_user' } } + let(:user_found) { false } + + it_behaves_like 'with a user' + end end context 'when no object or arguments provided' do diff --git a/spec/graphql/types/timelog_type_spec.rb b/spec/graphql/types/timelog_type_spec.rb index 59a0e373c5d..aa05c5ffd94 100644 --- a/spec/graphql/types/timelog_type_spec.rb +++ b/spec/graphql/types/timelog_type_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe GitlabSchema.types['Timelog'], feature_category: :team_planning do - let_it_be(:fields) { %i[id spent_at time_spent user issue merge_request note summary userPermissions] } + let_it_be(:fields) { %i[id spent_at time_spent user issue merge_request note summary userPermissions project] } it { expect(described_class.graphql_name).to eq('Timelog') } it { expect(described_class).to have_graphql_fields(fields) } diff --git a/spec/requests/time_tracking/timelogs_controller_spec.rb b/spec/requests/time_tracking/timelogs_controller_spec.rb new file mode 100644 index 00000000000..68eecf9b137 --- /dev/null +++ b/spec/requests/time_tracking/timelogs_controller_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe TimeTracking::TimelogsController, feature_category: :team_planning do + let_it_be(:user) { create(:user) } + + describe 'GET #index' do + subject { get timelogs_path } + + context 'when user is not logged in' do + it 'responds with a redirect to the login page' do + subject + + expect(response).to have_gitlab_http_status(:redirect) + end + end + + context 'when user is logged in' do + before do + sign_in(user) + end + + context 'when global_time_tracking_report FF is enabled' do + it 'responds with the global time tracking page', :aggregate_failures do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template(:index) + end + end + + context 'when global_time_tracking_report FF is disable' do + before do + stub_feature_flags(global_time_tracking_report: false) + end + + it 'returns a 404 page' do + subject + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + end +end |