diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-08-09 15:08:37 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-08-09 15:08:37 +0300 |
commit | 1faea1c6a0464e44dca4477fb31846938c2ad871 (patch) | |
tree | 49e1efd28dc28a14e68b7c3510621499f1a5141c /spec | |
parent | c2de38f36d2fb75a17ce161fa69f2b8a5e670f3e (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
44 files changed, 1192 insertions, 649 deletions
diff --git a/spec/controllers/profiles/preferences_controller_spec.rb b/spec/controllers/profiles/preferences_controller_spec.rb index f5c97f63293..b4ffe0bc844 100644 --- a/spec/controllers/profiles/preferences_controller_spec.rb +++ b/spec/controllers/profiles/preferences_controller_spec.rb @@ -110,31 +110,14 @@ RSpec.describe Profiles::PreferencesController do end end - context 'on disable_follow_users feature flag' do - context 'with feature flag disabled' do - before do - stub_feature_flags(disable_follow_users: false) - end + context 'on enabled_following setting' do + it 'does not update enabled_following preference of user' do + prefs = { enabled_following: false } - it 'does not update enabled_following preference of user' do - prefs = { enabled_following: false } - - go params: prefs - user.reload - - expect(user.enabled_following).to eq(true) - end - end - - context 'with feature flag enabled' do - it 'does not update enabled_following preference of user' do - prefs = { enabled_following: false } - - go params: prefs - user.reload + go params: prefs + user.reload - expect(user.enabled_following).to eq(false) - end + expect(user.enabled_following).to eq(false) end end end diff --git a/spec/factories/metrics/dashboard/annotations.rb b/spec/factories/metrics/dashboard/annotations.rb index 2e5c373918e..50c9ed01fd8 100644 --- a/spec/factories/metrics/dashboard/annotations.rb +++ b/spec/factories/metrics/dashboard/annotations.rb @@ -5,11 +5,5 @@ FactoryBot.define do description { "Dashbaord annoation description" } dashboard_path { "custom_dashbaord.yml" } starting_at { Time.current } - environment - - trait :with_cluster do - cluster - environment { nil } - end end end diff --git a/spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb b/spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb index 5756218d20f..9883434eb68 100644 --- a/spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb +++ b/spec/features/merge_request/user_sees_mini_pipeline_graph_spec.rb @@ -97,8 +97,8 @@ RSpec.describe 'Merge request < User sees mini pipeline graph', :js, feature_cat describe 'build list build item' do let(:build_item) do - find('.mini-pipeline-graph-dropdown-item') - first('.mini-pipeline-graph-dropdown-item') + find('.pipeline-job-item') + first('.pipeline-job-item') end it 'visits the build page when clicked' do diff --git a/spec/features/projects/commit/mini_pipeline_graph_spec.rb b/spec/features/projects/commit/mini_pipeline_graph_spec.rb index d2104799e79..5bb3d1af924 100644 --- a/spec/features/projects/commit/mini_pipeline_graph_spec.rb +++ b/spec/features/projects/commit/mini_pipeline_graph_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe 'Mini Pipeline Graph in Commit View', :js, feature_category: :source_code_management do let(:project) { create(:project, :public, :repository) } - context 'when commit has pipelines' do + context 'when commit has pipelines and feature flag is enabled' do let(:pipeline) do create( :ci_pipeline, @@ -24,6 +24,33 @@ RSpec.describe 'Mini Pipeline Graph in Commit View', :js, feature_category: :sou wait_for_requests end + it 'displays the graphql pipeline stage' do + expect(page).to have_selector('[data-testid="pipeline-stage"]') + + build.drop + end + end + + context 'when commit has pipelines and feature flag is disabled' do + let(:pipeline) do + create( + :ci_pipeline, + status: :running, + project: project, + ref: project.default_branch, + sha: project.commit.sha + ) + end + + let(:build) { create(:ci_build, pipeline: pipeline, status: :running) } + + before do + stub_feature_flags(ci_graphql_pipeline_mini_graph: false) + build.run + visit project_commit_path(project, project.commit.id) + wait_for_requests + end + it 'display icon with status' do expect(page).to have_selector('.ci-status-icon-running') end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index f742ec3dfa6..26fcd8ca3ca 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -559,7 +559,7 @@ RSpec.describe 'Pipelines', :js, feature_category: :continuous_integration do find(dropdown_selector).click within('.js-builds-dropdown-list') do - build_element = page.find('.mini-pipeline-graph-dropdown-item') + build_element = page.find('.pipeline-job-item') expect(build_element['title']).to eq('build - failed - (unknown failure)') end end diff --git a/spec/frontend/__mocks__/jed/index.js b/spec/frontend/__mocks__/jed/index.js new file mode 100644 index 00000000000..fa2be5aa6e2 --- /dev/null +++ b/spec/frontend/__mocks__/jed/index.js @@ -0,0 +1,17 @@ +/** + * ## Why are we mocking Jed? + * + * https://gitlab.com/gitlab-org/gitlab/-/issues/390934#note_1494028934 + * + * It's possible that some environments run a specific locale. If the unit + * tests run under this condition, hardcoded values will fail. To make + * tests more deterministic across environments, let's skip loading translations + * in FE unit tests. + */ +const Jed = jest.requireActual('jed'); + +export default class MockJed extends Jed { + constructor() { + super({}); + } +} diff --git a/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js index 858cf24061a..3bbe14adb88 100644 --- a/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js @@ -38,8 +38,9 @@ describe('Pipeline Status', () => { const findIcon = () => wrapper.findComponent(GlIcon); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph); const findPipelineEditorMiniGraph = () => wrapper.findComponent(PipelineEditorMiniGraph); + const findPipelineMiniGraph = () => wrapper.findComponent(PipelineMiniGraph); + const findPipelineId = () => wrapper.find('[data-testid="pipeline-id"]'); const findPipelineCommit = () => wrapper.find('[data-testid="pipeline-commit"]'); const findPipelineErrorMsg = () => wrapper.find('[data-testid="pipeline-error-msg"]'); @@ -142,18 +143,18 @@ describe('Pipeline Status', () => { }); it.each` - state | provide | showPipelineMiniGraph | showGraphqlPipelineMiniGraph - ${true} | ${{ ciGraphqlPipelineMiniGraph: true }} | ${false} | ${true} - ${false} | ${{}} | ${true} | ${false} + state | showLegacyPipelineMiniGraph | showPipelineMiniGraph + ${true} | ${false} | ${true} + ${false} | ${true} | ${false} `( 'renders the correct component when the feature flag is set to $state', - async ({ provide, showPipelineMiniGraph, showGraphqlPipelineMiniGraph }) => { - createComponentWithApollo(provide); + async ({ state, showLegacyPipelineMiniGraph, showPipelineMiniGraph }) => { + createComponentWithApollo({ ciGraphqlPipelineMiniGraph: state }); await waitForPromises(); - expect(findPipelineEditorMiniGraph().exists()).toBe(showPipelineMiniGraph); - expect(findPipelineMiniGraph().exists()).toBe(showGraphqlPipelineMiniGraph); + expect(findPipelineEditorMiniGraph().exists()).toBe(showLegacyPipelineMiniGraph); + expect(findPipelineMiniGraph().exists()).toBe(showPipelineMiniGraph); }, ); }); diff --git a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js index 79e1e087001..3b3e5098857 100644 --- a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js +++ b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js @@ -263,18 +263,18 @@ describe('Commit box pipeline mini graph', () => { describe('feature flag behavior', () => { it.each` - state | provide | showPipelineMiniGraph | showGraphqlPipelineMiniGraph - ${true} | ${{ ciGraphqlPipelineMiniGraph: true }} | ${false} | ${true} - ${false} | ${{}} | ${true} | ${false} + state | showLegacyPipelineMiniGraph | showPipelineMiniGraph + ${true} | ${false} | ${true} + ${false} | ${true} | ${false} `( 'renders the correct component when the feature flag is set to $state', - async ({ provide, showPipelineMiniGraph, showGraphqlPipelineMiniGraph }) => { - createComponent(provide); + async ({ state, showLegacyPipelineMiniGraph, showPipelineMiniGraph }) => { + createComponent({ ciGraphqlPipelineMiniGraph: state }); await waitForPromises(); - expect(findLegacyPipelineMiniGraph().exists()).toBe(showPipelineMiniGraph); - expect(findPipelineMiniGraph().exists()).toBe(showGraphqlPipelineMiniGraph); + expect(findLegacyPipelineMiniGraph().exists()).toBe(showLegacyPipelineMiniGraph); + expect(findPipelineMiniGraph().exists()).toBe(showPipelineMiniGraph); }, ); diff --git a/spec/frontend/jobs/components/job/job_log_controllers_spec.js b/spec/frontend/jobs/components/job/job_log_controllers_spec.js index 218096b9745..7b6d58f63d1 100644 --- a/spec/frontend/jobs/components/job/job_log_controllers_spec.js +++ b/spec/frontend/jobs/components/job/job_log_controllers_spec.js @@ -21,7 +21,6 @@ describe('Job log controllers', () => { const defaultProps = { rawPath: '/raw', - erasePath: '/erase', size: 511952, isScrollTopDisabled: false, isScrollBottomDisabled: false, diff --git a/spec/frontend/observability/client_spec.js b/spec/frontend/observability/client_spec.js index 239d7adf986..10fdc8c33c4 100644 --- a/spec/frontend/observability/client_spec.js +++ b/spec/frontend/observability/client_spec.js @@ -9,6 +9,7 @@ describe('buildClient', () => { let axiosMock; const tracingUrl = 'https://example.com/tracing'; + const EXPECTED_ERROR_MESSAGE = 'traces are missing/invalid in the response'; beforeEach(() => { axiosMock = new MockAdapter(axios); @@ -24,11 +25,51 @@ describe('buildClient', () => { axiosMock.restore(); }); + describe('fetchTrace', () => { + it('fetches the trace from the tracing URL', async () => { + const mockTraces = [ + { trace_id: 'trace-1', spans: [{ duration_nano: 1000 }, { duration_nano: 2000 }] }, + ]; + + axiosMock.onGet(tracingUrl).reply(200, { + traces: mockTraces, + }); + + const result = await client.fetchTrace('trace-1'); + + expect(axios.get).toHaveBeenCalledTimes(1); + expect(axios.get).toHaveBeenCalledWith(tracingUrl, { + withCredentials: true, + params: { trace_id: 'trace-1' }, + }); + expect(result).toEqual({ + ...mockTraces[0], + duration: 1, + }); + }); + + it('rejects if trace id is missing', () => { + return expect(client.fetchTrace()).rejects.toThrow('traceId is required.'); + }); + + it('rejects if traces are empty', () => { + axiosMock.onGet(tracingUrl).reply(200, { traces: [] }); + + return expect(client.fetchTrace('trace-1')).rejects.toThrow(EXPECTED_ERROR_MESSAGE); + }); + + it('rejects if traces are invalid', () => { + axiosMock.onGet(tracingUrl).reply(200, { traces: 'invalid' }); + + return expect(client.fetchTraces()).rejects.toThrow(EXPECTED_ERROR_MESSAGE); + }); + }); + describe('fetchTraces', () => { - it('should fetch traces from the tracing URL', async () => { + it('fetches traces from the tracing URL', async () => { const mockTraces = [ - { id: 1, spans: [{ duration_nano: 1000 }, { duration_nano: 2000 }] }, - { id: 2, spans: [{ duration_nano: 2000 }] }, + { trace_id: 'trace-1', spans: [{ duration_nano: 1000 }, { duration_nano: 2000 }] }, + { trace_id: 'trace-2', spans: [{ duration_nano: 2000 }] }, ]; axiosMock.onGet(tracingUrl).reply(200, { @@ -40,27 +81,127 @@ describe('buildClient', () => { expect(axios.get).toHaveBeenCalledTimes(1); expect(axios.get).toHaveBeenCalledWith(tracingUrl, { withCredentials: true, + params: new URLSearchParams(), }); expect(result).toEqual([ - { id: 1, spans: [{ duration_nano: 1000 }, { duration_nano: 2000 }], duration: 3 }, - { id: 2, spans: [{ duration_nano: 2000 }], duration: 2 }, + { + ...mockTraces[0], + duration: 1, + }, + { + ...mockTraces[1], + duration: 2, + }, ]); }); it('rejects if traces are missing', () => { axiosMock.onGet(tracingUrl).reply(200, {}); - return expect(client.fetchTraces()).rejects.toThrow( - 'traces are missing/invalid in the response', - ); + return expect(client.fetchTraces()).rejects.toThrow(EXPECTED_ERROR_MESSAGE); }); it('rejects if traces are invalid', () => { axiosMock.onGet(tracingUrl).reply(200, { traces: 'invalid' }); - return expect(client.fetchTraces()).rejects.toThrow( - 'traces are missing/invalid in the response', - ); + return expect(client.fetchTraces()).rejects.toThrow(EXPECTED_ERROR_MESSAGE); + }); + + describe('query filter', () => { + beforeEach(() => { + axiosMock.onGet(tracingUrl).reply(200, { + traces: [], + }); + }); + + const getQueryParam = () => decodeURIComponent(axios.get.mock.calls[0][1].params.toString()); + + it('does not set any query param without filters', async () => { + await client.fetchTraces(); + + expect(getQueryParam()).toBe(''); + }); + + it('converts filter to proper query params', async () => { + await client.fetchTraces({ + durationMs: [ + { operator: '>', value: '100' }, + { operator: '<', value: '1000' }, + ], + operation: [ + { operator: '=', value: 'op' }, + { operator: '!=', value: 'not-op' }, + ], + serviceName: [ + { operator: '=', value: 'service' }, + { operator: '!=', value: 'not-service' }, + ], + period: [{ operator: '=', value: '5m' }], + traceId: [ + { operator: '=', value: 'trace-id' }, + { operator: '!=', value: 'not-trace-id' }, + ], + }); + expect(getQueryParam()).toBe( + 'gt[duration_nano]=100000<[duration_nano]=1000000' + + '&operation=op¬[operation]=not-op' + + '&service_name=service¬[service_name]=not-service' + + '&period=5m' + + '&trace_id=trace-id¬[trace_id]=not-trace-id', + ); + }); + + it('handles repeated params', async () => { + await client.fetchTraces({ + operation: [ + { operator: '=', value: 'op' }, + { operator: '=', value: 'op2' }, + ], + }); + expect(getQueryParam()).toBe('operation=op&operation=op2'); + }); + + it('ignores unsupported filters', async () => { + await client.fetchTraces({ + unsupportedFilter: [{ operator: '=', value: 'foo' }], + }); + + expect(getQueryParam()).toBe(''); + }); + + it('ignores empty filters', async () => { + await client.fetchTraces({ + durationMs: null, + traceId: undefined, + }); + + expect(getQueryParam()).toBe(''); + }); + + it('ignores unsupported operators', async () => { + await client.fetchTraces({ + durationMs: [ + { operator: '*', value: 'foo' }, + { operator: '=', value: 'foo' }, + { operator: '!=', value: 'foo' }, + ], + operation: [ + { operator: '>', value: 'foo' }, + { operator: '<', value: 'foo' }, + ], + serviceName: [ + { operator: '>', value: 'foo' }, + { operator: '<', value: 'foo' }, + ], + period: [{ operator: '!=', value: 'foo' }], + traceId: [ + { operator: '>', value: 'foo' }, + { operator: '<', value: 'foo' }, + ], + }); + + expect(getQueryParam()).toBe(''); + }); }); }); }); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js index 98cd87cb07b..500fb0d7598 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js @@ -1,4 +1,4 @@ -import { GlDropdownItem, GlIcon, GlDropdown } from '@gitlab/ui'; +import { GlDisclosureDropdown, GlDisclosureDropdownItem, GlIcon } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; import Vue from 'vue'; import { numberToHumanSize } from '~/lib/utils/number_utils'; @@ -38,9 +38,9 @@ describe('Details Header', () => { const findTitle = () => wrapper.findByTestId('title'); const findTagsCount = () => wrapper.findByTestId('tags-count'); const findCleanup = () => wrapper.findByTestId('cleanup'); - const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); + const findDeleteButton = () => wrapper.findComponent(GlDisclosureDropdownItem); const findInfoIcon = () => wrapper.findComponent(GlIcon); - const findMenu = () => wrapper.findComponent(GlDropdown); + const findMenu = () => wrapper.findComponent(GlDisclosureDropdown); const findSize = () => wrapper.findByTestId('image-size'); const mountComponent = ({ @@ -58,10 +58,6 @@ describe('Details Header', () => { directives: { GlTooltip: createMockDirective('gl-tooltip'), }, - stubs: { - GlDropdown, - GlDropdownItem, - }, }); }; @@ -126,35 +122,36 @@ describe('Details Header', () => { }, ); - describe('delete button', () => { - it('exists', () => { - mountComponent(); + it('has the correct props', () => { + mountComponent(); - expect(findDeleteButton().exists()).toBe(true); + expect(findMenu().props()).toMatchObject({ + category: 'tertiary', + icon: 'ellipsis_v', + placement: 'right', + textSrOnly: true, + noCaret: true, + toggleText: 'More actions', }); + }); - it('has the correct text', () => { + describe('delete item', () => { + beforeEach(() => { mountComponent(); - - expect(findDeleteButton().text()).toBe('Delete image repository'); }); - it('has the correct props', () => { - mountComponent(); + it('exists', () => { + expect(findDeleteButton().exists()).toBe(true); + }); - expect(findDeleteButton().attributes()).toMatchObject( - expect.objectContaining({ - variant: 'danger', - }), - ); + it('has the correct text', () => { + expect(findDeleteButton().text()).toBe('Delete image repository'); }); it('emits the correct event', () => { - mountComponent(); - - findDeleteButton().vm.$emit('click'); + findDeleteButton().vm.$emit('action'); - expect(wrapper.emitted('delete')).toEqual([[]]); + expect(wrapper.emitted('delete')).toHaveLength(1); }); }); }); diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/job_item_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/job_item_spec.js new file mode 100644 index 00000000000..b89f27e5c05 --- /dev/null +++ b/spec/frontend/pipelines/components/pipeline_mini_graph/job_item_spec.js @@ -0,0 +1,29 @@ +import { shallowMount } from '@vue/test-utils'; +import JobItem from '~/pipelines/components/pipeline_mini_graph/job_item.vue'; + +describe('JobItem', () => { + let wrapper; + + const defaultProps = { + job: { id: '3' }, + }; + + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMount(JobItem, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + describe('when mounted', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the received HTML', () => { + expect(wrapper.html()).toContain(defaultProps.job.id); + }); + }); +}); diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/legacy_pipeline_stage_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/legacy_pipeline_stage_spec.js new file mode 100644 index 00000000000..3697eaeea1a --- /dev/null +++ b/spec/frontend/pipelines/components/pipeline_mini_graph/legacy_pipeline_stage_spec.js @@ -0,0 +1,247 @@ +import { GlDropdown } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import LegacyPipelineStage from '~/pipelines/components/pipeline_mini_graph/legacy_pipeline_stage.vue'; +import eventHub from '~/pipelines/event_hub'; +import waitForPromises from 'helpers/wait_for_promises'; +import { stageReply } from '../../mock_data'; + +const dropdownPath = 'path.json'; + +describe('Pipelines stage component', () => { + let wrapper; + let mock; + let glTooltipDirectiveMock; + + const createComponent = (props = {}) => { + glTooltipDirectiveMock = jest.fn(); + wrapper = mount(LegacyPipelineStage, { + attachTo: document.body, + directives: { + GlTooltip: glTooltipDirectiveMock, + }, + propsData: { + stage: { + status: { + group: 'success', + icon: 'status_success', + title: 'success', + }, + dropdown_path: dropdownPath, + }, + updateDropdown: false, + ...props, + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + jest.spyOn(eventHub, '$emit'); + }); + + afterEach(() => { + eventHub.$emit.mockRestore(); + mock.restore(); + // eslint-disable-next-line @gitlab/vtu-no-explicit-wrapper-destroy + wrapper.destroy(); + }); + + const findCiActionBtn = () => wrapper.find('.js-ci-action'); + const findCiIcon = () => wrapper.findComponent(CiIcon); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findDropdownToggle = () => wrapper.find('button.dropdown-toggle'); + const findDropdownMenu = () => + wrapper.find('[data-testid="mini-pipeline-graph-dropdown-menu-list"]'); + const findDropdownMenuTitle = () => + wrapper.find('[data-testid="pipeline-stage-dropdown-menu-title"]'); + const findMergeTrainWarning = () => wrapper.find('[data-testid="warning-message-merge-trains"]'); + const findLoadingState = () => wrapper.find('[data-testid="pipeline-stage-loading-state"]'); + + const openStageDropdown = async () => { + await findDropdownToggle().trigger('click'); + await waitForPromises(); + await nextTick(); + }; + + describe('loading state', () => { + beforeEach(async () => { + createComponent({ updateDropdown: true }); + + mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); + + await openStageDropdown(); + }); + + it('displays loading state while jobs are being fetched', async () => { + jest.runOnlyPendingTimers(); + await nextTick(); + + expect(findLoadingState().exists()).toBe(true); + expect(findLoadingState().text()).toBe(LegacyPipelineStage.i18n.loadingText); + }); + + it('does not display loading state after jobs have been fetched', async () => { + await waitForPromises(); + + expect(findLoadingState().exists()).toBe(false); + }); + }); + + describe('default appearance', () => { + beforeEach(() => { + createComponent(); + }); + + it('sets up the tooltip to not have a show delay animation', () => { + expect(glTooltipDirectiveMock.mock.calls[0][1].modifiers.ds0).toBe(true); + }); + + it('renders a dropdown with the status icon', () => { + expect(findDropdown().exists()).toBe(true); + expect(findDropdownToggle().exists()).toBe(true); + expect(findCiIcon().exists()).toBe(true); + }); + + it('renders a borderless ci-icon', () => { + expect(findCiIcon().exists()).toBe(true); + expect(findCiIcon().props('isBorderless')).toBe(true); + expect(findCiIcon().classes('borderless')).toBe(true); + }); + + it('renders a ci-icon with a custom border class', () => { + expect(findCiIcon().exists()).toBe(true); + expect(findCiIcon().classes('gl-border')).toBe(true); + }); + }); + + describe('when user opens dropdown and stage request is successful', () => { + beforeEach(async () => { + mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); + createComponent(); + + await openStageDropdown(); + await jest.runAllTimers(); + await axios.waitForAll(); + }); + + it('renders the received data and emits the correct events', () => { + expect(findDropdownMenu().text()).toContain(stageReply.latest_statuses[0].name); + expect(findDropdownMenuTitle().text()).toContain(stageReply.name); + expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown'); + expect(wrapper.emitted('miniGraphStageClick')).toEqual([[]]); + }); + + it('refreshes when updateDropdown is set to true', async () => { + expect(mock.history.get).toHaveLength(1); + + wrapper.setProps({ updateDropdown: true }); + await axios.waitForAll(); + + expect(mock.history.get).toHaveLength(2); + }); + }); + + describe('when user opens dropdown and stage request fails', () => { + it('should close the dropdown', async () => { + mock.onGet(dropdownPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + createComponent(); + + await openStageDropdown(); + await axios.waitForAll(); + await waitForPromises(); + + expect(findDropdown().classes('show')).toBe(false); + }); + }); + + describe('update endpoint correctly', () => { + beforeEach(async () => { + const copyStage = { ...stageReply }; + copyStage.latest_statuses[0].name = 'this is the updated content'; + mock.onGet('bar.json').reply(HTTP_STATUS_OK, copyStage); + createComponent({ + stage: { + status: { + group: 'running', + icon: 'status_running', + title: 'running', + }, + dropdown_path: 'bar.json', + }, + }); + await axios.waitForAll(); + }); + + it('should update the stage to request the new endpoint provided', async () => { + await openStageDropdown(); + jest.runOnlyPendingTimers(); + await waitForPromises(); + + expect(findDropdownMenu().text()).toContain('this is the updated content'); + }); + }); + + describe('job update in dropdown', () => { + beforeEach(async () => { + mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); + mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(HTTP_STATUS_OK); + + createComponent(); + await waitForPromises(); + await nextTick(); + }); + + const clickCiAction = async () => { + await openStageDropdown(); + jest.runOnlyPendingTimers(); + await waitForPromises(); + + await findCiActionBtn().trigger('click'); + }; + + it('keeps dropdown open when job item action is clicked', async () => { + await clickCiAction(); + await waitForPromises(); + + expect(findDropdown().classes('show')).toBe(true); + }); + }); + + describe('With merge trains enabled', () => { + it('shows a warning on the dropdown', async () => { + mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); + createComponent({ + isMergeTrain: true, + }); + + await openStageDropdown(); + jest.runOnlyPendingTimers(); + await waitForPromises(); + + const warning = findMergeTrainWarning(); + + expect(warning.text()).toBe('Merge train pipeline jobs can not be retried'); + }); + }); + + describe('With merge trains disabled', () => { + beforeEach(async () => { + mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); + createComponent(); + + await openStageDropdown(); + await axios.waitForAll(); + }); + + it('does not show a warning on the dropdown', () => { + const warning = findMergeTrainWarning(); + + expect(warning.exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js index 21d92fec9bf..1989aad12b0 100644 --- a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js +++ b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js @@ -1,247 +1,46 @@ -import { GlDropdown } from '@gitlab/ui'; -import { nextTick } from 'vue'; -import { mount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import axios from '~/lib/utils/axios_utils'; -import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; -import PipelineStage from '~/pipelines/components/pipeline_mini_graph/pipeline_stage.vue'; -import eventHub from '~/pipelines/event_hub'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import { stageReply } from '../../mock_data'; - -const dropdownPath = 'path.json'; - -describe('Pipelines stage component', () => { - let wrapper; - let mock; - let glTooltipDirectiveMock; - - const createComponent = (props = {}) => { - glTooltipDirectiveMock = jest.fn(); - wrapper = mount(PipelineStage, { - attachTo: document.body, - directives: { - GlTooltip: glTooltipDirectiveMock, - }, - propsData: { - stage: { - status: { - group: 'success', - icon: 'status_success', - title: 'success', - }, - dropdown_path: dropdownPath, - }, - updateDropdown: false, - ...props, - }, - }); - }; +import createMockApollo from 'helpers/mock_apollo_helper'; - beforeEach(() => { - mock = new MockAdapter(axios); - jest.spyOn(eventHub, '$emit'); - }); +import getPipelineStageQuery from '~/pipelines/graphql/queries/get_pipeline_stage.query.graphql'; +import PipelineStage from '~/pipelines/components/pipeline_mini_graph/pipeline_stage.vue'; - afterEach(() => { - eventHub.$emit.mockRestore(); - mock.restore(); - // eslint-disable-next-line @gitlab/vtu-no-explicit-wrapper-destroy - wrapper.destroy(); - }); +Vue.use(VueApollo); - const findCiActionBtn = () => wrapper.find('.js-ci-action'); - const findCiIcon = () => wrapper.findComponent(CiIcon); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownToggle = () => wrapper.find('button.dropdown-toggle'); - const findDropdownMenu = () => - wrapper.find('[data-testid="mini-pipeline-graph-dropdown-menu-list"]'); - const findDropdownMenuTitle = () => - wrapper.find('[data-testid="pipeline-stage-dropdown-menu-title"]'); - const findMergeTrainWarning = () => wrapper.find('[data-testid="warning-message-merge-trains"]'); - const findLoadingState = () => wrapper.find('[data-testid="pipeline-stage-loading-state"]'); +describe('PipelineStage', () => { + let wrapper; + let pipelineStageResponse; - const openStageDropdown = async () => { - await findDropdownToggle().trigger('click'); - await waitForPromises(); - await nextTick(); + const defaultProps = { + pipelineEtag: '/etag', + stageId: '1', }; - describe('loading state', () => { - beforeEach(async () => { - createComponent({ updateDropdown: true }); - - mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); + const createComponent = ({ pipelineStageHandler = pipelineStageResponse } = {}) => { + const handlers = [[getPipelineStageQuery, pipelineStageHandler]]; + const mockApollo = createMockApollo(handlers); - await openStageDropdown(); - }); - - it('displays loading state while jobs are being fetched', async () => { - jest.runOnlyPendingTimers(); - await nextTick(); - - expect(findLoadingState().exists()).toBe(true); - expect(findLoadingState().text()).toBe(PipelineStage.i18n.loadingText); + wrapper = shallowMountExtended(PipelineStage, { + propsData: { + ...defaultProps, + }, + apolloProvider: mockApollo, }); - it('does not display loading state after jobs have been fetched', async () => { - await waitForPromises(); + return waitForPromises(); + }; - expect(findLoadingState().exists()).toBe(false); - }); - }); + const findPipelineStage = () => wrapper.findComponent(PipelineStage); - describe('default appearance', () => { + describe('when mounted', () => { beforeEach(() => { createComponent(); }); - it('sets up the tooltip to not have a show delay animation', () => { - expect(glTooltipDirectiveMock.mock.calls[0][1].modifiers.ds0).toBe(true); - }); - - it('renders a dropdown with the status icon', () => { - expect(findDropdown().exists()).toBe(true); - expect(findDropdownToggle().exists()).toBe(true); - expect(findCiIcon().exists()).toBe(true); - }); - - it('renders a borderless ci-icon', () => { - expect(findCiIcon().exists()).toBe(true); - expect(findCiIcon().props('isBorderless')).toBe(true); - expect(findCiIcon().classes('borderless')).toBe(true); - }); - - it('renders a ci-icon with a custom border class', () => { - expect(findCiIcon().exists()).toBe(true); - expect(findCiIcon().classes('gl-border')).toBe(true); - }); - }); - - describe('when user opens dropdown and stage request is successful', () => { - beforeEach(async () => { - mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); - createComponent(); - - await openStageDropdown(); - await jest.runAllTimers(); - await axios.waitForAll(); - }); - - it('renders the received data and emits the correct events', () => { - expect(findDropdownMenu().text()).toContain(stageReply.latest_statuses[0].name); - expect(findDropdownMenuTitle().text()).toContain(stageReply.name); - expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown'); - expect(wrapper.emitted('miniGraphStageClick')).toEqual([[]]); - }); - - it('refreshes when updateDropdown is set to true', async () => { - expect(mock.history.get).toHaveLength(1); - - wrapper.setProps({ updateDropdown: true }); - await axios.waitForAll(); - - expect(mock.history.get).toHaveLength(2); - }); - }); - - describe('when user opens dropdown and stage request fails', () => { - it('should close the dropdown', async () => { - mock.onGet(dropdownPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); - createComponent(); - - await openStageDropdown(); - await axios.waitForAll(); - await waitForPromises(); - - expect(findDropdown().classes('show')).toBe(false); - }); - }); - - describe('update endpoint correctly', () => { - beforeEach(async () => { - const copyStage = { ...stageReply }; - copyStage.latest_statuses[0].name = 'this is the updated content'; - mock.onGet('bar.json').reply(HTTP_STATUS_OK, copyStage); - createComponent({ - stage: { - status: { - group: 'running', - icon: 'status_running', - title: 'running', - }, - dropdown_path: 'bar.json', - }, - }); - await axios.waitForAll(); - }); - - it('should update the stage to request the new endpoint provided', async () => { - await openStageDropdown(); - jest.runOnlyPendingTimers(); - await waitForPromises(); - - expect(findDropdownMenu().text()).toContain('this is the updated content'); - }); - }); - - describe('job update in dropdown', () => { - beforeEach(async () => { - mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); - mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(HTTP_STATUS_OK); - - createComponent(); - await waitForPromises(); - await nextTick(); - }); - - const clickCiAction = async () => { - await openStageDropdown(); - jest.runOnlyPendingTimers(); - await waitForPromises(); - - await findCiActionBtn().trigger('click'); - }; - - it('keeps dropdown open when job item action is clicked', async () => { - await clickCiAction(); - await waitForPromises(); - - expect(findDropdown().classes('show')).toBe(true); - }); - }); - - describe('With merge trains enabled', () => { - it('shows a warning on the dropdown', async () => { - mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); - createComponent({ - isMergeTrain: true, - }); - - await openStageDropdown(); - jest.runOnlyPendingTimers(); - await waitForPromises(); - - const warning = findMergeTrainWarning(); - - expect(warning.text()).toBe('Merge train pipeline jobs can not be retried'); - }); - }); - - describe('With merge trains disabled', () => { - beforeEach(async () => { - mock.onGet(dropdownPath).reply(HTTP_STATUS_OK, stageReply); - createComponent(); - - await openStageDropdown(); - await axios.waitForAll(); - }); - - it('does not show a warning on the dropdown', () => { - const warning = findMergeTrainWarning(); - - expect(warning.exists()).toBe(false); + it('renders job item', () => { + expect(findPipelineStage().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stages_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stages_spec.js index 73e810bde99..c212087b7e3 100644 --- a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stages_spec.js +++ b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stages_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { pipelines } from 'test_fixtures/pipelines/pipelines.json'; -import PipelineStage from '~/pipelines/components/pipeline_mini_graph/pipeline_stage.vue'; +import LegacyPipelineStage from '~/pipelines/components/pipeline_mini_graph/legacy_pipeline_stage.vue'; import PipelineStages from '~/pipelines/components/pipeline_mini_graph/pipeline_stages.vue'; const mockStages = pipelines[0].details.stages; @@ -8,8 +8,8 @@ const mockStages = pipelines[0].details.stages; describe('Pipeline Stages', () => { let wrapper; - const findPipelineStages = () => wrapper.findAllComponents(PipelineStage); - const findPipelineStagesAt = (i) => findPipelineStages().at(i); + const findLegacyPipelineStages = () => wrapper.findAllComponents(LegacyPipelineStage); + const findPipelineStagesAt = (i) => findLegacyPipelineStages().at(i); const createComponent = (props = {}) => { wrapper = shallowMount(PipelineStages, { @@ -23,14 +23,14 @@ describe('Pipeline Stages', () => { it('renders stages', () => { createComponent(); - expect(findPipelineStages()).toHaveLength(mockStages.length); + expect(findLegacyPipelineStages()).toHaveLength(mockStages.length); }); it('does not fail when stages are empty', () => { createComponent({ stages: [] }); expect(wrapper.exists()).toBe(true); - expect(findPipelineStages()).toHaveLength(0); + expect(findLegacyPipelineStages()).toHaveLength(0); }); it('update dropdown is false by default', () => { diff --git a/spec/frontend/sidebar/components/confidential/confidentiality_dropdown_spec.js b/spec/frontend/sidebar/components/confidential/confidentiality_dropdown_spec.js new file mode 100644 index 00000000000..571c2add626 --- /dev/null +++ b/spec/frontend/sidebar/components/confidential/confidentiality_dropdown_spec.js @@ -0,0 +1,62 @@ +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import ConfidentialityDropdown from '~/sidebar/components/confidential/confidentiality_dropdown.vue'; + +describe('ConfidentialityDropdown component', () => { + let wrapper; + + const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); + const findHiddenInput = () => wrapper.find('input'); + + function createComponent() { + wrapper = shallowMount(ConfidentialityDropdown, { + stubs: { + GlCollapsibleListbox, + }, + }); + } + + describe('with no value selected', () => { + beforeEach(() => { + createComponent(); + }); + + it('hidden input value is undefined', () => { + expect(findHiddenInput().attributes('value')).toBeUndefined(); + }); + + it('renders default text', () => { + expect(findDropdown().props('toggleText')).toBe('Select confidentiality'); + }); + }); + + describe('when selecting a value', () => { + const optionToSelect = { text: 'Not confidential', value: 'false' }; + + beforeEach(() => { + createComponent(); + findDropdown().vm.$emit('select', optionToSelect.value); + }); + + it('updates value of the hidden input', () => { + expect(findHiddenInput().attributes('value')).toBe(optionToSelect.value); + }); + }); + + describe('when reset is triggered', () => { + beforeEach(() => { + createComponent(); + findDropdown().vm.$emit('select', 'true'); + }); + + it('clears dropdown selection', async () => { + expect(findDropdown().props('toggleText')).not.toBe('Select confidentiality'); + + findDropdown().vm.$emit('reset'); + await nextTick(); + + expect(findDropdown().props('toggleText')).toBe('Select confidentiality'); + }); + }); +}); diff --git a/spec/frontend/tracing/components/tracing_list_filtered_search_spec.js b/spec/frontend/tracing/components/tracing_list_filtered_search_spec.js index df5703888e2..ad15dd4a371 100644 --- a/spec/frontend/tracing/components/tracing_list_filtered_search_spec.js +++ b/spec/frontend/tracing/components/tracing_list_filtered_search_spec.js @@ -6,7 +6,7 @@ import TracingListFilteredSearch from '~/tracing/components/tracing_list_filtere describe('TracingListFilteredSearch', () => { let wrapper; const initialFilters = [ - { type: 'time_range', value: 'last1h' }, + { type: 'period', value: '1h' }, { type: 'service_name', value: 'example-service' }, ]; beforeEach(() => { @@ -26,8 +26,13 @@ describe('TracingListFilteredSearch', () => { }); it('emits submit event on filtered search submit', () => { - wrapper.findComponent(GlFilteredSearch).vm.$emit('submit', { filters: [] }); + wrapper + .findComponent(GlFilteredSearch) + .vm.$emit('submit', { filters: [{ type: 'period', value: '1h' }] }); + expect(wrapper.emitted('submit')).toHaveLength(1); - expect(wrapper.emitted('submit')[0][0]).toEqual({ filters: [] }); + expect(wrapper.emitted('submit')[0][0]).toEqual({ + filters: [{ type: 'period', value: '1h' }], + }); }); }); diff --git a/spec/frontend/tracing/components/tracing_list_spec.js b/spec/frontend/tracing/components/tracing_list_spec.js index e28311abb22..9aa37ac9c9c 100644 --- a/spec/frontend/tracing/components/tracing_list_spec.js +++ b/spec/frontend/tracing/components/tracing_list_spec.js @@ -11,13 +11,13 @@ import { filterObjToQuery, filterObjToFilterToken, filterTokensToFilterObj, -} from '~/tracing/utils'; +} from '~/tracing/filters'; import FilteredSearch from '~/tracing/components/tracing_list_filtered_search.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; import setWindowLocation from 'helpers/set_window_location_helper'; jest.mock('~/alert'); -jest.mock('~/tracing/utils'); +jest.mock('~/tracing/filters'); describe('TracingList', () => { let wrapper; @@ -146,6 +146,14 @@ describe('TracingList', () => { expect(filterObjToQuery).toHaveBeenCalledWith(mockUpdatedFilterObj); expect(findUrlSync().props('query')).toBe(mockUpdatedQuery); }); + + it('fetches traces with filters', () => { + expect(observabilityClientMock.fetchTraces).toHaveBeenCalledWith(mockFilterObj); + + findFilteredSearch().vm.$emit('submit', {}); + + expect(observabilityClientMock.fetchTraces).toHaveBeenLastCalledWith(mockUpdatedFilterObj); + }); }); describe('when tracing is not enabled', () => { diff --git a/spec/frontend/tracing/utils_spec.js b/spec/frontend/tracing/filters_spec.js index e726ed63e18..ee396326f45 100644 --- a/spec/frontend/tracing/utils_spec.js +++ b/spec/frontend/tracing/filters_spec.js @@ -5,20 +5,18 @@ import { processFilters, } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; + import { - TIME_RANGE_FILTER_TOKEN_TYPE, + PERIOD_FILTER_TOKEN_TYPE, SERVICE_NAME_FILTER_TOKEN_TYPE, OPERATION_FILTER_TOKEN_TYPE, TRACE_ID_FILTER_TOKEN_TYPE, - DURATION_FILTER_TOKEN_TYPE, -} from '~/tracing/constants'; - -import { + DURATION_MS_FILTER_TOKEN_TYPE, queryToFilterObj, filterObjToQuery, filterObjToFilterToken, filterTokensToFilterObj, -} from '~/tracing/utils'; +} from '~/tracing/filters'; jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils'); @@ -27,11 +25,11 @@ describe('utils', () => { it('should build a filter obj', () => { const url = 'http://example.com/'; urlQueryToFilter.mockReturnValue({ - time_range: 'last_7_days', + period: '7d', service: 'my_service', operation: 'my_operation', trace_id: 'my_trace_id', - duration: '500', + durationMs: '500', [FILTERED_SEARCH_TERM]: 'test', }); @@ -45,11 +43,11 @@ describe('utils', () => { filteredSearchTermKey: 'search', }); expect(filterObj).toEqual({ - timeRange: 'last_7_days', + period: '7d', service: 'my_service', operation: 'my_operation', traceId: 'my_trace_id', - duration: '500', + durationMs: '500', search: 'test', }); }); @@ -60,27 +58,27 @@ describe('utils', () => { filterToQueryObject.mockReturnValue('mockquery'); const query = filterObjToQuery({ - timeRange: 'last_7_days', + period: '7d', serviceName: 'my_service', operation: 'my_operation', traceId: 'my_trace_id', - duration: '500', + durationMs: '500', search: 'test', }); expect(filterToQueryObject).toHaveBeenCalledWith( { - time_range: 'last_7_days', + period: '7d', service: 'my_service', operation: 'my_operation', trace_id: 'my_trace_id', - duration: '500', + durationMs: '500', 'filtered-search-term': 'test', }, { customOperators: [ - { applyOnlyToKey: 'duration', operator: '>', prefix: 'gt' }, - { applyOnlyToKey: 'duration', operator: '<', prefix: 'lt' }, + { applyOnlyToKey: 'durationMs', operator: '>', prefix: 'gt' }, + { applyOnlyToKey: 'durationMs', operator: '<', prefix: 'lt' }, ], filteredSearchTermKey: 'search', }, @@ -95,20 +93,20 @@ describe('utils', () => { prepareTokens.mockReturnValue(mockTokens); const tokens = filterObjToFilterToken({ - timeRange: 'last_7_days', + period: '7d', serviceName: 'my_service', operation: 'my_operation', traceId: 'my_trace_id', - duration: '500', + durationMs: '500', search: 'test', }); expect(prepareTokens).toHaveBeenCalledWith({ - [TIME_RANGE_FILTER_TOKEN_TYPE]: 'last_7_days', + [PERIOD_FILTER_TOKEN_TYPE]: '7d', [SERVICE_NAME_FILTER_TOKEN_TYPE]: 'my_service', [OPERATION_FILTER_TOKEN_TYPE]: 'my_operation', [TRACE_ID_FILTER_TOKEN_TYPE]: 'my_trace_id', - [DURATION_FILTER_TOKEN_TYPE]: '500', + [DURATION_MS_FILTER_TOKEN_TYPE]: '500', [FILTERED_SEARCH_TERM]: 'test', }); expect(tokens).toBe(mockTokens); @@ -120,10 +118,10 @@ describe('utils', () => { const mockTokens = []; processFilters.mockReturnValue({ [SERVICE_NAME_FILTER_TOKEN_TYPE]: 'my_service', - [TIME_RANGE_FILTER_TOKEN_TYPE]: 'last_7_days', + [PERIOD_FILTER_TOKEN_TYPE]: '7d', [OPERATION_FILTER_TOKEN_TYPE]: 'my_operation', [TRACE_ID_FILTER_TOKEN_TYPE]: 'my_trace_id', - [DURATION_FILTER_TOKEN_TYPE]: '500', + [DURATION_MS_FILTER_TOKEN_TYPE]: '500', [FILTERED_SEARCH_TERM]: 'test', }); @@ -132,10 +130,10 @@ describe('utils', () => { expect(processFilters).toHaveBeenCalledWith(mockTokens); expect(filterObj).toEqual({ serviceName: 'my_service', - timeRange: 'last_7_days', + period: '7d', operation: 'my_operation', traceId: 'my_trace_id', - duration: '500', + durationMs: '500', search: 'test', }); }); diff --git a/spec/graphql/types/work_items/linked_item_type_spec.rb b/spec/graphql/types/work_items/linked_item_type_spec.rb new file mode 100644 index 00000000000..7d7fda45ce4 --- /dev/null +++ b/spec/graphql/types/work_items/linked_item_type_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::WorkItems::LinkedItemType, feature_category: :portfolio_management do + specify { expect(described_class.graphql_name).to eq('LinkedWorkItemType') } + + it 'exposes the expected fields' do + expected_fields = %i[linkCreatedAt linkId linkType linkUpdatedAt workItem] + + expect(described_class).to have_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/work_items/widget_interface_spec.rb b/spec/graphql/types/work_items/widget_interface_spec.rb index d955ec5023e..645e63033c5 100644 --- a/spec/graphql/types/work_items/widget_interface_spec.rb +++ b/spec/graphql/types/work_items/widget_interface_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Types::WorkItems::WidgetInterface do +RSpec.describe Types::WorkItems::WidgetInterface, feature_category: :team_planning do include GraphqlHelpers it 'exposes the expected fields' do @@ -23,6 +23,7 @@ RSpec.describe Types::WorkItems::WidgetInterface do WorkItems::Widgets::Notifications | Types::WorkItems::Widgets::NotificationsType WorkItems::Widgets::CurrentUserTodos | Types::WorkItems::Widgets::CurrentUserTodosType WorkItems::Widgets::AwardEmoji | Types::WorkItems::Widgets::AwardEmojiType + WorkItems::Widgets::LinkedItems | Types::WorkItems::Widgets::LinkedItemsType end with_them do diff --git a/spec/graphql/types/work_items/widgets/linked_items_type_spec.rb b/spec/graphql/types/work_items/widgets/linked_items_type_spec.rb new file mode 100644 index 00000000000..28916070ed8 --- /dev/null +++ b/spec/graphql/types/work_items/widgets/linked_items_type_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::WorkItems::Widgets::LinkedItemsType, feature_category: :portfolio_management do + it 'exposes the expected fields' do + expected_fields = %i[type linkedItems] + + expect(described_class.graphql_name).to eq('WorkItemWidgetLinkedItems') + expect(described_class).to have_graphql_fields(*expected_fields) + end +end diff --git a/spec/lib/api/entities/user_spec.rb b/spec/lib/api/entities/user_spec.rb index 6475dcd7618..1d80aad2127 100644 --- a/spec/lib/api/entities/user_spec.rb +++ b/spec/lib/api/entities/user_spec.rb @@ -54,19 +54,7 @@ RSpec.describe API::Entities::User do it_behaves_like 'exposes relationship' end - context 'when current user can read user profile and disable_follow_users is switched off' do - let(:can_read_user_profile) { true } - - before do - stub_feature_flags(disable_follow_users: false) - user.enabled_following = false - user.save! - end - - it_behaves_like 'exposes relationship' - end - - context 'when current user can read user profile, disable_follow_users is switched on and user disabled it for themself' do + context 'when current user can read user profile and user disabled it for themself' do let(:can_read_user_profile) { true } before do @@ -77,7 +65,7 @@ RSpec.describe API::Entities::User do it_behaves_like 'does not expose relationship' end - context 'when current user can read user profile, disable_follow_users is switched on and current user disabled it for themself' do + context 'when current user can read user profile and current user disabled it for themself' do let(:can_read_user_profile) { true } before do diff --git a/spec/lib/api/ml/mlflow/api_helpers_spec.rb b/spec/lib/api/ml/mlflow/api_helpers_spec.rb new file mode 100644 index 00000000000..4f6a37c66c4 --- /dev/null +++ b/spec/lib/api/ml/mlflow/api_helpers_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Ml::Mlflow::ApiHelpers, feature_category: :mlops do + include described_class + + describe '#packages_url' do + subject { packages_url } + + let_it_be(:user_project) { build_stubbed(:project) } + + context 'with an empty relative URL root' do + before do + allow(Gitlab::Application.routes).to receive(:default_url_options) + .and_return(protocol: 'http', host: 'localhost', script_name: '') + end + + it { is_expected.to eql("http://localhost/api/v4/projects/#{user_project.id}/packages/generic") } + end + + context 'with a forward slash relative URL root' do + before do + allow(Gitlab::Application.routes).to receive(:default_url_options) + .and_return(protocol: 'http', host: 'localhost', script_name: '/') + end + + it { is_expected.to eql("http://localhost/api/v4/projects/#{user_project.id}/packages/generic") } + end + + context 'with a relative URL root' do + before do + allow(Gitlab::Application.routes).to receive(:default_url_options) + .and_return(protocol: 'http', host: 'localhost', script_name: '/gitlab/root') + end + + it { is_expected.to eql("http://localhost/gitlab/root/api/v4/projects/#{user_project.id}/packages/generic") } + end + end +end diff --git a/spec/migrations/20230807083334_add_linked_items_work_item_widget_spec.rb b/spec/migrations/20230807083334_add_linked_items_work_item_widget_spec.rb new file mode 100644 index 00000000000..cd6da15403f --- /dev/null +++ b/spec/migrations/20230807083334_add_linked_items_work_item_widget_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe AddLinkedItemsWorkItemWidget, :migration, feature_category: :portfolio_management do + it_behaves_like 'migration that adds widget to work items definitions', widget_name: 'Linked items' do + let(:work_item_type_count) { 8 } + end +end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 4011040a534..7e572e2fdc6 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: let_it_be(:user) { create(:user, :public_email) } let_it_be(:namespace) { create_default(:namespace).freeze } - let_it_be(:project) { create_default(:project, :repository).freeze } + let_it_be_with_refind(:project) { create_default(:project, :repository).freeze } it 'paginates 15 pipelines per page' do expect(described_class.default_per_page).to eq(15) @@ -2280,7 +2280,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: end describe '#modified_paths' do - let(:pipeline) { create(:ci_empty_pipeline, :created) } + let(:pipeline) { create(:ci_empty_pipeline, :created, project: project) } context 'when old and new revisions are set' do before do diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb index 73df283d996..7dafec2536f 100644 --- a/spec/models/clusters/cluster_spec.rb +++ b/spec/models/clusters/cluster_spec.rb @@ -26,7 +26,6 @@ RSpec.describe Clusters::Cluster, :use_clean_rails_memory_store_caching, it { is_expected.to have_many(:kubernetes_namespaces) } it { is_expected.to have_one(:cluster_project) } it { is_expected.to have_many(:deployment_clusters) } - it { is_expected.to have_many(:metrics_dashboard_annotations) } it { is_expected.to have_many(:successful_deployments) } it { is_expected.to have_many(:environments).through(:deployments) } diff --git a/spec/models/concerns/cross_database_ignored_tables_spec.rb b/spec/models/concerns/cross_database_ignored_tables_spec.rb new file mode 100644 index 00000000000..901a6f39eaf --- /dev/null +++ b/spec/models/concerns/cross_database_ignored_tables_spec.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe CrossDatabaseIgnoredTables, feature_category: :cell, query_analyzers: false do + # We enable only the PreventCrossDatabaseModification query analyzer in these tests + before do + stub_const("CiModel", ci_model) + allow(Gitlab::Database::QueryAnalyzer.instance).to receive(:all_analyzers).and_return( + [Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification] + ) + end + + around do |example| + Gitlab::Database::QueryAnalyzer.instance.within { example.run } + end + + let(:cross_database_exception) do + Gitlab::Database::QueryAnalyzers:: + PreventCrossDatabaseModification::CrossDatabaseModificationAcrossUnsupportedTablesError + end + + let(:ci_model) do + Class.new(Ci::ApplicationRecord) do + self.table_name = '_test_gitlab_ci_items' + + belongs_to :main_model_object, class_name: 'MainModel', + inverse_of: 'ci_model_object', foreign_key: 'main_model_id' + end + end + + before_all do + Ci::ApplicationRecord.connection.execute( + 'CREATE TABLE _test_gitlab_ci_items( + id BIGSERIAL PRIMARY KEY, main_model_id INTEGER, updated_at timestamp without time zone + )' + ) + ApplicationRecord.connection.execute( + 'CREATE TABLE _test_gitlab_main_items( + id BIGSERIAL PRIMARY KEY, updated_at timestamp without time zone + )' + ) + end + + after(:all) do + ApplicationRecord.connection.execute('DROP TABLE _test_gitlab_main_items') + Ci::ApplicationRecord.connection.execute('DROP TABLE _test_gitlab_ci_items') + end + + describe '.cross_database_ignore_tables' do + context 'when the tables are not ignored' do + before do + stub_const("MainModel", create_main_model([], [])) + end + + it 'raises an error when we doing cross-database modification using create' do + expect { MainModel.create! }.to raise_error(cross_database_exception) + end + + it 'raises an error when we doing cross-database modification using update' do + main_model_object = create_main_model_object + expect { main_model_object.update!(updated_at: Time.zone.now) }.to raise_error(cross_database_exception) + end + + it 'raises an error when we doing cross-database modification using destroy' do + main_model_object = create_main_model_object + expect { main_model_object.destroy! }.to raise_error(cross_database_exception) + end + end + + context 'when the tables are ignored on save' do + before do + stub_const("MainModel", create_main_model(%w[_test_gitlab_ci_items], %I[save])) + end + + it 'does not raise an error when creating a new object' do + expect { MainModel.create! }.not_to raise_error + end + + it 'does not raise an error when updating an existing object' do + main_model_object = create_main_model_object + expect { main_model_object.update!(updated_at: Time.zone.now) }.not_to raise_error + end + + it 'still raises an error when deleting an object' do # save doesn't include destroy + main_model_object = create_main_model_object + expect { main_model_object.destroy! }.to raise_error(cross_database_exception) + end + end + + context 'when the tables are ignored on save with if statement' do + before do + stub_const( + "MainModel", + create_main_model( + %w[_test_gitlab_ci_items], + %I[save], + & proc { condition } + ) + ) + + expect_next_instance_of(MainModel) do |instance| + allow(instance).to receive(:condition).and_return(condition_value) + end + end + + context 'when condition returns true' do + let(:condition_value) { true } + + it 'does not raise an error on creating a new object' do + expect { MainModel.create! }.not_to raise_error + end + end + + context 'when condition returns false' do + let(:condition_value) { false } + + it 'raises an error on creating a new object' do + expect { MainModel.create! }.to raise_error(cross_database_exception) + end + end + end + + context 'when the tables are ignored on create' do + before do + stub_const("MainModel", create_main_model(%w[_test_gitlab_ci_items], %I[create])) + end + + it 'does not raise an error when creating a new object' do + expect { MainModel.create! }.not_to raise_error + end + + it 'raises an error when updating an existing object' do + main_model_object = create_main_model_object + expect { main_model_object.update!(updated_at: Time.zone.now) }.to raise_error(cross_database_exception) + end + + it 'still raises an error when deleting an object' do + main_model_object = create_main_model_object + expect { main_model_object.destroy! }.to raise_error(cross_database_exception) + end + end + + context 'when the tables are ignored on update' do + before do + stub_const("MainModel", create_main_model(%w[_test_gitlab_ci_items], %I[update])) + end + + it 'raises an error when creating a new object' do + expect { MainModel.create! }.to raise_error(cross_database_exception) + end + + it 'does not raise an error when updating an existing object' do + main_model_object = create_main_model_object + expect { main_model_object.update!(updated_at: Time.zone.now) }.not_to raise_error + end + + it 'still raises an error when deleting an object' do + main_model_object = create_main_model_object + expect { main_model_object.destroy! }.to raise_error(cross_database_exception) + end + end + + context 'when the tables are ignored on create and destroy' do + before do + stub_const("MainModel", create_main_model(%w[_test_gitlab_ci_items], %I[create destroy])) + end + + it 'does not raise an error when creating a new object' do + expect { MainModel.create! }.not_to raise_error + end + + it 'raises an error when updating an existing object' do + main_model_object = create_main_model_object + expect { main_model_object.update!(updated_at: Time.zone.now) }.to raise_error(cross_database_exception) + end + + it 'does not raise an error when deleting an object' do + main_model_object = create_main_model_object + expect { main_model_object.destroy! }.not_to raise_error + end + end + end + + def create_main_model(ignored_tables, events, &condition_block) + klass = Class.new(ApplicationRecord) do + include CrossDatabaseIgnoredTables + + self.table_name = '_test_gitlab_main_items' + + has_one :ci_model_object, autosave: true, class_name: 'CiModel', + inverse_of: 'main_model_object', foreign_key: 'main_model_id', + dependent: :nullify, touch: true + before_create :prepare_ci_model_object + + def condition + true + end + + def prepare_ci_model_object + build_ci_model_object + end + end + + if ignored_tables.any? && events.any? + klass.class_eval do + cross_database_ignore_tables ignored_tables, on: events, url: "TODO", if: condition_block + end + end + + klass + end + + # This helper allows creating a test model object without raising a cross database exception + def create_main_model_object + Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction( + [CiModel.table_name], url: "TODO" + ) do + MainModel.create! + end + end +end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 2ad92907d77..d20069d15e0 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -22,7 +22,6 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ it { is_expected.to belong_to(:cluster_agent).optional } it { is_expected.to have_many(:deployments) } - it { is_expected.to have_many(:metrics_dashboard_annotations) } it { is_expected.to have_many(:alert_management_alerts) } it { is_expected.to have_one(:upcoming_deployment) } it { is_expected.to have_one(:latest_opened_most_severe_alert) } diff --git a/spec/models/metrics/dashboard/annotation_spec.rb b/spec/models/metrics/dashboard/annotation_spec.rb index 9b8601e4052..7c4f392fcdc 100644 --- a/spec/models/metrics/dashboard/annotation_spec.rb +++ b/spec/models/metrics/dashboard/annotation_spec.rb @@ -5,11 +5,6 @@ require 'spec_helper' RSpec.describe Metrics::Dashboard::Annotation do using RSpec::Parameterized::TableSyntax - describe 'associations' do - it { is_expected.to belong_to(:environment).inverse_of(:metrics_dashboard_annotations) } - it { is_expected.to belong_to(:cluster).class_name('Clusters::Cluster').inverse_of(:metrics_dashboard_annotations) } - end - describe 'validation' do it { is_expected.to validate_presence_of(:description) } it { is_expected.to validate_presence_of(:dashboard_path) } @@ -18,18 +13,6 @@ RSpec.describe Metrics::Dashboard::Annotation do it { is_expected.to validate_length_of(:panel_xid).is_at_most(255) } it { is_expected.to validate_length_of(:description).is_at_most(255) } - context 'orphaned annotation' do - subject { build(:metrics_dashboard_annotation, environment: nil) } - - it { is_expected.not_to be_valid } - - it 'reports error about both missing relations' do - subject.valid? - - expect(subject.errors.full_messages).to include(/Annotation must belong to a cluster or an environment/) - end - end - context 'ending_at_after_starting_at' do where(:starting_at, :ending_at, :valid?, :message) do 2.days.ago.beginning_of_day | 1.day.ago.beginning_of_day | true | nil @@ -49,28 +32,6 @@ RSpec.describe Metrics::Dashboard::Annotation do end end end - - context 'environments annotation' do - subject { build(:metrics_dashboard_annotation) } - - it { is_expected.to be_valid } - end - - context 'clusters annotation' do - subject { build(:metrics_dashboard_annotation, :with_cluster) } - - it { is_expected.to be_valid } - end - - context 'annotation with shared ownership' do - subject { build(:metrics_dashboard_annotation, :with_cluster, environment: build(:environment)) } - - it 'reports error about both shared ownership' do - subject.valid? - - expect(subject.errors.full_messages).to include(/Annotation can't belong to both a cluster and an environment at the same time/) - end - end end describe 'scopes' do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 5d964044041..788600194a5 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -4079,32 +4079,6 @@ RSpec.describe User, feature_category: :user_profile do expect(user.following?(followee)).to be_falsey end - - context 'when disable_follow_users feature flag is off' do - before do - stub_feature_flags(disable_follow_users: false) - end - - it 'follows user even if user disabled following' do - user = create(:user) - user.enabled_following = false - - followee = create(:user) - - expect(user.follow(followee)).to be_truthy - expect(user.following?(followee)).to be_truthy - end - - it 'follows user even if followee user disabled following' do - user = create(:user) - - followee = create(:user) - followee.enabled_following = false - - expect(user.follow(followee)).to be_truthy - expect(user.following?(followee)).to be_truthy - end - end end describe '#unfollow' do @@ -4151,15 +4125,11 @@ RSpec.describe User, feature_category: :user_profile do let_it_be(:user) { create(:user) } let_it_be(:followee) { create(:user) } - where(:user_enabled_following, :followee_enabled_following, :feature_flag_status, :result) do - true | true | false | true - true | false | false | true - true | true | true | true - true | false | true | false - false | true | false | true - false | true | true | false - false | false | false | true - false | false | true | false + where(:user_enabled_following, :followee_enabled_following, :result) do + true | true | true + true | false | false + false | true | false + false | false | false end with_them do @@ -4167,7 +4137,6 @@ RSpec.describe User, feature_category: :user_profile do user.enabled_following = user_enabled_following followee.enabled_following = followee_enabled_following followee.save! - stub_feature_flags(disable_follow_users: feature_flag_status) end it { expect(user.following_users_allowed?(followee)).to eq result } diff --git a/spec/models/work_items/widget_definition_spec.rb b/spec/models/work_items/widget_definition_spec.rb index a33e08a1bf2..da772eec39c 100644 --- a/spec/models/work_items/widget_definition_spec.rb +++ b/spec/models/work_items/widget_definition_spec.rb @@ -14,7 +14,8 @@ RSpec.describe WorkItems::WidgetDefinition, feature_category: :team_planning do ::WorkItems::Widgets::Notes, ::WorkItems::Widgets::Notifications, ::WorkItems::Widgets::CurrentUserTodos, - ::WorkItems::Widgets::AwardEmoji + ::WorkItems::Widgets::AwardEmoji, + ::WorkItems::Widgets::LinkedItems ] if Gitlab.ee? diff --git a/spec/models/work_items/widgets/linked_items_spec.rb b/spec/models/work_items/widgets/linked_items_spec.rb new file mode 100644 index 00000000000..b4a53b75561 --- /dev/null +++ b/spec/models/work_items/widgets/linked_items_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItems::Widgets::LinkedItems, feature_category: :portfolio_management do + let_it_be(:user) { create(:user) } + let_it_be(:work_item) { create(:work_item) } + let_it_be(:work_item_link) { create(:work_item_link, source: work_item) } + + describe '.type' do + subject { described_class.type } + + it { is_expected.to eq(:linked_items) } + end + + describe '#type' do + subject { described_class.new(work_item).type } + + it { is_expected.to eq(:linked_items) } + end + + describe '#related_issues' do + it { expect(described_class.new(work_item).related_issues(user)).to eq(work_item.related_issues(user)) } + end +end diff --git a/spec/policies/metrics/dashboard/annotation_policy_spec.rb b/spec/policies/metrics/dashboard/annotation_policy_spec.rb deleted file mode 100644 index 2d1ef0ee0cb..00000000000 --- a/spec/policies/metrics/dashboard/annotation_policy_spec.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Metrics::Dashboard::AnnotationPolicy, :models do - let(:policy) { described_class.new(user, annotation) } - - let_it_be(:user) { create(:user) } - - shared_examples 'metrics dashboard annotation policy' do - context 'when guest' do - before do - project.add_guest(user) - end - - it { expect(policy).to be_disallowed :read_metrics_dashboard_annotation } - it { expect(policy).to be_disallowed :admin_metrics_dashboard_annotation } - end - - context 'when reporter' do - before do - project.add_reporter(user) - end - - it { expect(policy).to be_allowed :read_metrics_dashboard_annotation } - it { expect(policy).to be_disallowed :admin_metrics_dashboard_annotation } - end - - context 'when developer' do - before do - project.add_developer(user) - end - - it { expect(policy).to be_allowed :read_metrics_dashboard_annotation } - it { expect(policy).to be_allowed :admin_metrics_dashboard_annotation } - end - - context 'when maintainer' do - before do - project.add_maintainer(user) - end - - it { expect(policy).to be_allowed :read_metrics_dashboard_annotation } - it { expect(policy).to be_allowed :admin_metrics_dashboard_annotation } - end - end - - describe 'rules' do - context 'environments annotation' do - let_it_be(:environment) { create(:environment) } - let_it_be(:annotation) { create(:metrics_dashboard_annotation, environment: environment) } - - it_behaves_like 'metrics dashboard annotation policy' do - let(:project) { environment.project } - end - end - - context 'cluster annotation' do - let_it_be(:cluster) { create(:cluster, :project) } - let_it_be(:annotation) { create(:metrics_dashboard_annotation, environment: nil, cluster: cluster) } - - it_behaves_like 'metrics dashboard annotation policy' do - let(:project) { cluster.project } - end - end - end -end diff --git a/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb b/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb index 09977cd19d7..c81f6381398 100644 --- a/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb +++ b/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb @@ -7,8 +7,7 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Delete, feature_categ let_it_be(:current_user) { create(:user) } let_it_be(:project) { create(:project, :private, :repository) } - let_it_be(:environment) { create(:environment, project: project) } - let_it_be(:annotation) { create(:metrics_dashboard_annotation, environment: environment) } + let_it_be(:annotation) { create(:metrics_dashboard_annotation) } let(:variables) { { id: GitlabSchema.id_from_object(annotation).to_s } } let(:mutation) { graphql_mutation(:delete_annotation, variables) } @@ -28,14 +27,6 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Delete, feature_categ project.add_developer(current_user) end - context 'with valid params' do - it 'deletes the annotation' do - expect do - post_graphql_mutation(mutation, current_user: current_user) - end.to change { Metrics::Dashboard::Annotation.count }.by(-1) - end - end - context 'with invalid params' do let(:variables) { { id: GitlabSchema.id_from_object(project).to_s } } @@ -44,21 +35,6 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Delete, feature_categ end end - context 'when the delete fails' do - let(:service_response) { { message: 'Annotation has not been deleted', status: :error, last_step: :delete } } - - before do - allow_next_instance_of(Metrics::Dashboard::Annotations::DeleteService) do |delete_service| - allow(delete_service).to receive(:execute).and_return(service_response) - end - end - it 'returns the error' do - post_graphql_mutation(mutation, current_user: current_user) - - expect(mutation_response['errors']).to eq([service_response[:message]]) - end - end - context 'when metrics dashboard feature is unavailable' do before do stub_feature_flags(remove_monitor_metrics: true) diff --git a/spec/requests/api/graphql/project/work_items_spec.rb b/spec/requests/api/graphql/project/work_items_spec.rb index 478112b687a..4aba83dae92 100644 --- a/spec/requests/api/graphql/project/work_items_spec.rb +++ b/spec/requests/api/graphql/project/work_items_spec.rb @@ -361,6 +361,59 @@ RSpec.describe 'getting a work item list for a project', feature_category: :team end end + context 'when fetching work item linked items widget' do + let_it_be(:related_items) { create_list(:work_item, 3, project: project, milestone: milestone1) } + + let(:fields) do + <<~GRAPHQL + nodes { + widgets { + type + ... on WorkItemWidgetLinkedItems { + linkedItems { + nodes { + linkId + linkType + linkCreatedAt + linkUpdatedAt + workItem { + id + widgets { + ... on WorkItemWidgetMilestone { + milestone { + id + } + } + } + } + } + } + } + } + } + GRAPHQL + end + + before do + create(:work_item_link, source: item1, target: related_items[0], link_type: 'relates_to') + end + + it 'executes limited number of N+1 queries', :use_sql_query_cache do + control = ActiveRecord::QueryRecorder.new(skip_cached: false) do + post_graphql(query, current_user: current_user) + end + + create(:work_item_link, source: item1, target: related_items[1], link_type: 'relates_to') + create(:work_item_link, source: item1, target: related_items[2], link_type: 'relates_to') + + expect_graphql_errors_to_be_empty + # TODO: Fix N+1 queries executed for the linked work item widgets + # https://gitlab.com/gitlab-org/gitlab/-/issues/420605 + expect { post_graphql(query, current_user: current_user) } + .not_to exceed_all_query_limit(control).with_threshold(11) + end + end + def item_ids graphql_dig_at(items_data, :node, :id) end diff --git a/spec/requests/api/graphql/work_item_spec.rb b/spec/requests/api/graphql/work_item_spec.rb index 6702224f303..fa354bc1f66 100644 --- a/spec/requests/api/graphql/work_item_spec.rb +++ b/spec/requests/api/graphql/work_item_spec.rb @@ -539,6 +539,79 @@ RSpec.describe 'Query.work_item(id)', feature_category: :team_planning do ) end end + + describe 'linked items widget' do + let_it_be(:related_item1) { create(:work_item, project: project) } + let_it_be(:related_item2) { create(:work_item, project: project) } + let_it_be(:related_item3) { create(:work_item) } + let_it_be(:link1) { create(:work_item_link, source: work_item, target: related_item1, link_type: 'relates_to') } + let_it_be(:link2) { create(:work_item_link, source: work_item, target: related_item2, link_type: 'relates_to') } + let_it_be(:link3) { create(:work_item_link, source: work_item, target: related_item3, link_type: 'relates_to') } + + let(:work_item_fields) do + <<~GRAPHQL + id + widgets { + type + ... on WorkItemWidgetLinkedItems { + linkedItems { + nodes { + linkId + linkType + linkCreatedAt + linkUpdatedAt + workItem { + id + } + } + } + } + } + GRAPHQL + end + + it 'returns widget information' do + expect(work_item_data).to include( + 'widgets' => include( + hash_including( + 'type' => 'LINKED_ITEMS', + 'linkedItems' => { 'nodes' => match_array( + [ + hash_including( + 'linkId' => link1.to_gid.to_s, 'linkType' => 'relates_to', + 'linkCreatedAt' => link1.created_at.iso8601, 'linkUpdatedAt' => link1.updated_at.iso8601, + 'workItem' => { 'id' => related_item1.to_gid.to_s } + ), + hash_including( + 'linkId' => link2.to_gid.to_s, 'linkType' => 'relates_to', + 'linkCreatedAt' => link2.created_at.iso8601, 'linkUpdatedAt' => link2.updated_at.iso8601, + 'workItem' => { 'id' => related_item2.to_gid.to_s } + ) + ] + ) } + ) + ) + ) + end + + context 'when `linked_work_items` feature flag is disabled' do + before do + stub_feature_flags(linked_work_items: false) + post_graphql(query, current_user: current_user) + end + + it 'returns empty result' do + expect(work_item_data).to include( + 'widgets' => include( + hash_including( + 'type' => 'LINKED_ITEMS', + 'linkedItems' => { "nodes" => [] } + ) + ) + ) + end + end + end end describe 'notes widget' do diff --git a/spec/requests/api/ml/mlflow/runs_spec.rb b/spec/requests/api/ml/mlflow/runs_spec.rb index a85fe4d867a..45479666e9a 100644 --- a/spec/requests/api/ml/mlflow/runs_spec.rb +++ b/spec/requests/api/ml/mlflow/runs_spec.rb @@ -39,6 +39,11 @@ RSpec.describe API::Ml::Mlflow::Runs, feature_category: :mlops do response end + before do + allow(Gitlab::Application.routes).to receive(:default_url_options) + .and_return(protocol: 'http', host: 'www.example.com', script_name: '') + end + RSpec.shared_examples 'MLflow|run_id param error cases' do context 'when run id is not passed' do let(:params) { {} } @@ -162,6 +167,17 @@ RSpec.describe API::Ml::Mlflow::Runs, feature_category: :mlops do }) end + context 'with a relative root URL' do + before do + allow(Gitlab::Application.routes).to receive(:default_url_options) + .and_return(protocol: 'http', host: 'www.example.com', script_name: '/gitlab/root') + end + + it 'gets a run including a valid artifact_uri' do + expect(json_response['run']['info']['artifact_uri']).to eql("http://www.example.com/gitlab/root/api/v4/projects/#{project_id}/packages/generic/ml_experiment_#{experiment.iid}/#{candidate.iid}/") + end + end + describe 'Error States' do it_behaves_like 'MLflow|run_id param error cases' it_behaves_like 'MLflow|shared error cases' diff --git a/spec/services/issuable/bulk_update_service_spec.rb b/spec/services/issuable/bulk_update_service_spec.rb index a76d575a1e0..769e8adc750 100644 --- a/spec/services/issuable/bulk_update_service_spec.rb +++ b/spec/services/issuable/bulk_update_service_spec.rb @@ -31,6 +31,23 @@ RSpec.describe Issuable::BulkUpdateService, feature_category: :team_planning do end end + shared_examples 'updates confidentiality' do + it 'succeeds' do + result = bulk_update(issuables, confidential: true) + + expect(result.success?).to be_truthy + expect(result.payload[:count]).to eq(issuables.count) + end + + it 'updates the issuables confidentiality' do + bulk_update(issuables, confidential: true) + + issuables.each do |issuable| + expect(issuable.reload.confidential).to be(true) + end + end + end + shared_examples 'updating labels' do def create_issue_with_labels(labels) create(:labeled_issue, project: project, labels: labels) @@ -303,6 +320,16 @@ RSpec.describe Issuable::BulkUpdateService, feature_category: :team_planning do end end + describe 'updating confidentiality' do + let(:issuables) { create_list(:issue, 2, project: project) } + + it_behaves_like 'updates confidentiality' + + it_behaves_like 'not scheduling cached group count clear' do + let(:params) { { confidential: true } } + end + end + describe 'updating labels' do let(:bug) { create(:label, project: project) } let(:regression) { create(:label, project: project) } @@ -390,6 +417,30 @@ RSpec.describe Issuable::BulkUpdateService, feature_category: :team_planning do end end + describe 'updating confidentiality' do + let_it_be(:project) { create(:project, :repository, group: group) } + + before do + group.add_maintainer(user) + end + + context 'with issues' do + let(:issuables) { create_list(:issue, 2, project: project) } + + it_behaves_like 'updates confidentiality' + end + + context 'with merge requests' do + let(:issuables) { [create(:merge_request, source_project: project, target_project: project)] } + + it 'does not throw an error' do + result = bulk_update(issuables, confidential: true) + + expect(result.success?).to be_truthy + end + end + end + describe 'updating labels' do let(:project) { create(:project, :repository, group: group) } let(:bug) { create(:group_label, group: group) } diff --git a/spec/services/metrics/dashboard/annotations/delete_service_spec.rb b/spec/services/metrics/dashboard/annotations/delete_service_spec.rb deleted file mode 100644 index 557d6d95767..00000000000 --- a/spec/services/metrics/dashboard/annotations/delete_service_spec.rb +++ /dev/null @@ -1,93 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Metrics::Dashboard::Annotations::DeleteService, feature_category: :metrics do - let(:user) { create(:user) } - let(:service_instance) { described_class.new(user, annotation) } - - shared_examples 'executed annotation deletion' do - it 'returns success response', :aggregate_failures do - expect(annotation).to receive(:destroy).and_return(true) - - response = service_instance.execute - - expect(response[:status]).to be :success - end - end - - shared_examples 'prevented annotation deletion' do |message| - it 'returns error response', :aggregate_failures do - response = service_instance.execute - - expect(response[:status]).to be :error - expect(response[:message]).to eql message - end - - it 'does not change db state' do - expect(annotation).not_to receive(:destroy) - - service_instance.execute - end - end - - describe '.execute' do - context 'with specific environment' do - let(:annotation) { create(:metrics_dashboard_annotation, environment: environment) } - let(:environment) { create(:environment) } - - context 'with anonymous user' do - it_behaves_like 'prevented annotation deletion', 'You are not authorized to delete this annotation' - end - - context 'with maintainer user' do - before do - environment.project.add_maintainer(user) - end - - it_behaves_like 'executed annotation deletion' - - context 'annotation failed to delete' do - it 'returns error response', :aggregate_failures do - allow(annotation).to receive(:destroy).and_return(false) - - response = service_instance.execute - - expect(response[:status]).to be :error - expect(response[:message]).to eql 'Annotation has not been deleted' - end - end - end - end - - context 'with specific cluster' do - let(:annotation) { create(:metrics_dashboard_annotation, cluster: cluster, environment: nil) } - - context 'with anonymous user' do - let(:cluster) { create(:cluster, :project) } - - it_behaves_like 'prevented annotation deletion', 'You are not authorized to delete this annotation' - end - - context 'with maintainer user' do - let(:cluster) { create(:cluster, :project) } - - before do - cluster.project.add_maintainer(user) - end - - it_behaves_like 'executed annotation deletion' - end - - context 'with owner user' do - let(:cluster) { create(:cluster, :group) } - - before do - cluster.group.add_owner(user) - end - - it_behaves_like 'executed annotation deletion' - end - end - end -end diff --git a/spec/services/users/update_service_spec.rb b/spec/services/users/update_service_spec.rb index 9ff3d9208fa..4cd78bc3b9c 100644 --- a/spec/services/users/update_service_spec.rb +++ b/spec/services/users/update_service_spec.rb @@ -203,15 +203,6 @@ RSpec.describe Users::UpdateService, feature_category: :user_profile do expect(user.enabled_following).to eq(false) end - it 'does not remove followers/followees if feature flag is off' do - stub_feature_flags(disable_follow_users: false) - - expect do - update_user(user, enabled_following: false) - end.to not_change { user.followed_users.count } - .and not_change { user.following_users.count } - end - context 'when there is more followers/followees then batch limit' do before do stub_env('BATCH_SIZE', 1) diff --git a/spec/support/database/auto_explain.rb b/spec/support/database/auto_explain.rb index 799457034a1..108d88e37b9 100644 --- a/spec/support/database/auto_explain.rb +++ b/spec/support/database/auto_explain.rb @@ -115,16 +115,11 @@ module AutoExplain private def record_auto_explain?(connection) - return false unless ENV['CI'] - return false if ENV['CI_JOB_NAME_SLUG'] == 'db-migrate-non-superuser' - return false if connection.database_version.to_s[0..1].to_i < 14 - return false if connection.select_one('SHOW is_superuser')['is_superuser'] != 'on' - - # This condition matches the pipeline rules for if-merge-request-labels-record-queries - return true if ENV['CI_MERGE_REQUEST_LABELS']&.include?('pipeline:record-queries') - - # This condition matches the pipeline rules for if-default-branch-refs - ENV['CI_COMMIT_REF_NAME'] == ENV['CI_DEFAULT_BRANCH'] && !ENV['CI_MERGE_REQUEST_IID'] + ENV['CI'] \ + && ENV['CI_MERGE_REQUEST_LABELS']&.include?('pipeline:record-queries') \ + && ENV['CI_JOB_NAME_SLUG'] != 'db-migrate-non-superuser' \ + && connection.database_version.to_s[0..1].to_i >= 14 \ + && connection.select_one('SHOW is_superuser')['is_superuser'] == 'on' end end end diff --git a/spec/support/database/prevent_cross_database_modification.rb b/spec/support/database/prevent_cross_database_modification.rb index cd0cbe733d1..77fa7feacd4 100644 --- a/spec/support/database/prevent_cross_database_modification.rb +++ b/spec/support/database/prevent_cross_database_modification.rb @@ -25,7 +25,7 @@ RSpec.configure do |config| end # Reset after execution to preferred state - config.after do |example_file| + config.after do |_example_file| ::Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.suppress_in_rspec = true ::ApplicationRecord.gitlab_transactions_stack.clear diff --git a/spec/support/shared_examples/migrations/add_work_item_widget_shared_examples.rb b/spec/support/shared_examples/migrations/add_work_item_widget_shared_examples.rb index 28eac52256f..fdb31fa5d9d 100644 --- a/spec/support/shared_examples/migrations/add_work_item_widget_shared_examples.rb +++ b/spec/support/shared_examples/migrations/add_work_item_widget_shared_examples.rb @@ -3,12 +3,13 @@ RSpec.shared_examples 'migration that adds widget to work items definitions' do |widget_name:| let(:migration) { described_class.new } let(:work_item_definitions) { table(:work_item_widget_definitions) } + let(:work_item_type_count) { 7 } describe '#up' do it "creates widget definition in all types" do work_item_definitions.where(name: widget_name).delete_all - expect { migrate! }.to change { work_item_definitions.count }.by(7) + expect { migrate! }.to change { work_item_definitions.count }.by(work_item_type_count) expect(work_item_definitions.all.pluck(:name)).to include(widget_name) end @@ -26,7 +27,7 @@ RSpec.shared_examples 'migration that adds widget to work items definitions' do it "removes definitions for widget" do migrate! - expect { migration.down }.to change { work_item_definitions.count }.by(-7) + expect { migration.down }.to change { work_item_definitions.count }.by(-work_item_type_count) expect(work_item_definitions.all.pluck(:name)).not_to include(widget_name) end end |