diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-07-24 18:10:11 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-07-24 18:10:11 +0300 |
commit | 7308ec9d13fb69018200a40f287e76ef499ed47c (patch) | |
tree | 06c75f7ddceebd61d09f925a48fef2789338f3cd /spec | |
parent | f296f23500b4b3758670ae0c5ce2e1779f533e8b (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
29 files changed, 936 insertions, 394 deletions
diff --git a/spec/features/admin/admin_abuse_reports_spec.rb b/spec/features/admin/admin_abuse_reports_spec.rb index 9739ea53f81..18bc851558d 100644 --- a/spec/features/admin/admin_abuse_reports_spec.rb +++ b/spec/features/admin/admin_abuse_reports_spec.rb @@ -2,27 +2,29 @@ require 'spec_helper' -RSpec.describe "Admin::AbuseReports", :js, feature_category: :shared do +RSpec.describe "Admin::AbuseReports", :js, feature_category: :insider_threat do let_it_be(:user) { create(:user) } let_it_be(:admin) { create(:admin) } - context 'as an admin' do - describe 'displayed reports' do - include FilteredSearchHelpers + let_it_be(:open_report) { create(:abuse_report, created_at: 5.days.ago, updated_at: 2.days.ago, category: 'spam', user: user) } + let_it_be(:open_report2) { create(:abuse_report, created_at: 4.days.ago, updated_at: 3.days.ago, category: 'phishing') } + let_it_be(:closed_report) { create(:abuse_report, :closed, user: user, category: 'spam') } - let_it_be(:open_report) { create(:abuse_report, created_at: 5.days.ago, updated_at: 2.days.ago) } - let_it_be(:open_report2) { create(:abuse_report, created_at: 4.days.ago, updated_at: 3.days.ago, category: 'phishing') } - let_it_be(:closed_report) { create(:abuse_report, :closed) } + describe 'as an admin' do + before do + sign_in(admin) + gitlab_enable_admin_mode_sign_in(admin) + end - let(:abuse_report_row_selector) { '[data-testid="abuse-report-row"]' } + context 'when abuse_reports_list feature flag is enabled' do + include FilteredSearchHelpers before do - sign_in(admin) - gitlab_enable_admin_mode_sign_in(admin) - visit admin_abuse_reports_path end + let(:abuse_report_row_selector) { '[data-testid="abuse-report-row"]' } + it 'only includes open reports by default' do expect_displayed_reports_count(2) @@ -68,7 +70,8 @@ RSpec.describe "Admin::AbuseReports", :js, feature_category: :shared do end it 'can be sorted by created_at and updated_at in desc and asc order', :aggregate_failures do - # created_at desc (default) + sort_by 'Created date' + # created_at desc expect(report_rows[0].text).to include(report_text(open_report2)) expect(report_rows[1].text).to include(report_text(open_report)) @@ -78,25 +81,90 @@ RSpec.describe "Admin::AbuseReports", :js, feature_category: :shared do expect(report_rows[0].text).to include(report_text(open_report)) expect(report_rows[1].text).to include(report_text(open_report2)) - # updated_at ascending + # updated_at asc sort_by 'Updated date' expect(report_rows[0].text).to include(report_text(open_report2)) expect(report_rows[1].text).to include(report_text(open_report)) - # updated_at descending + # updated_at desc toggle_sort_direction expect(report_rows[0].text).to include(report_text(open_report)) expect(report_rows[1].text).to include(report_text(open_report2)) end + context 'when multiple reports for the same user are created' do + let_it_be(:open_report3) { create(:abuse_report, category: 'spam', user: user) } + let_it_be(:closed_report2) { create(:abuse_report, :closed, user: user, category: 'spam') } + + it 'aggregates open reports by user & category', :aggregate_failures do + expect_displayed_reports_count(2) + + expect_aggregated_report_shown(open_report, 2) + expect_report_shown(open_report2) + end + + it 'can sort aggregated reports by number_of_reports in desc order only', :aggregate_failures do + sort_by 'Number of Reports' + + expect(report_rows[0].text).to include(aggregated_report_text(open_report, 2)) + expect(report_rows[1].text).to include(report_text(open_report2)) + + toggle_sort_direction + + expect(report_rows[0].text).to include(aggregated_report_text(open_report, 2)) + expect(report_rows[1].text).to include(report_text(open_report2)) + end + + it 'can sort aggregated reports by created_at and updated_at in desc and asc order', :aggregate_failures do + # number_of_reports desc (default) + expect(report_rows[0].text).to include(aggregated_report_text(open_report, 2)) + expect(report_rows[1].text).to include(report_text(open_report2)) + + # created_at desc + sort_by 'Created date' + + expect(report_rows[0].text).to include(report_text(open_report2)) + expect(report_rows[1].text).to include(aggregated_report_text(open_report, 2)) + + # created_at asc + toggle_sort_direction + + expect(report_rows[0].text).to include(aggregated_report_text(open_report, 2)) + expect(report_rows[1].text).to include(report_text(open_report2)) + + sort_by 'Updated date' + + # updated_at asc + expect(report_rows[0].text).to include(report_text(open_report2)) + expect(report_rows[1].text).to include(aggregated_report_text(open_report, 2)) + + # updated_at desc + toggle_sort_direction + + expect(report_rows[0].text).to include(aggregated_report_text(open_report, 2)) + expect(report_rows[1].text).to include(report_text(open_report2)) + end + + it 'does not aggregate closed reports', :aggregate_failures do + filter %w[Status Closed] + + expect_displayed_reports_count(2) + expect_report_shown(closed_report, closed_report2) + end + end + def report_rows page.all(abuse_report_row_selector) end def report_text(report) - "#{report.user.name} reported for #{report.category}" + "#{report.user.name} reported for #{report.category} by #{report.reporter.name}" + end + + def aggregated_report_text(report, count) + "#{report.user.name} reported for #{report.category} by #{count} users" end def expect_report_shown(*reports) @@ -111,6 +179,12 @@ RSpec.describe "Admin::AbuseReports", :js, feature_category: :shared do end end + def expect_aggregated_report_shown(*reports, count) + reports.each do |r| + expect(page).to have_content(aggregated_report_text(r, count)) + end + end + def expect_displayed_reports_count(count) expect(page).to have_css(abuse_report_row_selector, count: count) end @@ -138,71 +212,30 @@ RSpec.describe "Admin::AbuseReports", :js, feature_category: :shared do before do stub_feature_flags(abuse_reports_list: false) - sign_in(admin) - gitlab_enable_admin_mode_sign_in(admin) + visit admin_abuse_reports_path end - describe 'if a user has been reported for abuse' do - let_it_be(:abuse_report) { create(:abuse_report, user: user) } - - describe 'in the abuse report view' do - before do - visit admin_abuse_reports_path - end - - it 'presents information about abuse report' do - expect(page).to have_content('Abuse Reports') - - expect(page).to have_content(user.name) - expect(page).to have_content(abuse_report.reporter.name) - expect(page).to have_content(abuse_report.message) - expect(page).to have_link(user.name, href: user_path(user)) - end - - it 'present actions items' do - expect(page).to have_link('Remove user & report') - expect(page).to have_link('Block user') - expect(page).to have_link('Remove user') - end - end + it 'displays all abuse reports', :aggregate_failures do + expect_report_shown(open_report) + expect_report_actions_shown(open_report) - describe 'in the profile page of the user' do - it 'shows a link to view user in the admin area' do - visit user_path(user) + expect_report_shown(open_report2) + expect_report_actions_shown(open_report2) - expect(page).to have_link 'View user in admin area', href: admin_user_path(user) - end - end + expect_report_shown(closed_report) + expect_report_actions_shown(closed_report) end - describe 'if an admin has been reported for abuse' do + context 'when an admin has been reported for abuse' do let_it_be(:admin_abuse_report) { create(:abuse_report, user: admin) } - describe 'in the abuse report view' do - before do - visit admin_abuse_reports_path - end - - it 'presents information about abuse report' do - page.within(:table_row, { "User" => admin.name }) do - expect(page).to have_content(admin.name) - expect(page).to have_content(admin_abuse_report.reporter.name) - expect(page).to have_content(admin_abuse_report.message) - expect(page).to have_link(admin.name, href: user_path(admin)) - end - end - - it 'does not present actions items' do - page.within(:table_row, { "User" => admin.name }) do - expect(page).not_to have_link('Remove user & report') - expect(page).not_to have_link('Block user') - expect(page).not_to have_link('Remove user') - end - end + it 'displays the abuse report without actions' do + expect_report_shown(admin_abuse_report) + expect_report_actions_not_shown(admin_abuse_report) end end - describe 'if a many users have been reported for abuse' do + context 'when multiple users have been reported for abuse' do let(:report_count) { AbuseReport.default_per_page + 3 } before do @@ -211,8 +244,8 @@ RSpec.describe "Admin::AbuseReports", :js, feature_category: :shared do end end - describe 'in the abuse report view' do - it 'presents information about abuse report' do + context 'in the abuse report view', :aggregate_failures do + it 'adds pagination' do visit admin_abuse_reports_path expect(page).to have_selector('.pagination') @@ -221,12 +254,8 @@ RSpec.describe "Admin::AbuseReports", :js, feature_category: :shared do end end - describe 'filtering by user' do - let!(:user2) { create(:user) } - let!(:abuse_report) { create(:abuse_report, user: user) } - let!(:abuse_report_2) { create(:abuse_report, user: user2) } - - it 'shows only single user report' do + context 'when filtering reports' do + it 'can be filtered by reported-user', :aggregate_failures do visit admin_abuse_reports_path page.within '.filter-form' do @@ -234,14 +263,39 @@ RSpec.describe "Admin::AbuseReports", :js, feature_category: :shared do wait_for_requests page.within '.dropdown-menu-user' do - click_link user2.name + click_link user.name end wait_for_requests end - expect(page).to have_content(user2.name) - expect(page).not_to have_content(user.name) + expect_report_shown(open_report) + expect_report_shown(closed_report) + end + end + + def expect_report_shown(report) + page.within(:table_row, { "User" => report.user.name, "Reported by" => report.reporter.name }) do + expect(page).to have_content(report.user.name) + expect(page).to have_content(report.reporter.name) + expect(page).to have_content(report.message) + expect(page).to have_link(report.user.name, href: user_path(report.user)) + end + end + + def expect_report_actions_shown(report) + page.within(:table_row, { "User" => report.user.name, "Reported by" => report.reporter.name }) do + expect(page).to have_link('Remove user & report') + expect(page).to have_link('Block user') + expect(page).to have_link('Remove user') + end + end + + def expect_report_actions_not_shown(report) + page.within(:table_row, { "User" => report.user.name, "Reported by" => report.reporter.name }) do + expect(page).not_to have_link('Remove user & report') + expect(page).not_to have_link('Block user') + expect(page).not_to have_link('Remove user') end end end diff --git a/spec/features/profiles/keys_spec.rb b/spec/features/profiles/keys_spec.rb index ae61f1cf492..b49d16603b2 100644 --- a/spec/features/profiles/keys_spec.rb +++ b/spec/features/profiles/keys_spec.rb @@ -27,7 +27,7 @@ RSpec.describe 'Profile > SSH Keys', feature_category: :user_profile do fill_in('Title', with: attrs[:title]) click_button('Add key') - expect(page).to have_content("Title: #{attrs[:title]}") + expect(page).to have_content(format(s_('Profiles|SSH Key: %{title}'), title: attrs[:title])) expect(page).to have_content(attrs[:key]) expect(find('[data-testid="breadcrumb-current-link"]')).to have_link(attrs[:title]) end diff --git a/spec/finders/abuse_reports_finder_spec.rb b/spec/finders/abuse_reports_finder_spec.rb index ee93d042ca2..0b641d0cb08 100644 --- a/spec/finders/abuse_reports_finder_spec.rb +++ b/spec/finders/abuse_reports_finder_spec.rb @@ -2,142 +2,205 @@ require 'spec_helper' -RSpec.describe AbuseReportsFinder, '#execute' do - let_it_be(:user1) { create(:user) } - let_it_be(:user2) { create(:user) } - let_it_be(:reporter) { create(:user) } - let_it_be(:abuse_report_1) { create(:abuse_report, id: 20, category: 'spam', user: user1) } - let_it_be(:abuse_report_2) do - create(:abuse_report, :closed, id: 30, category: 'phishing', user: user2, reporter: reporter) - end +RSpec.describe AbuseReportsFinder, feature_category: :insider_threat do + let_it_be(:user_1) { create(:user) } + let_it_be(:user_2) { create(:user) } - let(:params) { {} } + let_it_be(:reporter_1) { create(:user) } + let_it_be(:reporter_2) { create(:user) } - subject { described_class.new(params).execute } + let_it_be(:abuse_report_1) do + create(:abuse_report, :open, category: 'spam', user: user_1, reporter: reporter_1, id: 1) + end - context 'when params is empty' do - it 'returns all abuse reports' do - expect(subject).to match_array([abuse_report_1, abuse_report_2]) - end + let_it_be(:abuse_report_2) do + create(:abuse_report, :closed, category: 'phishing', user: user_2, reporter: reporter_2, id: 2) end - context 'when params[:user_id] is present' do - let(:params) { { user_id: user2 } } + let(:params) { {} } - it 'returns abuse reports for the specified user' do - expect(subject).to match_array([abuse_report_2]) - end - end + subject(:finder) { described_class.new(params).execute } - shared_examples 'returns filtered reports' do |filter_field| - it "returns abuse reports filtered by #{filter_field}_id" do - expect(subject).to match_array(filtered_reports) + describe '#execute' do + context 'when params is empty' do + it 'returns all abuse reports' do + expect(finder).to match_array([abuse_report_1, abuse_report_2]) + end end - context "when no user has username = params[:#{filter_field}]" do - before do - allow(User).to receive_message_chain(:by_username, :pick) - .with(params[filter_field]) - .with(:id) - .and_return(nil) + shared_examples 'returns filtered reports' do |filter_field| + it "returns abuse reports filtered by #{filter_field}_id" do + expect(finder).to match_array(filtered_reports) end - it 'returns all abuse reports' do - expect(subject).to match_array([abuse_report_1, abuse_report_2]) + context "when no user has username = params[:#{filter_field}]" do + before do + allow(User).to receive_message_chain(:by_username, :pick) + .with(params[filter_field]) + .with(:id) + .and_return(nil) + end + + it 'returns all abuse reports' do + expect(finder).to match_array([abuse_report_1, abuse_report_2]) + end end end - end - context 'when params[:user] is present' do - it_behaves_like 'returns filtered reports', :user do - let(:params) { { user: user1.username } } - let(:filtered_reports) { [abuse_report_1] } + context 'when params[:user] is present' do + it_behaves_like 'returns filtered reports', :user do + let(:params) { { user: user_1.username } } + let(:filtered_reports) { [abuse_report_1] } + end end - end - context 'when params[:reporter] is present' do - it_behaves_like 'returns filtered reports', :reporter do - let(:params) { { reporter: reporter.username } } - let(:filtered_reports) { [abuse_report_2] } + context 'when params[:reporter] is present' do + it_behaves_like 'returns filtered reports', :reporter do + let(:params) { { reporter: reporter_1.username } } + let(:filtered_reports) { [abuse_report_1] } + end end - end - context 'when params[:status] is present' do - context 'when value is "open"' do + context 'when params[:status] = open' do let(:params) { { status: 'open' } } it 'returns only open abuse reports' do - expect(subject).to match_array([abuse_report_1]) + expect(finder).to match_array([abuse_report_1]) end end - context 'when value is "closed"' do + context 'when params[:status] = closed' do let(:params) { { status: 'closed' } } it 'returns only closed abuse reports' do - expect(subject).to match_array([abuse_report_2]) + expect(finder).to match_array([abuse_report_2]) end end - context 'when value is not a valid status' do + context 'when params[:status] is not a valid status' do let(:params) { { status: 'partial' } } it 'defaults to returning open abuse reports' do - expect(subject).to match_array([abuse_report_1]) + expect(finder).to match_array([abuse_report_1]) end end - context 'when abuse_reports_list feature flag is disabled' do - before do - stub_feature_flags(abuse_reports_list: false) - end + context 'when params[:category] is present' do + let(:params) { { category: 'phishing' } } - it 'does not filter by status' do - expect(subject).to match_array([abuse_report_1, abuse_report_2]) + it 'returns abuse reports with the specified category' do + expect(subject).to match_array([abuse_report_2]) end end - end - context 'when params[:category] is present' do - let(:params) { { category: 'phishing' } } + describe 'aggregating reports' do + context 'when multiple open reports exist' do + let(:params) { { status: 'open' } } - it 'returns abuse reports with the specified category' do - expect(subject).to match_array([abuse_report_2]) - end - end + # same category and user as abuse_report_1 -> will get aggregated + let_it_be(:abuse_report_3) do + create(:abuse_report, :open, category: abuse_report_1.category, user: abuse_report_1.user, id: 3) + end - describe 'sorting' do - let(:params) { { sort: 'created_at_asc' } } + # different category, but same user as abuse_report_1 -> won't get aggregated + let_it_be(:abuse_report_4) do + create(:abuse_report, :open, category: 'phishing', user: abuse_report_1.user, id: 4) + end - it 'returns reports sorted by the specified sort attribute' do - expect(subject).to eq [abuse_report_1, abuse_report_2] - end + it 'aggregates open reports by user and category' do + expect(finder).to match_array([abuse_report_1, abuse_report_4]) + end + + it 'sorts by aggregated_count in descending order and created_at in descending order' do + expect(finder).to eq([abuse_report_1, abuse_report_4]) + end + + it 'returns count with aggregated reports' do + expect(finder[0].count).to eq(2) + end + + context 'when a different sorting attribute is given' do + let(:params) { { status: 'open', sort: 'created_at_desc' } } - context 'when sort is not specified' do - let(:params) { {} } + it 'returns reports sorted by the specified sort attribute' do + expect(subject).to eq([abuse_report_4, abuse_report_1]) + end + end - it "returns reports sorted by #{described_class::DEFAULT_SORT}" do - expect(subject).to eq [abuse_report_2, abuse_report_1] + context 'when params[:sort] is invalid' do + let(:params) { { status: 'open', sort: 'invalid' } } + + it 'sorts reports by aggregated_count in descending order' do + expect(finder).to eq([abuse_report_1, abuse_report_4]) + end + end end - end - context 'when sort is not supported' do - let(:params) { { sort: 'superiority' } } + context 'when multiple closed reports exist' do + let(:params) { { status: 'closed' } } + + # same user and category as abuse_report_2 -> won't get aggregated + let_it_be(:abuse_report_5) do + create(:abuse_report, :closed, category: abuse_report_2.category, user: abuse_report_2.user, id: 5) + end + + it 'does not aggregate closed reports' do + expect(finder).to match_array([abuse_report_2, abuse_report_5]) + end + + it 'sorts reports by created_at in descending order' do + expect(finder).to eq([abuse_report_5, abuse_report_2]) + end + + context 'when a different sorting attribute is given' do + let(:params) { { status: 'closed', sort: 'created_at_asc' } } - it "returns reports sorted by #{described_class::DEFAULT_SORT}" do - expect(subject).to eq [abuse_report_2, abuse_report_1] + it 'returns reports sorted by the specified sort attribute' do + expect(subject).to eq([abuse_report_2, abuse_report_5]) + end + end + + context 'when params[:sort] is invalid' do + let(:params) { { status: 'closed', sort: 'invalid' } } + + it 'sorts reports by created_at in descending order' do + expect(finder).to eq([abuse_report_5, abuse_report_2]) + end + end end end - context 'when abuse_reports_list feature flag is disabled' do - let_it_be(:abuse_report_3) { create(:abuse_report, id: 10) } - + context 'when legacy view is enabled' do before do stub_feature_flags(abuse_reports_list: false) end - it 'returns reports sorted by id in descending order' do - expect(subject).to eq [abuse_report_2, abuse_report_1, abuse_report_3] + context 'when params is empty' do + it 'returns all abuse reports' do + expect(subject).to match_array([abuse_report_1, abuse_report_2]) + end + end + + context 'when params[:user_id] is present' do + let(:params) { { user_id: user_1 } } + + it 'returns abuse reports for the specified user' do + expect(subject).to match_array([abuse_report_1]) + end + end + + context 'when sorting' do + it 'returns reports sorted by id in descending order' do + expect(subject).to match_array([abuse_report_2, abuse_report_1]) + end + end + + context 'when any of the new filters are present such as params[:status]' do + let(:params) { { status: 'open' } } + + it 'returns all abuse reports' do + expect(subject).to match_array([abuse_report_1, abuse_report_2]) + end end end end diff --git a/spec/frontend/access_tokens/components/new_access_token_app_spec.js b/spec/frontend/access_tokens/components/new_access_token_app_spec.js index fb92cc34ce9..70f77932ccf 100644 --- a/spec/frontend/access_tokens/components/new_access_token_app_spec.js +++ b/spec/frontend/access_tokens/components/new_access_token_app_spec.js @@ -69,6 +69,7 @@ describe('~/access_tokens/components/new_access_token_app', () => { const InputCopyToggleVisibilityComponent = wrapper.findComponent(InputCopyToggleVisibility); expect(InputCopyToggleVisibilityComponent.props('value')).toBe(newToken); + expect(InputCopyToggleVisibilityComponent.props('readonly')).toBe(true); expect(InputCopyToggleVisibilityComponent.props('copyButtonTitle')).toBe( sprintf(__('Copy %{accessTokenType}'), { accessTokenType }), ); diff --git a/spec/frontend/access_tokens/components/token_spec.js b/spec/frontend/access_tokens/components/token_spec.js index f62f7d72e3b..ad92366c3b6 100644 --- a/spec/frontend/access_tokens/components/token_spec.js +++ b/spec/frontend/access_tokens/components/token_spec.js @@ -50,6 +50,7 @@ describe('Token', () => { formInputGroupProps: { id: defaultPropsData.inputId, }, + readonly: true, value: defaultPropsData.token, copyButtonTitle: defaultPropsData.copyButtonTitle, }); diff --git a/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js b/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js index 03bf510f3ad..8482faccca0 100644 --- a/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js +++ b/spec/frontend/admin/abuse_reports/components/abuse_report_row_spec.js @@ -94,4 +94,19 @@ describe('AbuseReportRow', () => { it('renders abuse category', () => { expect(findAbuseCategory().exists()).toBe(true); }); + + describe('aggregated report', () => { + const mockAggregatedAbuseReport = mockAbuseReports[1]; + const { reportedUser, category, count } = mockAggregatedAbuseReport; + + beforeEach(() => { + createComponent({ report: mockAggregatedAbuseReport }); + }); + + it('displays title with number of aggregated reports', () => { + expect(findAbuseReportTitle().text()).toMatchInterpolatedText( + `${reportedUser.name} reported for ${category} by ${count} users`, + ); + }); + }); }); diff --git a/spec/frontend/admin/abuse_reports/components/abuse_reports_filtered_search_bar_spec.js b/spec/frontend/admin/abuse_reports/components/abuse_reports_filtered_search_bar_spec.js index 1f3f2caa995..dda9263d094 100644 --- a/spec/frontend/admin/abuse_reports/components/abuse_reports_filtered_search_bar_spec.js +++ b/spec/frontend/admin/abuse_reports/components/abuse_reports_filtered_search_bar_spec.js @@ -8,8 +8,10 @@ import { FILTERED_SEARCH_TOKEN_REPORTER, FILTERED_SEARCH_TOKEN_STATUS, FILTERED_SEARCH_TOKEN_CATEGORY, - DEFAULT_SORT, - SORT_OPTIONS, + DEFAULT_SORT_STATUS_OPEN, + DEFAULT_SORT_STATUS_CLOSED, + SORT_OPTIONS_STATUS_OPEN, + SORT_OPTIONS_STATUS_CLOSED, } from '~/admin/abuse_reports/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -53,8 +55,8 @@ describe('AbuseReportsFilteredSearchBar', () => { recentSearchesStorageKey: 'abuse_reports', searchInputPlaceholder: 'Filter reports', tokens: [...FILTERED_SEARCH_TOKENS, categoryToken], - initialSortBy: DEFAULT_SORT, - sortOptions: SORT_OPTIONS, + initialSortBy: DEFAULT_SORT_STATUS_OPEN, + sortOptions: SORT_OPTIONS_STATUS_OPEN, }); }); @@ -88,6 +90,10 @@ describe('AbuseReportsFilteredSearchBar', () => { expect(findFilteredSearchBar().props('initialFilterValue')).toMatchObject([ { + type: FILTERED_SEARCH_TOKEN_STATUS.type, + value: { data: 'closed', operator: '=' }, + }, + { type: FILTERED_SEARCH_TOKEN_USER.type, value: { data: 'mr_abuser', operator: '=' }, }, @@ -95,16 +101,12 @@ describe('AbuseReportsFilteredSearchBar', () => { type: FILTERED_SEARCH_TOKEN_REPORTER.type, value: { data: 'ms_nitch', operator: '=' }, }, - { - type: FILTERED_SEARCH_TOKEN_STATUS.type, - value: { data: 'closed', operator: '=' }, - }, ]); }); describe('initial sort', () => { it.each( - SORT_OPTIONS.flatMap(({ sortDirection: { descending, ascending } }) => [ + SORT_OPTIONS_STATUS_OPEN.flatMap(({ sortDirection: { descending, ascending } }) => [ descending, ascending, ]), @@ -115,16 +117,20 @@ describe('AbuseReportsFilteredSearchBar', () => { createComponent(); - expect(findFilteredSearchBar().props('initialSortBy')).toEqual(sortBy); + if (sortBy) { + expect(findFilteredSearchBar().props('initialSortBy')).toEqual(sortBy); + } else { + expect(findFilteredSearchBar().props('initialSortBy')).toEqual(DEFAULT_SORT_STATUS_OPEN); + } }, ); - it(`uses ${DEFAULT_SORT} as initialSortBy when sort query param is invalid`, () => { + it(`uses ${DEFAULT_SORT_STATUS_OPEN} as initialSortBy when sort query param is invalid`, () => { setWindowLocation(`?sort=unknown`); createComponent(); - expect(findFilteredSearchBar().props('initialSortBy')).toEqual(DEFAULT_SORT); + expect(findFilteredSearchBar().props('initialSortBy')).toEqual(DEFAULT_SORT_STATUS_OPEN); }); }); @@ -161,26 +167,39 @@ describe('AbuseReportsFilteredSearchBar', () => { (filterToken) => { createComponentAndFilter([filterToken]); const { type, value } = filterToken; - expect(redirectTo).toHaveBeenCalledWith(`https://localhost/?${type}=${value.data}`); // eslint-disable-line import/no-deprecated + + // eslint-disable-next-line import/no-deprecated + expect(redirectTo).toHaveBeenCalledWith( + `https://localhost/?${type}=${value.data}&sort=${DEFAULT_SORT_STATUS_OPEN}`, + ); }, ); it('ignores search query param', () => { const searchFilterToken = { type: FILTERED_SEARCH_TERM, value: { data: 'ignored' } }; createComponentAndFilter([USER_FILTER_TOKEN, searchFilterToken]); - expect(redirectTo).toHaveBeenCalledWith('https://localhost/?user=mr_abuser'); // eslint-disable-line import/no-deprecated + + // eslint-disable-next-line import/no-deprecated + expect(redirectTo).toHaveBeenCalledWith( + `https://localhost/?user=mr_abuser&sort=${DEFAULT_SORT_STATUS_OPEN}`, + ); }); it('redirects without page query param', () => { createComponentAndFilter([USER_FILTER_TOKEN], '?page=2'); - expect(redirectTo).toHaveBeenCalledWith('https://localhost/?user=mr_abuser'); // eslint-disable-line import/no-deprecated + + // eslint-disable-next-line import/no-deprecated + expect(redirectTo).toHaveBeenCalledWith( + `https://localhost/?user=mr_abuser&sort=${DEFAULT_SORT_STATUS_OPEN}`, + ); }); it('redirects with existing sort query param', () => { - createComponentAndFilter([USER_FILTER_TOKEN], `?sort=${DEFAULT_SORT}`); + createComponentAndFilter([USER_FILTER_TOKEN], `?sort=${DEFAULT_SORT_STATUS_OPEN}`); + // eslint-disable-next-line import/no-deprecated expect(redirectTo).toHaveBeenCalledWith( - `https://localhost/?user=mr_abuser&sort=${DEFAULT_SORT}`, + `https://localhost/?user=mr_abuser&sort=${DEFAULT_SORT_STATUS_OPEN}`, ); }); }); @@ -222,4 +241,42 @@ describe('AbuseReportsFilteredSearchBar', () => { ); }); }); + + describe('sortOptions', () => { + describe('when status is closed', () => { + beforeEach(() => { + setWindowLocation('?status=closed'); + + createComponent(); + }); + + it('only shows created_at & updated_at as sorting options', () => { + expect(findFilteredSearchBar().props('sortOptions')).toMatchObject( + SORT_OPTIONS_STATUS_CLOSED, + ); + }); + + it('initially sorts by created_at_desc', () => { + expect(findFilteredSearchBar().props('initialSortBy')).toEqual(DEFAULT_SORT_STATUS_CLOSED); + }); + }); + + describe('when status is open', () => { + beforeEach(() => { + setWindowLocation('?status=open'); + + createComponent(); + }); + + it('shows number of reports as an additional sorting option', () => { + expect(findFilteredSearchBar().props('sortOptions')).toMatchObject( + SORT_OPTIONS_STATUS_OPEN, + ); + }); + + it('initially sorts by number_of_reports_desc', () => { + expect(findFilteredSearchBar().props('initialSortBy')).toEqual(DEFAULT_SORT_STATUS_OPEN); + }); + }); + }); }); diff --git a/spec/frontend/admin/abuse_reports/mock_data.js b/spec/frontend/admin/abuse_reports/mock_data.js index 1ea6ea7d131..33a28a21cca 100644 --- a/spec/frontend/admin/abuse_reports/mock_data.js +++ b/spec/frontend/admin/abuse_reports/mock_data.js @@ -6,6 +6,7 @@ export const mockAbuseReports = [ reporter: { name: 'Ms. Admin' }, reportedUser: { name: 'Mr. Abuser' }, reportPath: '/admin/abuse_reports/1', + count: 1, }, { category: 'phishing', @@ -14,5 +15,6 @@ export const mockAbuseReports = [ reporter: { name: 'Ms. Reporter' }, reportedUser: { name: 'Mr. Phisher' }, reportPath: '/admin/abuse_reports/2', + count: 2, }, ]; diff --git a/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js b/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js index 0d9196b88ed..aef06a74fdd 100644 --- a/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js +++ b/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js @@ -6,12 +6,10 @@ import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import RecoveryCodes, { i18n, } from '~/authentication/two_factor_auth/components/recovery_codes.vue'; -import { - RECOVERY_CODE_DOWNLOAD_FILENAME, - COPY_KEYBOARD_SHORTCUT, -} from '~/authentication/two_factor_auth/constants'; +import { RECOVERY_CODE_DOWNLOAD_FILENAME } from '~/authentication/two_factor_auth/constants'; import Tracking from '~/tracking'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { MOUSETRAP_COPY_KEYBOARD_SHORTCUT } from '~/lib/mousetrap'; import { codes, codesFormattedString, codesDownloadHref, profileAccountPath } from '../mock_data'; describe('RecoveryCodes', () => { @@ -42,7 +40,7 @@ describe('RecoveryCodes', () => { const findPrintButton = () => findButtonByText('Print codes'); const findProceedButton = () => findButtonByText('Proceed'); const manuallyCopyRecoveryCodes = () => - wrapper.vm.$options.mousetrap.trigger(COPY_KEYBOARD_SHORTCUT); + wrapper.vm.$options.mousetrap.trigger(MOUSETRAP_COPY_KEYBOARD_SHORTCUT); beforeEach(() => { jest.spyOn(Tracking, 'event'); diff --git a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js index e4373d1c198..3fb845b186a 100644 --- a/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js +++ b/spec/frontend/ci/runner/components/registration/registration_dropdown_spec.js @@ -168,9 +168,8 @@ describe('RegistrationDropdown', () => { expect(findTokenDropdownItem().exists()).toBe(true); }); - it('Displays masked value by default', () => { + it('Displays masked value as password input by default', () => { const mockToken = '0123456789'; - const maskToken = '**********'; createComponent( { @@ -179,7 +178,7 @@ describe('RegistrationDropdown', () => { mountExtended, ); - expect(findRegistrationTokenInput().element.value).toBe(maskToken); + expect(findRegistrationTokenInput().element.type).toBe('password'); }); }); diff --git a/spec/frontend/ci/runner/components/registration/registration_token_spec.js b/spec/frontend/ci/runner/components/registration/registration_token_spec.js index fd3896d5500..eccfe43b47f 100644 --- a/spec/frontend/ci/runner/components/registration/registration_token_spec.js +++ b/spec/frontend/ci/runner/components/registration/registration_token_spec.js @@ -38,10 +38,15 @@ describe('RegistrationToken', () => { ); }); + it('Renders readonly input', () => { + createComponent(); + + expect(findInputCopyToggleVisibility().props('readonly')).toBe(true); + }); + // Component integration test to ensure secure masking - it('Displays masked value by default', () => { + it('Displays masked value as password input by default', () => { const mockToken = '0123456789'; - const maskToken = '**********'; createComponent({ props: { @@ -50,7 +55,7 @@ describe('RegistrationToken', () => { mountFn: mountExtended, }); - expect(wrapper.find('input').element.value).toBe(maskToken); + expect(wrapper.find('input').element.type).toBe('password'); }); describe('When the copy to clipboard button is clicked', () => { diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index fb5cf4dfd0a..2ad04a7c371 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -11,7 +11,7 @@ import CompareVersions from '~/diffs/components/compare_versions.vue'; import DiffFile from '~/diffs/components/diff_file.vue'; import NoChanges from '~/diffs/components/no_changes.vue'; import findingsDrawer from '~/diffs/components/shared/findings_drawer.vue'; -import TreeList from '~/diffs/components/tree_list.vue'; +import DiffsFileTree from '~/diffs/components/diffs_file_tree.vue'; import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue'; import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue'; @@ -252,34 +252,6 @@ describe('diffs/components/app', () => { }); }); - describe('resizable', () => { - afterEach(() => { - localStorage.removeItem('mr_tree_list_width'); - }); - - it('sets initial width when no localStorage has been set', () => { - createComponent(); - - expect(wrapper.vm.treeWidth).toEqual(320); - }); - - it('sets initial width to localStorage size', () => { - localStorage.setItem('mr_tree_list_width', '200'); - - createComponent(); - - expect(wrapper.vm.treeWidth).toEqual(200); - }); - - it('sets width of tree list', () => { - createComponent({}, ({ state }) => { - state.diffs.treeEntries = { 111: { type: 'blob', fileHash: '111', path: '111.js' } }; - }); - - expect(wrapper.find('.js-diff-tree-list').element.style.width).toEqual('320px'); - }); - }); - it('marks current diff file based on currently highlighted row', async () => { window.location.hash = 'ABC_123'; @@ -596,18 +568,21 @@ describe('diffs/components/app', () => { ); }); - it("doesn't render tree list when no changes exist", () => { + it('should always render diffs file tree', () => { createComponent(); - - expect(wrapper.findComponent(TreeList).exists()).toBe(false); + expect(wrapper.findComponent(DiffsFileTree).exists()).toBe(true); }); - it('should render tree list', () => { + it('should pass renderDiffFiles to file tree as true when files are present', () => { createComponent({}, ({ state }) => { state.diffs.treeEntries = { 111: { type: 'blob', fileHash: '111', path: '111.js' } }; }); + expect(wrapper.findComponent(DiffsFileTree).props('renderDiffFiles')).toBe(true); + }); - expect(wrapper.findComponent(TreeList).exists()).toBe(true); + it('should pass renderDiffFiles to file tree as false without files', () => { + createComponent(); + expect(wrapper.findComponent(DiffsFileTree).props('renderDiffFiles')).toBe(false); }); }); diff --git a/spec/frontend/diffs/components/compare_versions_spec.js b/spec/frontend/diffs/components/compare_versions_spec.js index cbbfd88260b..3601f0cc7b0 100644 --- a/spec/frontend/diffs/components/compare_versions_spec.js +++ b/spec/frontend/diffs/components/compare_versions_spec.js @@ -84,7 +84,7 @@ describe('CompareVersions', () => { const treeListBtn = wrapper.find('.js-toggle-tree-list'); expect(treeListBtn.exists()).toBe(true); - expect(treeListBtn.attributes('title')).toBe('Hide file browser'); + expect(treeListBtn.attributes('title')).toBe('Hide file browser (or press F)'); expect(treeListBtn.props('icon')).toBe('file-tree'); }); diff --git a/spec/frontend/diffs/components/diffs_file_tree_spec.js b/spec/frontend/diffs/components/diffs_file_tree_spec.js new file mode 100644 index 00000000000..a79023a07cb --- /dev/null +++ b/spec/frontend/diffs/components/diffs_file_tree_spec.js @@ -0,0 +1,116 @@ +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { Mousetrap } from '~/lib/mousetrap'; +import DiffsFileTree from '~/diffs/components/diffs_file_tree.vue'; +import TreeList from '~/diffs/components/tree_list.vue'; +import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; +import { SET_SHOW_TREE_LIST } from '~/diffs/store/mutation_types'; +import createDiffsStore from '../create_diffs_store'; + +describe('DiffsFileTree', () => { + let wrapper; + let store; + + const createComponent = ({ renderDiffFiles = true, showTreeList = true } = {}) => { + store = createDiffsStore(); + store.commit(`diffs/${SET_SHOW_TREE_LIST}`, showTreeList); + wrapper = shallowMount(DiffsFileTree, { + store, + propsData: { + renderDiffFiles, + }, + }); + }; + + describe('visibility', () => { + describe('when renderDiffFiles and showTreeList are true', () => { + beforeEach(() => { + createComponent(); + }); + + it('tree list is visible', () => { + expect(wrapper.findComponent(TreeList).exists()).toBe(true); + }); + }); + + describe('when renderDiffFiles and showTreeList are false', () => { + beforeEach(() => { + createComponent({ renderDiffFiles: false, showTreeList: false }); + }); + + it('tree list is hidden', () => { + expect(wrapper.findComponent(TreeList).exists()).toBe(false); + }); + }); + }); + + it('emits toggled event', async () => { + createComponent(); + store.commit(`diffs/${SET_SHOW_TREE_LIST}`, false); + await nextTick(); + expect(wrapper.emitted('toggled')).toStrictEqual([[]]); + }); + + it('toggles when "f" hotkey is pressed', async () => { + createComponent(); + Mousetrap.trigger('f'); + await nextTick(); + expect(wrapper.findComponent(TreeList).exists()).toBe(false); + }); + + describe('size', () => { + const checkWidth = (width) => { + expect(wrapper.element.style.width).toEqual(`${width}px`); + expect(wrapper.findComponent(PanelResizer).props('startSize')).toEqual(width); + }; + + afterEach(() => { + localStorage.removeItem('mr_tree_list_width'); + }); + + describe('when no localStorage record is set', () => { + beforeEach(() => { + createComponent(); + }); + + it('sets initial width when no localStorage has been set', () => { + checkWidth(320); + }); + }); + + it('sets initial width to localStorage size', () => { + localStorage.setItem('mr_tree_list_width', '200'); + createComponent(); + checkWidth(200); + }); + + it('sets width of tree list', () => { + createComponent({}, ({ state }) => { + state.diffs.treeEntries = { 111: { type: 'blob', fileHash: '111', path: '111.js' } }; + }); + checkWidth(320); + }); + + it('updates width', async () => { + const WIDTH = 500; + createComponent(); + wrapper.findComponent(PanelResizer).vm.$emit('update:size', WIDTH); + await nextTick(); + checkWidth(WIDTH); + }); + + it('passes down hideFileStats as true when width is less than 260', async () => { + createComponent(); + wrapper.findComponent(PanelResizer).vm.$emit('update:size', 200); + await nextTick(); + expect(wrapper.findComponent(TreeList).props('hideFileStats')).toBe(true); + }); + + it('passes down hideFileStats as false when width is bigger than 260', async () => { + createComponent(); + wrapper.findComponent(PanelResizer).vm.$emit('update:size', 300); + await nextTick(); + expect(wrapper.findComponent(TreeList).props('hideFileStats')).toBe(false); + }); + }); +}); diff --git a/spec/frontend/oauth_application/components/oauth_secret_spec.js b/spec/frontend/oauth_application/components/oauth_secret_spec.js index c38bd066da8..5ad55c1e81b 100644 --- a/spec/frontend/oauth_application/components/oauth_secret_spec.js +++ b/spec/frontend/oauth_application/components/oauth_secret_spec.js @@ -47,6 +47,10 @@ describe('OAuthSecret', () => { it('shows the renew secret button', () => { expect(findRenewSecretButton().exists()).toBe(true); }); + + it('renders secret in readonly input', () => { + expect(findInputCopyToggleVisibility().props('readonly')).toBe(true); + }); }); describe('when secret is not provided', () => { diff --git a/spec/frontend/super_sidebar/components/flyout_menu_spec.js b/spec/frontend/super_sidebar/components/flyout_menu_spec.js new file mode 100644 index 00000000000..b894d29c875 --- /dev/null +++ b/spec/frontend/super_sidebar/components/flyout_menu_spec.js @@ -0,0 +1,25 @@ +import { shallowMount } from '@vue/test-utils'; +import FlyoutMenu from '~/super_sidebar/components/flyout_menu.vue'; + +jest.mock('@floating-ui/dom'); + +describe('FlyoutMenu', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(FlyoutMenu, { + propsData: { + targetId: 'section-1', + items: [], + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders the component', () => { + expect(wrapper.exists()).toBe(true); + }); +}); diff --git a/spec/frontend/super_sidebar/components/menu_section_spec.js b/spec/frontend/super_sidebar/components/menu_section_spec.js index 556e07a2e31..dd729d8fd6a 100644 --- a/spec/frontend/super_sidebar/components/menu_section_spec.js +++ b/spec/frontend/super_sidebar/components/menu_section_spec.js @@ -2,6 +2,7 @@ import { GlCollapse } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import MenuSection from '~/super_sidebar/components/menu_section.vue'; import NavItem from '~/super_sidebar/components/nav_item.vue'; +import FlyoutMenu from '~/super_sidebar/components/flyout_menu.vue'; import { stubComponent } from 'helpers/stub_component'; describe('MenuSection component', () => { @@ -9,6 +10,7 @@ describe('MenuSection component', () => { const findButton = () => wrapper.find('button'); const findCollapse = () => wrapper.getComponent(GlCollapse); + const findFlyout = () => wrapper.findComponent(FlyoutMenu); const findNavItems = () => wrapper.findAllComponents(NavItem); const createWrapper = (item, otherProps) => { wrapper = shallowMountExtended(MenuSection, { @@ -68,6 +70,40 @@ describe('MenuSection component', () => { }); }); + describe('flyout behavior', () => { + describe('when hasFlyout is false', () => { + it('is not rendered', () => { + createWrapper({ title: 'Asdf' }, { 'has-flyout': false }); + expect(findFlyout().exists()).toBe(false); + }); + }); + + describe('when hasFlyout is true', () => { + it('is rendered', () => { + createWrapper({ title: 'Asdf' }, { 'has-flyout': true }); + expect(findFlyout().exists()).toBe(true); + }); + + describe('on mouse hover', () => { + describe('when section is expanded', () => { + it('is not shown', async () => { + createWrapper({ title: 'Asdf' }, { 'has-flyout': true, expanded: true }); + await findButton().trigger('pointerover', { pointerType: 'mouse' }); + expect(findFlyout().isVisible()).toBe(false); + }); + }); + + describe('when section is not expanded', () => { + it('is shown', async () => { + createWrapper({ title: 'Asdf' }, { 'has-flyout': true, expanded: false }); + await findButton().trigger('pointerover', { pointerType: 'mouse' }); + expect(findFlyout().isVisible()).toBe(true); + }); + }); + }); + }); + }); + describe('`separated` prop', () => { describe('by default (false)', () => { it('does not render a separator', () => { diff --git a/spec/frontend/super_sidebar/components/pinned_section_spec.js b/spec/frontend/super_sidebar/components/pinned_section_spec.js index fd6e2b7343e..00cc7cf29c9 100644 --- a/spec/frontend/super_sidebar/components/pinned_section_spec.js +++ b/spec/frontend/super_sidebar/components/pinned_section_spec.js @@ -2,10 +2,12 @@ import { nextTick } from 'vue'; import Cookies from '~/lib/utils/cookies'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import PinnedSection from '~/super_sidebar/components/pinned_section.vue'; +import MenuSection from '~/super_sidebar/components/menu_section.vue'; import NavItem from '~/super_sidebar/components/nav_item.vue'; import { SIDEBAR_PINS_EXPANDED_COOKIE, SIDEBAR_COOKIE_EXPIRATION } from '~/super_sidebar/constants'; import { setCookie } from '~/lib/utils/common_utils'; +jest.mock('@floating-ui/dom'); jest.mock('~/lib/utils/common_utils', () => ({ getCookie: jest.requireActual('~/lib/utils/common_utils').getCookie, setCookie: jest.fn(), @@ -16,10 +18,11 @@ describe('PinnedSection component', () => { const findToggle = () => wrapper.find('button'); - const createWrapper = () => { + const createWrapper = (props = {}) => { wrapper = mountExtended(PinnedSection, { propsData: { items: [{ title: 'Pin 1', href: '/page1' }], + ...props, }, }); }; @@ -72,4 +75,16 @@ describe('PinnedSection component', () => { }); }); }); + + describe('hasFlyout prop', () => { + describe.each([true, false])(`when %s`, (hasFlyout) => { + beforeEach(() => { + createWrapper({ hasFlyout }); + }); + + it(`passes ${hasFlyout} to the section's hasFlyout prop`, () => { + expect(wrapper.findComponent(MenuSection).props('hasFlyout')).toBe(hasFlyout); + }); + }); + }); }); diff --git a/spec/frontend/super_sidebar/components/sidebar_menu_spec.js b/spec/frontend/super_sidebar/components/sidebar_menu_spec.js index 21e5220edd9..ac94f3f8f82 100644 --- a/spec/frontend/super_sidebar/components/sidebar_menu_spec.js +++ b/spec/frontend/super_sidebar/components/sidebar_menu_spec.js @@ -1,3 +1,4 @@ +import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SidebarMenu from '~/super_sidebar/components/sidebar_menu.vue'; import PinnedSection from '~/super_sidebar/components/pinned_section.vue'; @@ -15,9 +16,13 @@ const menuItems = [ describe('Sidebar Menu', () => { let wrapper; + let flyoutFlag = false; const createWrapper = (extraProps = {}) => { wrapper = shallowMountExtended(SidebarMenu, { + provide: { + glFeatures: { superSidebarFlyoutMenus: flyoutFlag }, + }, propsData: { items: sidebarData.current_menu_items, pinnedItemIds: sidebarData.pinned_items, @@ -117,6 +122,65 @@ describe('Sidebar Menu', () => { ); }); }); + + describe('flyout menus', () => { + describe('when feature is disabled', () => { + beforeEach(() => { + createWrapper({ + items: menuItems, + }); + }); + + it('does not add flyout menus to sections', () => { + expect(findNonStaticSectionItems().wrappers.map((w) => w.props('hasFlyout'))).toEqual([ + false, + false, + ]); + }); + }); + + describe('when feature is enabled', () => { + beforeEach(() => { + flyoutFlag = true; + }); + + describe('when screen width is smaller than "md" breakpoint', () => { + beforeEach(() => { + jest.spyOn(GlBreakpointInstance, 'windowWidth').mockImplementation(() => { + return 767; + }); + createWrapper({ + items: menuItems, + }); + }); + + it('does not add flyout menus to sections', () => { + expect(findNonStaticSectionItems().wrappers.map((w) => w.props('hasFlyout'))).toEqual([ + false, + false, + ]); + }); + }); + + describe('when screen width is equal or larger than "md" breakpoint', () => { + beforeEach(() => { + jest.spyOn(GlBreakpointInstance, 'windowWidth').mockImplementation(() => { + return 768; + }); + createWrapper({ + items: menuItems, + }); + }); + + it('adds flyout menus to sections', () => { + expect(findNonStaticSectionItems().wrappers.map((w) => w.props('hasFlyout'))).toEqual([ + true, + true, + ]); + }); + }); + }); + }); }); describe('Separators', () => { diff --git a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js index 4f1603f93ba..7afa8e9b8dc 100644 --- a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js +++ b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js @@ -1,11 +1,12 @@ +import { nextTick } from 'vue'; import { merge } from 'lodash'; import { GlFormInputGroup } from '@gitlab/ui'; import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; - import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { MOUSETRAP_COPY_KEYBOARD_SHORTCUT } from '~/lib/mousetrap'; describe('InputCopyToggleVisibility', () => { let wrapper; @@ -40,6 +41,18 @@ describe('InputCopyToggleVisibility', () => { return event; }; + const triggerCopyShortcut = () => { + wrapper.vm.$options.mousetrap.trigger(MOUSETRAP_COPY_KEYBOARD_SHORTCUT); + }; + + function expectInputToBeMasked() { + expect(findFormInput().element.type).toBe('password'); + } + + function expectInputToBeRevealed() { + expect(findFormInput().element.type).toBe('text'); + expect(findFormInput().element.value).toBe(valueProp); + } const itDoesNotModifyCopyEvent = () => { it('does not modify copy event', () => { @@ -61,29 +74,55 @@ describe('InputCopyToggleVisibility', () => { }); }); - it('displays value as hidden', () => { - expect(findFormInput().element.value).toBe('********************'); + it('hides the value with a password input', () => { + expectInputToBeMasked(); }); - it('saves actual value to clipboard when manually copied', () => { - const event = createCopyEvent(); - findFormInput().element.dispatchEvent(event); + it('emits `copy` event and sets clipboard when copying token via keyboard shortcut', async () => { + const writeTextSpy = jest.spyOn(global.navigator.clipboard, 'writeText'); - expect(event.clipboardData.setData).toHaveBeenCalledWith('text/plain', valueProp); - expect(event.preventDefault).toHaveBeenCalled(); - }); - - it('emits `copy` event when manually copied the token', () => { expect(wrapper.emitted('copy')).toBeUndefined(); - findFormInput().element.dispatchEvent(createCopyEvent()); + triggerCopyShortcut(); + await nextTick(); - expect(wrapper.emitted()).toHaveProperty('copy'); - expect(wrapper.emitted('copy')).toHaveLength(1); expect(wrapper.emitted('copy')[0]).toEqual([]); + expect(writeTextSpy).toHaveBeenCalledWith(valueProp); }); + describe('copy button', () => { + it('renders button with correct props passed', () => { + expect(findCopyButton().props()).toMatchObject({ + text: valueProp, + title: 'Copy', + }); + }); + + describe('when clicked', () => { + beforeEach(async () => { + await findCopyButton().trigger('click'); + }); + + it('emits `copy` event', () => { + expect(wrapper.emitted()).toHaveProperty('copy'); + expect(wrapper.emitted('copy')).toHaveLength(1); + expect(wrapper.emitted('copy')[0]).toEqual([]); + }); + }); + }); + }); + + describe('when input is readonly', () => { describe('visibility toggle button', () => { + beforeEach(() => { + createComponent({ + propsData: { + value: valueProp, + readonly: true, + }, + }); + }); + it('renders a reveal button', () => { const revealButton = findRevealButton(); @@ -103,7 +142,7 @@ describe('InputCopyToggleVisibility', () => { }); it('displays value', () => { - expect(findFormInput().element.value).toBe(valueProp); + expectInputToBeRevealed(); }); it('renders a hide button', () => { @@ -127,78 +166,161 @@ describe('InputCopyToggleVisibility', () => { }); }); - describe('copy button', () => { - it('renders button with correct props passed', () => { - expect(findCopyButton().props()).toMatchObject({ - text: valueProp, - title: 'Copy', + describe('when `initialVisibility` prop is `true`', () => { + const label = 'My label'; + beforeEach(() => { + createComponent({ + propsData: { + value: valueProp, + initialVisibility: true, + readonly: true, + label, + 'label-for': 'my-input', + formInputGroupProps: { + id: 'my-input', + }, + }, }); }); - describe('when clicked', () => { - beforeEach(async () => { - await findCopyButton().trigger('click'); + it('displays value', () => { + expectInputToBeRevealed(); + }); + + itDoesNotModifyCopyEvent(); + + describe('when input is clicked', () => { + it('selects input value', async () => { + const mockSelect = jest.fn(); + wrapper.vm.$refs.input.$el.select = mockSelect; + await findFormInput().trigger('click'); + + expect(mockSelect).toHaveBeenCalled(); }); + }); - it('emits `copy` event', () => { - expect(wrapper.emitted()).toHaveProperty('copy'); - expect(wrapper.emitted('copy')).toHaveLength(1); - expect(wrapper.emitted('copy')[0]).toEqual([]); + describe('when label is clicked', () => { + it('selects input value', async () => { + const mockSelect = jest.fn(); + wrapper.vm.$refs.input.$el.select = mockSelect; + await wrapper.find('label').trigger('click'); + + expect(mockSelect).toHaveBeenCalled(); }); }); }); }); - describe('when `value` prop is not passed', () => { - beforeEach(() => { - createComponent(); - }); + describe('when input is editable', () => { + describe('and no `value` prop is passed', () => { + beforeEach(() => { + createComponent({ + propsData: { + value: '', + readonly: false, + }, + }); + }); - it('displays value as hidden with 20 asterisks', () => { - expect(findFormInput().element.value).toBe('********************'); - }); - }); + it('displays value', () => { + expect(findRevealButton().exists()).toBe(false); + expect(findHideButton().exists()).toBe(true); - describe('when `initialVisibility` prop is `true`', () => { - const label = 'My label'; + const input = findFormInput(); + input.element.value = valueProp; + input.trigger('input'); - beforeEach(() => { - createComponent({ - propsData: { - value: valueProp, - initialVisibility: true, - label, - 'label-for': 'my-input', - formInputGroupProps: { - id: 'my-input', - }, - }, + expectInputToBeRevealed(); }); }); - it('displays value', () => { - expect(findFormInput().element.value).toBe(valueProp); - }); + describe('and `value` prop is passed', () => { + beforeEach(() => { + createComponent({ + propsData: { + value: valueProp, + readonly: false, + }, + }); + }); - itDoesNotModifyCopyEvent(); + it('renders a reveal button', () => { + const revealButton = findRevealButton(); - describe('when input is clicked', () => { - it('selects input value', async () => { - const mockSelect = jest.fn(); - wrapper.vm.$refs.input.$el.select = mockSelect; - await wrapper.findByLabelText(label).trigger('click'); + expect(revealButton.exists()).toBe(true); + + const tooltip = getBinding(revealButton.element, 'gl-tooltip'); - expect(mockSelect).toHaveBeenCalled(); + expect(tooltip.value).toBe(InputCopyToggleVisibility.i18n.toggleVisibilityLabelReveal); }); - }); - describe('when label is clicked', () => { - it('selects input value', async () => { - const mockSelect = jest.fn(); - wrapper.vm.$refs.input.$el.select = mockSelect; - await wrapper.find('label').trigger('click'); + it('renders a hide button once revealed', async () => { + const revealButton = findRevealButton(); + await revealButton.trigger('click'); + await nextTick(); + + const hideButton = findHideButton(); + expect(hideButton.exists()).toBe(true); + + const tooltip = getBinding(hideButton.element, 'gl-tooltip'); + + expect(tooltip.value).toBe(InputCopyToggleVisibility.i18n.toggleVisibilityLabelHide); + }); + + it('emits `input` event when editing', () => { + expect(wrapper.emitted('input')).toBeUndefined(); + const newVal = 'ding!'; + + const input = findFormInput(); + input.element.value = newVal; + input.trigger('input'); + + expect(wrapper.emitted()).toHaveProperty('input'); + expect(wrapper.emitted('input')).toHaveLength(1); + expect(wrapper.emitted('input')[0][0]).toBe(newVal); + }); + + it('copies updated value to clipboard after editing', async () => { + const writeTextSpy = jest.spyOn(global.navigator.clipboard, 'writeText'); + + triggerCopyShortcut(); + await nextTick(); - expect(mockSelect).toHaveBeenCalled(); + expect(wrapper.emitted('copy')).toHaveLength(1); + expect(writeTextSpy).toHaveBeenCalledWith(valueProp); + + const updatedValue = 'wow amazing'; + wrapper.setProps({ value: updatedValue }); + await nextTick(); + + triggerCopyShortcut(); + await nextTick(); + + expect(wrapper.emitted('copy')).toHaveLength(2); + expect(writeTextSpy).toHaveBeenCalledWith(updatedValue); + }); + + describe('when input is clicked', () => { + it('shows the actual value', async () => { + const input = findFormInput(); + + expectInputToBeMasked(); + await findFormInput().trigger('click'); + + expect(input.element.value).toBe(valueProp); + }); + + it('ensures the selection start/end are in the correct position once the actual value has been revealed', async () => { + const input = findFormInput(); + const selectionStart = 2; + const selectionEnd = 4; + + input.element.setSelectionRange(selectionStart, selectionEnd); + await input.trigger('click'); + + expect(input.element.selectionStart).toBe(selectionStart); + expect(input.element.selectionEnd).toBe(selectionEnd); + }); }); }); }); @@ -219,7 +341,7 @@ describe('InputCopyToggleVisibility', () => { }); it('displays value', () => { - expect(findFormInput().element.value).toBe(valueProp); + expectInputToBeRevealed(); }); itDoesNotModifyCopyEvent(); diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb index afabcc49017..092d3c07716 100644 --- a/spec/lib/gitlab/regex_spec.rb +++ b/spec/lib/gitlab/regex_spec.rb @@ -74,6 +74,18 @@ RSpec.describe Gitlab::Regex, feature_category: :tooling do it { is_expected.to eq("can contain only letters, digits, emoji, '_', '.', dash, space, parenthesis. It must start with letter, digit, emoji or '_'.") } end + describe '.slack_link_regex' do + subject { described_class.slack_link_regex } + + it { is_expected.not_to match('http://custom-url.com|click here') } + it { is_expected.not_to match('custom-url.com|any-Charact3r$') } + it { is_expected.not_to match("<custom-url.com|any-Charact3r$>") } + + it { is_expected.to match('<http://custom-url.com|click here>') } + it { is_expected.to match('<custom-url.com|any-Charact3r$>') } + it { is_expected.to match('<any-Charact3r$|any-Charact3r$>') } + end + describe '.bulk_import_destination_namespace_path_regex_message' do subject { described_class.bulk_import_destination_namespace_path_regex_message } diff --git a/spec/lib/gitlab/usage/metrics/instrumentations/work_items_activity_aggregated_metric_spec.rb b/spec/lib/gitlab/usage/metrics/instrumentations/work_items_activity_aggregated_metric_spec.rb deleted file mode 100644 index 35e5d7f2796..00000000000 --- a/spec/lib/gitlab/usage/metrics/instrumentations/work_items_activity_aggregated_metric_spec.rb +++ /dev/null @@ -1,72 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Usage::Metrics::Instrumentations::WorkItemsActivityAggregatedMetric do - let(:metric_definition) do - { - data_source: 'redis_hll', - time_frame: time_frame, - options: { - aggregate: { - operator: 'OR' - }, - events: %w[ - users_creating_work_items - users_updating_work_item_title - users_updating_work_item_dates - users_updating_work_item_labels - users_updating_work_item_milestone - users_updating_work_item_iteration - ] - } - } - end - - around do |example| - freeze_time { example.run } - end - - where(:time_frame) { [['28d'], ['7d']] } - - with_them do - describe '#available?' do - it 'returns false without track_work_items_activity feature' do - stub_feature_flags(track_work_items_activity: false) - - expect(described_class.new(metric_definition).available?).to eq(false) - end - - it 'returns true with track_work_items_activity feature' do - stub_feature_flags(track_work_items_activity: true) - - expect(described_class.new(metric_definition).available?).to eq(true) - end - end - - describe '#value', :clean_gitlab_redis_shared_state do - let(:counter) { Gitlab::UsageDataCounters::HLLRedisCounter } - let(:author1_id) { 1 } - let(:author2_id) { 2 } - let(:event_time) { 1.week.ago } - - before do - counter.track_event(:users_creating_work_items, values: author1_id, time: event_time) - end - - it 'has correct value after events are tracked', :aggregate_failures do - expect do - counter.track_event(:users_updating_work_item_title, values: author1_id, time: event_time) - counter.track_event(:users_updating_work_item_dates, values: author1_id, time: event_time) - counter.track_event(:users_updating_work_item_labels, values: author1_id, time: event_time) - counter.track_event(:users_updating_work_item_milestone, values: author1_id, time: event_time) - end.to not_change { described_class.new(metric_definition).value } - - expect do - counter.track_event(:users_updating_work_item_iteration, values: author2_id, time: event_time) - counter.track_event(:users_updating_weight_estimate, values: author1_id, time: event_time) - end.to change { described_class.new(metric_definition).value }.from(1).to(2) - end - end - end -end diff --git a/spec/lib/slack_markdown_sanitizer_spec.rb b/spec/lib/slack_markdown_sanitizer_spec.rb index f4042439213..d9552542465 100644 --- a/spec/lib/slack_markdown_sanitizer_spec.rb +++ b/spec/lib/slack_markdown_sanitizer_spec.rb @@ -20,4 +20,21 @@ RSpec.describe SlackMarkdownSanitizer, feature_category: :integrations do end end end + + describe '.sanitize_slack_link' do + using RSpec::Parameterized::TableSyntax + + where(:input, :output) do + '' | '' + '[label](url)' | '[label](url)' + '<url|label>' | '<url|label>' + '<a href="url">label</a>' | '<a href="url">label</a>' + end + + with_them do + it 'returns the expected output' do + expect(described_class.sanitize_slack_link(input)).to eq(output) + end + end + end end diff --git a/spec/models/abuse_report_spec.rb b/spec/models/abuse_report_spec.rb index 6192a271028..584f9b010ad 100644 --- a/spec/models/abuse_report_spec.rb +++ b/spec/models/abuse_report_spec.rb @@ -164,6 +164,34 @@ RSpec.describe AbuseReport, feature_category: :insider_threat do expect(described_class.by_category('phishing')).to match_array([report2]) end end + + describe '.aggregated_by_user_and_category' do + let_it_be(:report3) { create(:abuse_report, category: report1.category, user: report1.user) } + let_it_be(:report4) { create(:abuse_report, category: 'phishing', user: report1.user) } + let_it_be(:report5) { create(:abuse_report, category: report1.category, user: build(:user)) } + + let_it_be(:sort_by_count) { true } + + subject(:aggregated) { described_class.aggregated_by_user_and_category(sort_by_count) } + + context 'when sort_by_count = true' do + it 'sorts by aggregated_count in descending order and created_at in descending order' do + expect(aggregated).to eq([report1, report5, report4, report]) + end + + it 'returns count with aggregated reports' do + expect(aggregated[0].count).to eq(2) + end + end + + context 'when sort_by_count = false' do + let_it_be(:sort_by_count) { false } + + it 'does not sort using a specific order' do + expect(aggregated).to match_array([report, report1, report4, report5]) + end + end + end end describe 'before_validation' do diff --git a/spec/models/integrations/chat_message/issue_message_spec.rb b/spec/models/integrations/chat_message/issue_message_spec.rb index cd40e4c361e..14451427a5a 100644 --- a/spec/models/integrations/chat_message/issue_message_spec.rb +++ b/spec/models/integrations/chat_message/issue_message_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Integrations::ChatMessage::IssueMessage do +RSpec.describe Integrations::ChatMessage::IssueMessage, feature_category: :integrations do subject { described_class.new(args) } let(:args) do @@ -24,7 +24,7 @@ RSpec.describe Integrations::ChatMessage::IssueMessage do url: 'http://url.com', action: 'open', state: 'opened', - description: 'issue description' + description: 'issue description <http://custom-url.com|CLICK HERE>' } } end @@ -45,7 +45,7 @@ RSpec.describe Integrations::ChatMessage::IssueMessage do end context 'open' do - it 'returns a message regarding opening of issues' do + it 'returns a slack-link sanitized message regarding opening of issues' do expect(subject.pretext).to eq( '[<http://somewhere.com|project_name>] Issue <http://url.com|#100 Issue title> opened by Test User (test.user)') expect(subject.attachments).to eq( @@ -53,7 +53,7 @@ RSpec.describe Integrations::ChatMessage::IssueMessage do { title: "#100 Issue title", title_link: "http://url.com", - text: "issue description", + text: "issue description <http://custom-url.com|CLICK HERE>", color: color } ]) @@ -96,7 +96,7 @@ RSpec.describe Integrations::ChatMessage::IssueMessage do it 'returns a message regarding opening of issues' do expect(subject.pretext).to eq( '[[project_name](http://somewhere.com)] Issue [#100 Issue title](http://url.com) opened by Test User (test.user)') - expect(subject.attachments).to eq('issue description') + expect(subject.attachments).to eq('issue description <http://custom-url.com|CLICK HERE>') expect(subject.activity).to eq({ title: 'Issue opened by Test User (test.user)', subtitle: 'in [project_name](http://somewhere.com)', diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index e99d77dc0a0..d56bc210d82 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -795,6 +795,24 @@ RSpec.describe Note, feature_category: :team_planning do expect(note.system_note_visible_for?(nil)).to be_truthy end end + + context 'when referenced resource is not present' do + let(:note) do + create :note, noteable: ext_issue, project: ext_proj, note: "mentioned in merge request !1", system: true + end + + it "returns true for other users" do + expect(note.system_note_visible_for?(private_user)).to be_truthy + end + + it "returns true if user visible reference count set" do + note.user_visible_reference_count = 0 + note.total_reference_count = 0 + + expect(note).not_to receive(:reference_mentionables) + expect(note.system_note_visible_for?(ext_issue.author)).to be_truthy + end + end end describe '#system_note_with_references?' do diff --git a/spec/serializers/admin/abuse_report_entity_spec.rb b/spec/serializers/admin/abuse_report_entity_spec.rb index 003d76a172f..c7f57258f40 100644 --- a/spec/serializers/admin/abuse_report_entity_spec.rb +++ b/spec/serializers/admin/abuse_report_entity_spec.rb @@ -19,6 +19,7 @@ RSpec.describe Admin::AbuseReportEntity, feature_category: :insider_threat do :category, :created_at, :updated_at, + :count, :reported_user, :reporter, :report_path diff --git a/spec/support/shared_examples/features/runners_shared_examples.rb b/spec/support/shared_examples/features/runners_shared_examples.rb index 54a4db0e81d..0c043f48c5f 100644 --- a/spec/support/shared_examples/features/runners_shared_examples.rb +++ b/spec/support/shared_examples/features/runners_shared_examples.rb @@ -57,7 +57,7 @@ RSpec.shared_examples 'shows and resets runner registration token' do click_on dropdown_text click_on 'Click to reveal' - expect(old_registration_token).not_to eq registration_token + expect(find_field('token-value').value).not_to eq old_registration_token end end end diff --git a/spec/support/shared_examples/usage_data_counters/work_item_activity_unique_counter_shared_examples.rb b/spec/support/shared_examples/usage_data_counters/work_item_activity_unique_counter_shared_examples.rb index 4655585a092..83119046377 100644 --- a/spec/support/shared_examples/usage_data_counters/work_item_activity_unique_counter_shared_examples.rb +++ b/spec/support/shared_examples/usage_data_counters/work_item_activity_unique_counter_shared_examples.rb @@ -1,41 +1,27 @@ # frozen_string_literal: true -RSpec.shared_examples 'counter that does not track the event' do - it 'does not track the event' do - expect { 3.times { track_event } }.to not_change { +RSpec.shared_examples 'work item unique counter' do + it 'tracks a unique event only once' do + expect { 3.times { track_event } }.to change { Gitlab::UsageDataCounters::HLLRedisCounter.unique_events( event_names: event_name, start_date: 2.weeks.ago, end_date: 2.weeks.from_now ) - } + }.by(1) end -end -RSpec.shared_examples 'work item unique counter' do - context 'when track_work_items_activity FF is enabled' do - it 'tracks a unique event only once' do - expect { 3.times { track_event } }.to change { + context 'when author is nil' do + let(:user) { nil } + + it 'does not track the event' do + expect { 3.times { track_event } }.to not_change { Gitlab::UsageDataCounters::HLLRedisCounter.unique_events( event_names: event_name, start_date: 2.weeks.ago, end_date: 2.weeks.from_now ) - }.by(1) + } end - - context 'when author is nil' do - let(:user) { nil } - - it_behaves_like 'counter that does not track the event' - end - end - - context 'when track_work_items_activity FF is disabled' do - before do - stub_feature_flags(track_work_items_activity: false) - end - - it_behaves_like 'counter that does not track the event' end end |