Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-04-05 21:08:51 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-04-05 21:08:51 +0300
commit9c05a84cac5e6519ef545b14ead8989719c6f612 (patch)
treee93937c87050f9f9b5603bfe9b7f8aca86e146c8 /spec
parentd4e0452ed946ca0cf4dd0537675abeda7a4c0ffa (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
-rw-r--r--spec/frontend/alerts_settings/components/alert_mapping_builder_spec.js2
-rw-r--r--spec/frontend/environments/deploy_board_component_spec.js4
-rw-r--r--spec/frontend/fixtures/timelogs.rb53
-rw-r--r--spec/frontend/lib/utils/datetime/time_spent_utility_spec.js25
-rw-r--r--spec/frontend/performance_bar/stores/performance_bar_store_spec.js8
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap2
-rw-r--r--spec/frontend/time_tracking/components/timelog_source_cell_spec.js136
-rw-r--r--spec/frontend/time_tracking/components/timelogs_app_spec.js238
-rw-r--r--spec/frontend/time_tracking/components/timelogs_table_spec.js223
-rw-r--r--spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js157
-rw-r--r--spec/graphql/resolvers/timelog_resolver_spec.rb22
-rw-r--r--spec/graphql/types/timelog_type_spec.rb2
-rw-r--r--spec/requests/time_tracking/timelogs_controller_spec.rb46
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